heat
triwarp.heat
Heat-diffusion methods on triangle meshes.
Three solvers that share one idea: diffuse a quantity over the surface for a short time t, then
recover the answer from the direction of the resulting field rather than its magnitude. Short-time
heat flow approximates the geodesic kernel, so a single sparse solve carries information that a
combinatorial shortest-path search would have to walk edge by edge. Each solver differs only in what
it diffuses and how it reads the result back:
- Geodesic distance.
heat_geodesicdiffuses a scalar indicator from source vertices, normalizes its gradient, and integrates that unit field back with a Poisson solve. This is the heat method of Crane et al. (igl::heat_geodesics,potpourri3d.MeshHeatMethodDistanceSolver). - Signed distance to curves.
heat_signed_distancediffuses the normals of a set of oriented curves, then solves a Poisson problem against that field to get a signed distance whose zero set is the curves (potpourri3d.MeshSignedHeatSolver). - Vector-valued transport.
transport_tangent_vectors,extend_scalarandlog_mapdiffuse tangent vectors through the connection Laplacian, which transports each vector into its neighbour's frame before differencing (potpourri3d.MeshVectorHeatSolver).
The three are one module rather than three because they are one family and because two of them
cannot be separated: the vector solvers import heat_operators
and heat_geodesic,
VectorHeatOperators embeds the scalar method's operator
tuple, and log_map's radius is heat_geodesic's answer.
All three run in float64, because the diffused field decays exponentially and underflows
float32. They run on either device.
The operators these solvers assemble are not here -- the cotangent and connection Laplacians
live in triwarp.laplacian, tangent frames in
triwarp.tangent_space, and the batched conjugate-gradient machinery in
triwarp.linalg. This module is the three algorithms only.
HeatOperators
module-attribute
HeatOperators = tuple[
wps.BsrMatrix[wp.float64],
wpl.LinearOperator,
wps.BsrMatrix[wp.float64],
wps.BsrMatrix[wp.float64],
wpl.LinearOperator,
twt.Array2dFloat32,
wp.array[wp.vec3],
wp.array[wp.float32],
]
What heat_operators returns for the heat method's
solves.
VectorHeatOperators
module-attribute
VectorHeatOperators = tuple[
wps.BsrMatrix[wp.float64],
HeatOperators,
tuple[
wp.array[wp.vec3],
wp.array[wp.vec3],
wp.array[wp.vec3],
],
wpl.LinearOperator,
]
What vector_heat_operators returns: the vector
heat system, the scalar heat_operators, the frames, and the
vector system's own Jacobi preconditioner.
heat_operators
heat_operators(
vertices: array[vec3],
faces: array[int32],
t: float | None = None,
*,
use_robust: bool = False,
cot_entries: Array2dFloat | None = None,
) -> HeatOperators
Assemble the source-independent operators the heat method solves against.
Every quantity here depends on the mesh alone, not on the source set, so a caller computing
distance from many different sources on one mesh can build these once and pass them back through
heat_geodesic(..., operators=...). That is the split
potpourri3d.MeshHeatMethodDistanceSolver and igl::heat_geodesics expose as a stateful
solver object; here it stays a plain tuple of buffers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
t
|
float | None
|
Diffusion time. When |
None
|
use_robust
|
bool
|
Build the Laplacian from mollified edge lengths
( This is mollification only, not the intrinsic Delaunay retriangulation that
|
False
|
cot_entries
|
Array2dFloat | None
|
Optional precomputed |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
heat_system |
BsrMatrix
|
|
heat_preconditioner |
``warp.optim.linear.LinearOperator``
|
Jacobi preconditioner for |
laplacian |
BsrMatrix
|
The |
poisson_system |
BsrMatrix
|
|
poisson_preconditioner |
``warp.optim.linear.LinearOperator``
|
Jacobi preconditioner for |
cot_entries |
Array2dFloat32
|
|
face_normals |
array[vec3]
|
One unit normal per face. |
face_areas |
array[float32]
|
One area per face. |
Notes
The Poisson operator and both Jacobi preconditioners are here because they satisfy this
function's own contract — they depend on the mesh alone — and
heat_geodesic used to rebuild all three on every call, which is
unnecessary work whenever the mesh is unchanged across several solves. It is only those
three — the solver state is deliberately not cached, because a warp.optim.linear state
captures its right-hand-side and solution buffers at construction, which would make these
operators stateful and unsafe to share between two concurrent solves. The remaining lever for
repeated calls is conditioning — a preconditioner stronger than Jacobi on a cotangent operator —
the same conclusion the constrained-solve family reaches in
harmonic.
Note
solve_spd warm-starts from whatever the solution buffer already holds, so reusing
one buffer across unrelated right-hand sides carries over the previous solution as the
initial guess. And this tuple's third field is the raw (singular) Laplacian, not the
Poisson system — handing it a right-hand side runs CG to its iteration cap.
Raises:
| Type | Description |
|---|---|
ValueError
|
If both |
RuntimeError
|
If |
Source code in triwarp/heat.py
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | |
heat_geodesic
heat_geodesic(
vertices: array[vec3],
faces: array[int32],
sources: array[int32],
t: float | None = None,
operators: HeatOperators | None = None,
*,
use_robust: bool = False,
) -> wp.array[wp.float64]
Approximate geodesic distance to the nearest source vertex (Crane et al. heat method).
Diffuses heat from the source vertices for a short time t, normalizes the resulting
gradient into a unit vector field pointing away from the sources, and integrates it back into a
distance field by solving a Poisson problem. Both solves are sparse, symmetric positive
(semi-)definite systems handled on-device by conjugate gradient. The result is an
approximation of the true geodesic distance (typically a few percent error), matching
igl::heat_geodesics.
The computation runs in float64: the diffused heat decays exponentially away from the
source and would underflow float32, collapsing the far field.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
sources
|
array[int32]
|
|
required |
t
|
float | None
|
Diffusion time. When |
None
|
operators
|
HeatOperators | None
|
Optional precomputed |
None
|
use_robust
|
bool
|
Forwarded to |
False
|
Returns:
| Type | Description |
|---|---|
array[float64]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Source code in triwarp/heat.py
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 | |
heat_signed_distance
heat_signed_distance(
vertices: array[vec3],
faces: array[int32],
curve_vertices: array[int32],
curve_offsets: array[int32] | None = None,
t: float | None = None,
*,
closed: bool = True,
level_set_constraint: str = "zero_set",
operators: VectorHeatOperators | None = None,
) -> wp.array[wp.float64]
Signed distance from every vertex to a set of oriented curves.
The result is positive inside the region a counter-clockwise curve encloses and negative outside it, with the curve itself at (or near) zero — geometry-central's convention. Orientation is what fixes the sign: reversing a curve's vertex order negates the whole field.
Three stages, mirroring potpourri3d.MeshSignedHeatSolver.compute_distance:
- each curve segment splats its normal onto its two endpoints, weighted by half its length;
- the connection Laplacian diffuses that tangent field for a short time
tand it is normalized, giving a unit field that approximates the signed distance's gradient; - a Poisson solve integrates the field back into a scalar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
curve_vertices
|
array[int32]
|
Vertex indices along the curves, packed one curve after another. Consecutive entries should be adjacent on the mesh; nothing breaks if they are not, but the source is then splatted along a chord rather than along the surface. |
required |
curve_offsets
|
array[int32] | None
|
Length |
None
|
t
|
float | None
|
Diffusion time; defaults to the squared mean edge length. Larger values smooth the field. |
None
|
closed
|
bool
|
Whether each curve closes back on its first vertex (adding one more segment). Signed distance is most meaningful for closed curves; an open curve gives a field whose sign flips across it but which has no consistent far-field meaning. |
True
|
level_set_constraint
|
str
|
|
'zero_set'
|
operators
|
VectorHeatOperators | None
|
Optional precomputed
|
None
|
Returns:
| Type | Description |
|---|---|
array[float64]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Source code in triwarp/heat.py
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 | |
vector_heat_operators
vector_heat_operators(
vertices: array[vec3],
faces: array[int32],
t: float | None = None,
*,
scalar_operators: HeatOperators | None = None,
frames: tuple[array[vec3], array[vec3], array[vec3]]
| None = None,
) -> VectorHeatOperators
Assemble everything the vector-valued solvers need before their solves.
Four pieces, none of which depends on a source:
- the vector heat system
M + t * L_connection, whose2 x 2blocks act on tangent vectors (connection_laplacian); - the scalar
heat_operators, for the magnitude extension and the distance field the log map needs; - the
vertex_tangent_framesevery 2D component is measured in; - the vector system's own Jacobi preconditioner.
Pass the result back through any solver's operators= argument to skip the assembly of all
four on every call after the first. That is the split potpourri3d.MeshVectorHeatSolver gets
from being an object; here it stays a plain tuple.
diffuse_tangent_field threads the fourth piece into
solve_spd's own preconditioner=, so a caller running many
transports or log maps against one mesh pays for it once rather than on
every diffusion solve.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
t
|
float | None
|
Diffusion time for both the vector and the scalar systems. When |
None
|
scalar_operators
|
HeatOperators | None
|
Optional prebuilt |
None
|
frames
|
tuple[array[vec3], array[vec3], array[vec3]] | None
|
Optional prebuilt |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
vector_system |
BsrMatrix
|
|
scalar |
tuple
|
The |
frames |
tuple[array[vec3], array[vec3], array[vec3]]
|
|
preconditioner |
``warp.optim.linear.LinearOperator``
|
Jacobi preconditioner for |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Notes
The vector and scalar systems must share t: log_map's
radius is asserted to be the heat_geodesic distance,
so two diffusion times would split a quantity that is supposed to be one number. That is why
scalar_operators is the one argument here that cannot be validated.
Source code in triwarp/heat.py
683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 | |
extend_scalar
extend_scalar(
vertices: array[vec3],
faces: array[int32],
sources: array[int32],
values: array[float64],
t: float | None = None,
operators: HeatOperators | None = None,
) -> wp.array[wp.float64]
Extend values from a few source vertices over the whole surface by nearest-source interpolation.
Diffuses the values and an indicator of where they came from for the same short time, then
divides one by the other. The ratio is what makes the result interpolate rather than decay: both
numerator and denominator fall off away from the sources at the same rate, so their quotient
stays close to the value of the nearest source, and blends smoothly where two sources compete.
Matches potpourri3d.MeshVectorHeatSolver.extend_scalar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
sources
|
array[int32]
|
|
required |
values
|
array[float64]
|
|
required |
t
|
float | None
|
Diffusion time; defaults to the squared mean edge length. |
None
|
operators
|
HeatOperators | None
|
Optional precomputed |
None
|
Returns:
| Type | Description |
|---|---|
array[float64]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
See Also
Source code in triwarp/heat.py
789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 | |
transport_tangent_vectors
transport_tangent_vectors(
vertices: array[vec3],
faces: array[int32],
sources: array[int32],
vectors: array[vec2],
t: float | None = None,
operators: VectorHeatOperators | None = None,
) -> tuple[wp.array[wp.vec2], wp.array[wp.bool]]
Parallel-transport tangent vectors from a few source vertices to every vertex.
Three solves, following the vector heat method: the connection Laplacian diffuses the source
vectors (which preserves their directions well but smears their magnitudes), while a scalar
extension of the source magnitudes supplies the length. The result at each vertex is the source
vector carried along the shortest path to it — the field a "drag this arrow across the surface"
tool needs. Matches potpourri3d.MeshVectorHeatSolver.transport_tangent_vectors, which
returns the vectors alone.
The second return exists because the vectors are not self-describing: a zero is ambiguous and a
non-zero one is not always meaningful. A vertex is unresolved when the diffused direction that
reached it is shorter than 1e-07 of the field's maximum — a decade above the round-off left
where the transported copies cancel, measured at 8.7e-09 of the maximum. Below that line a
direction cannot be told from noise, and the mask says so rather than the value being altered:
no vector here is changed by it. Three situations it separates:
- Nothing reached the vertex — another connected component, or short-time diffusion underflowing. Unresolved, and the vector is zero.
- The cut locus — several shortest paths arrive and their copies cancel. Unresolved, but the vector may still have full length, pointing wherever the round-off landed. This is the case that differs between CPU and CUDA, and the one a caller cannot otherwise detect.
- An ordinary vertex. Resolved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
sources
|
array[int32]
|
|
required |
vectors
|
array[vec2]
|
|
required |
t
|
float | None
|
Diffusion time; defaults to the squared mean edge length. Ignored when |
None
|
operators
|
VectorHeatOperators | None
|
Optional precomputed
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
transported |
array[vec2]
|
|
resolved |
array[bool]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
See Also
Notes
The direction is undefined on the cut locus
Where several shortest paths of equal length arrive, the copies they carry cancel, and what
is left is round-off rather than a direction. This is not a pathological case: the corner of
a cube shell diagonally opposite the source receives three copies 120 degrees apart whose
sum is exactly zero, for every source vector and every diffusion time. What comes back is
then decided by the arithmetic — on CUDA enough round-off survives to be scaled up to full
length in an arbitrary direction, while on CPU the same point can cancel to exactly zero and
read as unreached. resolved is False on both, and is the only way to tell.
Source code in triwarp/heat.py
873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 | |
log_map
log_map(
vertices: array[vec3],
faces: array[int32],
source: int,
t: float | None = None,
operators: VectorHeatOperators | None = None,
) -> wp.array[wp.vec2]
Logarithmic map: every vertex's position in the source vertex's tangent plane.
log_map(...)[v] is the 2D point in the source's frame whose length is the geodesic
distance to v, and whose direction is the initial direction of the geodesic that reaches
v. It is the inverse of the exponential map
trace_from_vertex
computes, and the standard way to lay out a local coordinate patch around a point.
Assembled from two fields that are each cheap: the distance to the source
(heat_geodesic) gives the radius, and the source's
reference direction parallel-transported outwards gives the angle — at any vertex the angle
between that transported direction and the outward radial direction is exactly the angle at
which the connecting geodesic left the source, because transport along that geodesic preserves
it. This is
the VectorHeat strategy in potpourri3d.MeshVectorHeatSolver.compute_log_map; its
AffineLocal and AffineAdaptive strategies solve a small dense problem per vertex and are
deliberately not ported.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
source
|
int
|
Index of the vertex the map is centred on. |
required |
t
|
float | None
|
Diffusion time; defaults to the squared mean edge length. Ignored when |
None
|
operators
|
VectorHeatOperators | None
|
Optional precomputed
|
None
|
Returns:
| Type | Description |
|---|---|
array[vec2]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Source code in triwarp/heat.py
1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 | |
tangent_to_world
tangent_to_world(
tangent: array[vec2],
basis_x: array[vec3],
basis_y: array[vec3],
) -> wp.array[wp.vec3]
Expand per-vertex tangent vectors into 3D using their frames.
The only way to compare a tangent field against another library's: each library measures 2D
components from its own reference direction, but a * basis_x + b * basis_y is the same 3D
vector either way.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tangent
|
array[vec2]
|
|
required |
basis_x
|
array[vec3]
|
The frames those components refer to, from
|
required |
basis_y
|
array[vec3]
|
The frames those components refer to, from
|
required |
Returns:
| Type | Description |
|---|---|
array[vec3]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
See Also
Source code in triwarp/heat.py
1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 | |
diffuse_tangent_field
diffuse_tangent_field(
system: BsrMatrix[float64],
source: array[vec2d],
*,
preconditioner: LinearOperator | None = None,
) -> wp.array[wp.vec2d]
Short-time diffusion of a tangent-vector field: solve (M + t L_connection) X = source.
Public because the source term is where the vector-valued methods differ from one another — a
handful of vertices for parallel transport, a whole splatted curve for
heat_signed_distance — while the solve is the same
for all of them.
Only the directions of the result carry meaning: magnitudes decay away from the source, and every caller replaces them, either with a scalar extension or by normalizing outright.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
system
|
BsrMatrix[float64]
|
The vector heat system from
|
required |
source
|
array[vec2d]
|
|
required |
preconditioner
|
LinearOperator | None
|
Optional Jacobi preconditioner for |
None
|
Returns:
| Type | Description |
|---|---|
array[vec2d]
|
|
Source code in triwarp/heat.py
1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 | |