tangent_space
triwarp.tangent_space
Per-vertex tangent spaces: orthonormal frames, halfedge polar angles and transport rotations.
Vector-valued surface algorithms (parallel transport, the vector heat method, log maps) need a 2D
coordinate system per vertex plus a rule for re-expressing a vector from one vertex's coordinates in
its neighbour's. Both come from intrinsic flattening: laying a vertex's incident triangles out in
the plane and rescaling the total angle to 2 * pi (pi at a boundary vertex), which turns the
counter-clockwise halfedge ring from triwarp.halfedge into polar coordinates
around the vertex.
Three quantities, in the order they build on each other:
vertex_tangent_frames— an extrinsic(basis_x, basis_y, normal)triple per vertex, for moving between 2D tangent coordinates and 3D. This is whatpotpourri3d.MeshVectorHeatSolver.get_tangent_framesreturns.halfedge_tangent_angles— the polar angle of each outgoing halfedge, measured from that vertex'sbasis_x.halfedge_transport_angles— the rotation that carries a tangent vector across an edge, the off-diagonal phase of the connection Laplacian.
Frames are only defined up to a rotation within the tangent plane, so they are not comparable across libraries element-wise; the transport angles inherit that gauge freedom and are comparable only through gauge-invariant combinations such as the holonomy around a face.
vertex_tangent_frames
vertex_tangent_frames(
vertices: array[vec3],
faces: array[int32],
normals: array[vec3] | None = None,
rings: tuple[array[int32], array[int32], array[bool]]
| None = None,
) -> tuple[
wp.array[wp.vec3], wp.array[wp.vec3], wp.array[wp.vec3]
]
Orthonormal tangent frame at every vertex as (basis_x, basis_y, normal).
normal is the angle-weighted vertex normal
(vertex_normals at weighting="angle");
basis_x is the first halfedge of the vertex's counter-clockwise ring, projected into the
tangent plane and normalized; basis_y = normal x basis_x completes a right-handed frame. A
tangent vector (a, b) in these coordinates is a * basis_x + b * basis_y in world space.
Because basis_x follows the ring's first halfedge, it is the direction that
halfedge_tangent_angles assigns polar angle
0: the two functions describe one coordinate system, not two. Which halfedge that is depends
on the face ordering, so a frame agrees with another library's only up to a rotation about the
normal. A vertex with no incident faces has no tangent plane; it gets the fixed frame ((1, 0,
0), (0, 1, 0)) so that downstream normalizations see unit vectors rather than NaN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
normals
|
array[vec3] | None
|
Optional precomputed |
None
|
rings
|
tuple[array[int32], array[int32], array[bool]] | None
|
Optional precomputed |
None
|
Returns:
| Type | Description |
|---|---|
tuple[array[vec3], array[vec3], array[vec3]]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
See Also
halfedge_tangent_angles
vertex_normals at weighting="angle"
vertex_one_rings
Source code in triwarp/tangent_space.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 | |
face_tangent_frames
face_tangent_frames(
vertices: array[vec3],
faces: array[int32],
normals: array[vec3] | None = None,
) -> tuple[
wp.array[wp.vec3], wp.array[wp.vec3], wp.array[wp.vec3]
]
Orthonormal tangent frame on every face as (basis_x, basis_y, normal).
basis_x is the face's first edge v1 - v0 normalized, normal its unit face normal and
basis_y = normal x basis_x. A tangent vector (a, b) in these coordinates is
a * basis_x + b * basis_y in world space.
Unlike vertex_tangent_frames, this frame is
not gauge-dependent: the first edge of a face is a property of the face table, so any library
using the same rule produces the identical frame rather than one rotated about the normal.
igl.local_basis uses exactly this rule, which is why tests/test_tangent_space.py can
compare all three vectors element-wise instead of through an invariant.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
normals
|
array[vec3] | None
|
Optional precomputed |
None
|
Returns:
| Type | Description |
|---|---|
tuple[array[vec3], array[vec3], array[vec3]]
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
See Also
vertex_tangent_frames
face_normals_and_areas
igl.local_basis
Source code in triwarp/tangent_space.py
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 | |
halfedge_tangent_angles
halfedge_tangent_angles(
vertices: array[vec3],
faces: array[int32],
face_angles: Array2dFloat32 | None = None,
rings: tuple[array[int32], array[int32], array[bool]]
| None = None,
) -> wp.array[wp.float32]
Polar angle of every outgoing halfedge in its origin vertex's tangent plane.
Walking a vertex's counter-clockwise ring, halfedge k sits at
theta_k = s * sum(alpha_j for j < k) where alpha_j are the incident corner angles and
s = 2 * pi / Theta scales the total angle Theta to a full turn — s = pi / Theta at a
boundary vertex, whose fan covers a half-disk. The first halfedge of each ring therefore has
angle 0, aligning this coordinate system with basis_x from
vertex_tangent_frames.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
face_angles
|
Array2dFloat32 | None
|
Optional precomputed |
None
|
rings
|
tuple[array[int32], array[int32], array[bool]] | None
|
Optional precomputed |
None
|
Returns:
| Type | Description |
|---|---|
array[float32]
|
Length |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Source code in triwarp/tangent_space.py
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 | |
halfedge_transport_angles
halfedge_transport_angles(
vertices: array[vec3],
faces: array[int32],
twins: array[int32] | None = None,
tangent_angles: array[float32] | None = None,
) -> wp.array[wp.float32]
Rotation that carries a tangent vector across each halfedge, in (-pi, pi].
For halfedge h from i to j, a vector expressed in i's tangent basis is expressed
in j's by rotating it through rho = theta_ji + pi - theta_ij, where theta are the
halfedge_tangent_angles of the halfedge and
its twin: the two vertices see the shared edge along directions that differ by pi, and the
rest is the mismatch between their reference directions. These are the off-diagonal phases of
the connection Laplacian, -w_ij * exp(i * rho_ij).
A boundary edge exists as a halfedge in one direction only. At its destination vertex it is the
fan-closing edge, whose rescaled polar angle is pi, and that value stands in for the missing
twin's angle.
Because each theta is measured from its own vertex's reference direction, rho is
gauge-dependent: rotating one vertex's frame shifts every incident rho. Sums around a closed
loop — the holonomy — are gauge-invariant and are what cross-library comparisons must use.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
vertices
|
array[vec3]
|
|
required |
faces
|
array[int32]
|
Length- |
required |
twins
|
array[int32] | None
|
Optional precomputed |
None
|
tangent_angles
|
array[float32] | None
|
Optional precomputed
|
None
|
Returns:
| Type | Description |
|---|---|
array[float32]
|
Length |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Source code in triwarp/tangent_space.py
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 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 | |