Skip to content

transform

triwarp.transform

Affine transforms: build a 4x4 matrix, apply it to a buffer, and classify what it preserves.

Three layers, and which one a caller wants depends on what they hold. The buffer ops (transform_points and its vector / normal / mesh siblings) take a matrix and an array and support in-place operation through out=. The builders (translation_matrix, rotation_matrix, scale_matrix, reflection_matrix) compose the matrix on the host, each about an optional center. And classify_transform answers what a matrix preserves -- lengths, angles, orientation -- which is what Trimesh.transform reads to decide how much of its cache survives.

Points, vectors and normals are three different maps and picking the wrong one is a silent error rather than a loud one:

quantity maps by translation
position M applied
displacement, tangent linear block of M ignored
normal inverse transpose of the linear block ignored

The first two agree on everything but the translation, so a normal pushed through transform_vectors is correct under any isometry and tilts off the surface under a shear or a non-uniform scale -- measured 37 degrees off on a diag(2, 1, 1) scale of the plane x + y = 0. Only unit scale hides it, which is why the distinction gets its own entry point.

See Also

triwarp.registration Fits a transform (procrustes, icp); this module applies one.

ORTHOGONALITY_RTOL module-attribute

ORTHOGONALITY_RTOL = 1e-05

TransformKind

Bases: StrEnum

What an affine transform preserves, ordered from most structure to least.

Each member's guarantees include every later member's: a TRANSLATION is a RIGID motion whose rotation is the identity, and a RIGID motion is a SIMILARITY of scale 1. The ordering is what makes this useful as a cache key -- see Trimesh.transform.

Attributes:

Name Type Description
IDENTITY

Changes nothing.

TRANSLATION

Identity linear block. Preserves lengths, angles, orientation and every direction, so normals and tangent frames are unchanged rather than merely rotated.

RIGID

Orthogonal linear block with positive determinant: a rotation, optionally with a translation. Preserves lengths, angles and orientation; rotates directions.

REFLECTION

Orthogonal linear block with negative determinant and unit scale. Preserves lengths and unsigned angles but reverses orientation, so face winding must be flipped to keep normals outward. A mirroring transform that also scales is SIMILARITY, not this.

SIMILARITY

Orthogonal up to a single factor: a uniform scale, optionally rotated, mirrored and translated. Preserves angles and therefore cotangent weights; scales lengths by transform_scale and areas by its square. May or may not reverse orientation -- unlike the other members this says nothing about that, so read the determinant sign separately.

AFFINE

An invertible map that is none of the above -- a non-uniform scale or a shear. Being a bijection it still preserves connectivity, incidence and self-intersection; nothing metric.

SINGULAR

Not an invertible affine map: a rank-deficient linear block, or a bottom row that makes the matrix projective rather than affine. Flattens the mesh, so even the predicates a bijection would preserve are gone and only connectivity survives.

IDENTITY class-attribute instance-attribute

IDENTITY = 'identity'

TRANSLATION class-attribute instance-attribute

TRANSLATION = 'translation'

RIGID class-attribute instance-attribute

RIGID = 'rigid'

REFLECTION class-attribute instance-attribute

REFLECTION = 'reflection'

SIMILARITY class-attribute instance-attribute

SIMILARITY = 'similarity'

AFFINE class-attribute instance-attribute

AFFINE = 'affine'

SINGULAR class-attribute instance-attribute

SINGULAR = 'singular'

transform_points

transform_points(
    points: array[vec3],
    matrix: mat44 | array[mat44],
    *,
    out: array[vec3] | None = None,
) -> wp.array[wp.vec3]

Apply an affine transform to a buffer of positions.

Parameters:

Name Type Description Default
points array[vec3]

(n,) positions.

required
matrix mat44 | array[mat44]

4x4 transform, as a scalar wp.mat44 or a (1,) wp.array[wp.mat44]. The array form keeps a fitted transform on the device, so an icp result can be applied without a host readback.

required
out array[vec3] | None

Destination, allocated when None. Pass out=points to transform in place.

None

Returns:

Type Description
array[vec3]

out, or a freshly allocated (n,) buffer on points.device.

Raises:

Type Description
ValueError

If out is given and its length differs from points, or matrix is a device array without exactly one entry.

RuntimeError

If points, matrix and out are not all on one device.

Notes

Safe in place: each thread reads exactly the element it writes.

See Also

transform_vectors transform_normals transform_mesh

Source code in triwarp/transform.py
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
def transform_points(
    points: wp.array[wp.vec3],
    matrix: wp.mat44 | wp.array[wp.mat44],
    *,
    out: wp.array[wp.vec3] | None = None,
) -> wp.array[wp.vec3]:
    """
    Apply an affine transform to a buffer of positions.

    Parameters
    ----------
    points
        ``(n,)`` positions.
    matrix
        ``4x4`` transform, as a scalar ``wp.mat44`` or a ``(1,)`` ``wp.array[wp.mat44]``. The
        array form keeps a fitted transform on the device, so an
        [`icp`][triwarp.registration.icp] result can be applied without a host readback.
    out
        Destination, allocated when ``None``. Pass ``out=points`` to transform in place.

    Returns
    -------
    wp.array[wp.vec3]
        ``out``, or a freshly allocated ``(n,)`` buffer on ``points.device``.

    Raises
    ------
    ValueError
        If ``out`` is given and its length differs from ``points``, or ``matrix`` is a device
        array without exactly one entry.
    RuntimeError
        If ``points``, ``matrix`` and ``out`` are not all on one device.

    Notes
    -----
    Safe in place: each thread reads exactly the element it writes.

    See Also
    --------
    [`transform_vectors`][triwarp.transform.transform_vectors]
    [`transform_normals`][triwarp.transform.transform_normals]
    [`transform_mesh`][triwarp.transform.transform_mesh]
    """
    require_same_device(points=points, matrix=matrix, out=out)
    result = _alloc_or_out(out, int(points.shape[0]), wp.vec3, points.device)
    if int(points.shape[0]) == 0:
        return result
    if int(result.shape[0]) != int(points.shape[0]):
        raise ValueError(
            f"transform_points: out must have length {points.shape[0]}, got {result.shape[0]}"
        )
    if isinstance(matrix, wp.array):
        # The one path that never crosses to host: the kernel reads the device pointer directly,
        # so a fitted (e.g. icp) transform never has to leave the device. `apply_transform_mat44`
        # reads `matrix[0]` unconditionally, which is why the (1,) shape is checked above rather
        # than left to a silent partial read.
        if int(matrix.shape[0]) != 1:
            raise ValueError(
                "transform_points: matrix must be a length-1 wp.array[wp.mat44], got shape "
                f"{tuple(matrix.shape)}"
            )
        wp.launch(
            kernel_transform.apply_transform_mat44,
            dim=int(points.shape[0]),
            inputs=[points, matrix, result],
            device=points.device,
        )
        return result
    wp.map(kernel_transform.transform_point_mat44, points, matrix, out=result)
    return result

transform_vectors

transform_vectors(
    vectors: array[vec3],
    matrix: mat44 | array[mat44],
    *,
    out: array[vec3] | None = None,
) -> wp.array[wp.vec3]

Apply the linear block of an affine transform to a buffer of directions, ignoring translation.

For a displacement, a velocity or a tangent vector. Not for a normal unless the transform is an isometry -- see transform_normals and this module's docstring. The result is not renormalized: a scale in matrix scales the output.

Parameters:

Name Type Description Default
vectors array[vec3]

(n,) directions.

required
matrix mat44 | array[mat44]

4x4 transform; its translation column is ignored. Scalar wp.mat44 or a (1,) wp.array[wp.mat44] -- unlike transform_points there is no on-device kernel for this map, so the array form still costs one host readback (see as_mat44).

required
out array[vec3] | None

Destination, allocated when None. Pass out=vectors to transform in place.

None

Returns:

Type Description
array[vec3]

out, or a freshly allocated (n,) buffer on vectors.device.

Raises:

Type Description
ValueError

If matrix is a device array without exactly one entry.

RuntimeError

If vectors, matrix and out are not all on one device.

See Also

transform_points transform_normals

Source code in triwarp/transform.py
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
def transform_vectors(
    vectors: wp.array[wp.vec3],
    matrix: wp.mat44 | wp.array[wp.mat44],
    *,
    out: wp.array[wp.vec3] | None = None,
) -> wp.array[wp.vec3]:
    """
    Apply the linear block of an affine transform to a buffer of directions, ignoring translation.

    For a displacement, a velocity or a tangent vector. **Not** for a normal unless the transform
    is an isometry -- see [`transform_normals`][triwarp.transform.transform_normals] and this
    module's docstring. The result is not renormalized: a scale in ``matrix`` scales the output.

    Parameters
    ----------
    vectors
        ``(n,)`` directions.
    matrix
        ``4x4`` transform; its translation column is ignored. Scalar ``wp.mat44`` or a ``(1,)``
        ``wp.array[wp.mat44]`` -- unlike [`transform_points`][triwarp.transform.transform_points]
        there is no on-device kernel for this map, so the array form still costs one host readback
        (see [`as_mat44`][triwarp.transform.as_mat44]).
    out
        Destination, allocated when ``None``. Pass ``out=vectors`` to transform in place.

    Returns
    -------
    wp.array[wp.vec3]
        ``out``, or a freshly allocated ``(n,)`` buffer on ``vectors.device``.

    Raises
    ------
    ValueError
        If ``matrix`` is a device array without exactly one entry.
    RuntimeError
        If ``vectors``, ``matrix`` and ``out`` are not all on one device.

    See Also
    --------
    [`transform_points`][triwarp.transform.transform_points]
    [`transform_normals`][triwarp.transform.transform_normals]
    """
    require_same_device(vectors=vectors, matrix=matrix, out=out)
    result = _alloc_or_out(out, int(vectors.shape[0]), wp.vec3, vectors.device)
    if int(vectors.shape[0]) == 0:
        return result
    wp.map(kernel_transform.transform_vector_mat44, vectors, as_mat44(matrix), out=result)
    return result

transform_normals

transform_normals(
    normals: array[vec3],
    matrix: mat44 | array[mat44],
    *,
    out: array[vec3] | None = None,
) -> wp.array[wp.vec3]

Map unit normals through an affine transform by its inverse transpose, and renormalize.

A normal is a covector: it maps by the inverse transpose of the linear block, which is the only map keeping it perpendicular to the transformed surface under a shear or a non-uniform scale. Zero normals stay zero, the degenerate-face convention face_normals_and_areas writes.

Parameters:

Name Type Description Default
normals array[vec3]

(n,) normals, unit or zero.

required
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array. Its linear block must be invertible; the host-side inverse this needs (see normal_matrix) is computed once and reused, regardless of normals' length.

required
out array[vec3] | None

Destination, allocated when None. Pass out=normals to transform in place.

None

Returns:

Type Description
array[vec3]

out, or a freshly allocated (n,) buffer of unit (or zero) normals on normals.device.

Raises:

Type Description
ValueError

If the linear block of matrix is singular, so no normal map exists -- checked even when normals is empty, since the matrix itself is what's invalid.

RuntimeError

If normals, matrix and out are not all on one device.

Notes

Orientation is not corrected: a mirroring matrix leaves these pointing inward until the face winding is reversed too. transform_mesh is the entry point that handles both.

See Also

normal_matrix transform_vectors

Source code in triwarp/transform.py
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
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
def transform_normals(
    normals: wp.array[wp.vec3],
    matrix: wp.mat44 | wp.array[wp.mat44],
    *,
    out: wp.array[wp.vec3] | None = None,
) -> wp.array[wp.vec3]:
    """
    Map unit normals through an affine transform by its inverse transpose, and renormalize.

    A normal is a covector: it maps by the inverse transpose of the linear block, which is the
    only map keeping it perpendicular to the transformed surface under a shear or a non-uniform
    scale. Zero normals stay zero, the degenerate-face convention
    [`face_normals_and_areas`][triwarp.triangles.face_normals_and_areas] writes.

    Parameters
    ----------
    normals
        ``(n,)`` normals, unit or zero.
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array. Its linear block must be invertible;
        the host-side inverse this needs (see [`normal_matrix`][triwarp.transform.normal_matrix])
        is computed once and reused, regardless of ``normals``' length.
    out
        Destination, allocated when ``None``. Pass ``out=normals`` to transform in place.

    Returns
    -------
    wp.array[wp.vec3]
        ``out``, or a freshly allocated ``(n,)`` buffer of unit (or zero) normals on
        ``normals.device``.

    Raises
    ------
    ValueError
        If the linear block of ``matrix`` is singular, so no normal map exists -- checked even
        when ``normals`` is empty, since the matrix itself is what's invalid.
    RuntimeError
        If ``normals``, ``matrix`` and ``out`` are not all on one device.

    Notes
    -----
    Orientation is *not* corrected: a mirroring ``matrix`` leaves these pointing inward until the
    face winding is reversed too. [`transform_mesh`][triwarp.transform.transform_mesh] is the
    entry point that handles both.

    See Also
    --------
    [`normal_matrix`][triwarp.transform.normal_matrix]
    [`transform_vectors`][triwarp.transform.transform_vectors]
    """
    require_same_device(normals=normals, matrix=matrix, out=out)
    # Computed before the empty-input early return: the matrix's own invertibility is a property
    # of `matrix` alone, not of how many normals there are, so a zero-length `normals` must not
    # silently skip it (it used to, via `normal_matrix`'s only call site sitting below the guard).
    linear_normal_matrix = normal_matrix(matrix)
    result = _alloc_or_out(out, int(normals.shape[0]), wp.vec3, normals.device)
    if int(normals.shape[0]) == 0:
        return result
    wp.map(kernel_transform.transform_normal_mat33, normals, linear_normal_matrix, out=result)
    return result

transform_mesh

transform_mesh(
    vertices: array[vec3],
    faces: array[int32],
    matrix: mat44 | array[mat44],
    *,
    out_vertices: array[vec3] | None = None,
    out_faces: array[int32] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Transform a mesh's vertices, reversing face winding when the transform mirrors.

The winding flip is what keeps normals outward: a transform with negative determinant maps the face's corner cross product to -M n, so reversing the corner order restores +M n. Without it a mirrored mesh has inward normals and a negative volume.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
faces array[int32]

Length-3 * n_faces flat wp.int32 triangle index buffer.

required
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array.

required
out_vertices array[vec3] | None

Destinations, allocated when None. Pass the inputs to transform in place.

None
out_faces array[vec3] | None

Destinations, allocated when None. Pass the inputs to transform in place.

None

Returns:

Name Type Description
vertices array[vec3]

Transformed positions.

faces array[int32]

Face buffer, corner-reversed if matrix mirrors and otherwise a copy of the input.

Raises:

Type Description
ValueError

If out_vertices or out_faces is given and its length differs from vertices or faces, or matrix is a device array without exactly one entry.

RuntimeError

If vertices, faces, matrix, out_vertices and out_faces are not all on one device.

See Also

transform_points Trimesh.transform The cached-mesh entry point, which carries derived quantities forward.

Source code in triwarp/transform.py
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
def transform_mesh(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    matrix: wp.mat44 | wp.array[wp.mat44],
    *,
    out_vertices: wp.array[wp.vec3] | None = None,
    out_faces: wp.array[wp.int32] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Transform a mesh's vertices, reversing face winding when the transform mirrors.

    The winding flip is what keeps normals outward: a transform with negative determinant maps the
    face's corner cross product to ``-M n``, so reversing the corner order restores ``+M n``.
    Without it a mirrored mesh has inward normals and a negative
    [`volume`][triwarp.measures.volume].

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` flat ``wp.int32`` triangle index buffer.
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array.
    out_vertices, out_faces
        Destinations, allocated when ``None``. Pass the inputs to transform in place.

    Returns
    -------
    vertices : wp.array[wp.vec3]
        Transformed positions.
    faces : wp.array[wp.int32]
        Face buffer, corner-reversed if ``matrix`` mirrors and otherwise a copy of the input.

    Raises
    ------
    ValueError
        If ``out_vertices`` or ``out_faces`` is given and its length differs from ``vertices`` or
        ``faces``, or ``matrix`` is a device array without exactly one entry.
    RuntimeError
        If ``vertices``, ``faces``, ``matrix``, ``out_vertices`` and ``out_faces`` are not all on
        one device.

    See Also
    --------
    [`transform_points`][triwarp.transform.transform_points]
    [`Trimesh.transform`][triwarp.mesh.Trimesh.transform]
        The cached-mesh entry point, which carries derived quantities forward.
    """
    require_same_device(
        vertices=vertices,
        faces=faces,
        matrix=matrix,
        out_vertices=out_vertices,
        out_faces=out_faces,
    )
    new_vertices = transform_points(vertices, matrix, out=out_vertices)
    new_faces = _alloc_or_out(out_faces, int(faces.shape[0]), wp.int32, faces.device)
    if int(new_faces.shape[0]) != int(faces.shape[0]):
        raise ValueError(
            f"transform_mesh: out_faces must have length {faces.shape[0]}, got {new_faces.shape[0]}"
        )
    n_faces = int(faces.shape[0]) // 3
    if reverses_orientation(matrix):
        if n_faces > 0:
            wp.launch(
                kernel_repair.reverse_face_winding,
                dim=n_faces,
                inputs=[faces, new_faces],
                device=faces.device,
            )
    elif new_faces.ptr != faces.ptr:
        wp.copy(new_faces, faces)
    return new_vertices, new_faces

translation_matrix

translation_matrix(
    offset: vec3 | Sequence[float],
) -> wp.mat44

Homogeneous matrix translating by offset.

Parameters:

Name Type Description Default
offset vec3 | Sequence[float]

Length-3 translation.

required

Returns:

Type Description
mat44

A TransformKind.TRANSLATION matrix.

Examples:

up = tw.transform.translation_matrix((0.0, 0.0, 1.0))
moved = tw.transform.transform_points(v, up)
See Also

rotation_matrix scale_matrix reflection_matrix

Source code in triwarp/transform.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
def translation_matrix(offset: wp.vec3 | Sequence[float]) -> wp.mat44:
    """
    Homogeneous matrix translating by ``offset``.

    Parameters
    ----------
    offset
        Length-3 translation.

    Returns
    -------
    wp.mat44
        A `TransformKind.TRANSLATION` matrix.

    Examples
    --------
    ```python
    up = tw.transform.translation_matrix((0.0, 0.0, 1.0))
    moved = tw.transform.transform_points(v, up)
    ```

    See Also
    --------
    [`rotation_matrix`][triwarp.transform.rotation_matrix]
    [`scale_matrix`][triwarp.transform.scale_matrix]
    [`reflection_matrix`][triwarp.transform.reflection_matrix]
    """
    return _compose(np.eye(3), _vec3_host(offset, "offset"))

rotation_matrix

rotation_matrix(
    axis: vec3 | Sequence[float],
    angle: float,
    center: vec3 | Sequence[float] | None = None,
) -> wp.mat44

Homogeneous matrix rotating by angle radians about axis, through center.

Parameters:

Name Type Description Default
axis vec3 | Sequence[float]

Length-3 rotation axis; normalized internally, so its magnitude is ignored.

required
angle float

Rotation angle in radians, counter-clockwise about axis by the right-hand rule.

required
center vec3 | Sequence[float] | None

Fixed point of the rotation, the origin when None.

None

Returns:

Type Description
mat44

A TransformKind.RIGID matrix (TransformKind.IDENTITY at angle = 0).

Raises:

Type Description
ValueError

If axis is zero-length or not finite, so no rotation is defined.

See Also

translation_matrix scale_matrix

Source code in triwarp/transform.py
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
def rotation_matrix(
    axis: wp.vec3 | Sequence[float], angle: float, center: wp.vec3 | Sequence[float] | None = None
) -> wp.mat44:
    """
    Homogeneous matrix rotating by ``angle`` radians about ``axis``, through ``center``.

    Parameters
    ----------
    axis
        Length-3 rotation axis; normalized internally, so its magnitude is ignored.
    angle
        Rotation angle in radians, counter-clockwise about ``axis`` by the right-hand rule.
    center
        Fixed point of the rotation, the origin when ``None``.

    Returns
    -------
    wp.mat44
        A `TransformKind.RIGID` matrix (`TransformKind.IDENTITY` at ``angle = 0``).

    Raises
    ------
    ValueError
        If ``axis`` is zero-length or not finite, so no rotation is defined.

    See Also
    --------
    [`translation_matrix`][triwarp.transform.translation_matrix]
    [`scale_matrix`][triwarp.transform.scale_matrix]
    """
    direction = _vec3_host(axis, "axis")
    norm = float(np.linalg.norm(direction))
    # `not np.isfinite(norm)` rather than only `norm == 0.0`: a NaN- or Inf-valued axis (e.g. one
    # built from a degenerate cross product upstream) gives a non-finite `norm`, and every
    # comparison against NaN is False, so `== 0.0` alone would let it through and silently fill the
    # whole matrix with NaN.
    if not np.isfinite(norm) or norm <= 0.0:
        raise ValueError("rotation_matrix requires a finite, non-zero axis")
    direction = direction / norm
    # Rodrigues' rotation formula. Built on the host in float64 and rounded once on the way into
    # the float32 ``wp.mat44``, so composing a handful of these stays inside
    # ``ORTHOGONALITY_RTOL``.
    cos_a, sin_a = math.cos(angle), math.sin(angle)
    # `np.cross(np.eye(3), direction)`'s row i is `e_i x direction`, which expands to exactly the
    # skew-symmetric cross-product matrix Rodrigues' formula needs -- no need to spell out the six
    # signed entries by hand.
    cross = np.cross(np.eye(3), direction)
    rotation = cos_a * np.eye(3) + sin_a * cross + (1.0 - cos_a) * np.outer(direction, direction)
    return _compose_about(rotation, center)

scale_matrix

scale_matrix(
    factor: float | Sequence[float],
    center: vec3 | Sequence[float] | None = None,
) -> wp.mat44

Homogeneous matrix scaling about center, uniformly or per axis.

Parameters:

Name Type Description Default
factor float | Sequence[float]

A single scale factor, or a length-3 per-axis factor. A per-axis factor whose components differ is a TransformKind.AFFINE transform, not a TransformKind.SIMILARITY one: it does not preserve angles, so the cotangent weights of a mesh scaled that way have to be rebuilt.

required
center vec3 | Sequence[float] | None

Fixed point of the scaling, the origin when None.

None

Returns:

Type Description
mat44

A TransformKind.SIMILARITY matrix for a uniform positive factor, TransformKind.AFFINE otherwise.

Raises:

Type Description
ValueError

If factor is neither a scalar nor length-3.

See Also

translation_matrix rotation_matrix

Source code in triwarp/transform.py
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
def scale_matrix(
    factor: float | Sequence[float], center: wp.vec3 | Sequence[float] | None = None
) -> wp.mat44:
    """
    Homogeneous matrix scaling about ``center``, uniformly or per axis.

    Parameters
    ----------
    factor
        A single scale factor, or a length-3 per-axis factor. A per-axis factor whose components
        differ is a `TransformKind.AFFINE` transform, not a `TransformKind.SIMILARITY` one: it
        does not preserve angles, so the cotangent weights of a mesh scaled that way have to be
        rebuilt.
    center
        Fixed point of the scaling, the origin when ``None``.

    Returns
    -------
    wp.mat44
        A `TransformKind.SIMILARITY` matrix for a uniform positive ``factor``,
        `TransformKind.AFFINE` otherwise.

    Raises
    ------
    ValueError
        If ``factor`` is neither a scalar nor length-3.

    See Also
    --------
    [`translation_matrix`][triwarp.transform.translation_matrix]
    [`rotation_matrix`][triwarp.transform.rotation_matrix]
    """
    factors = np.asanyarray(factor, dtype=np.float64).reshape(-1)
    if factors.size == 1:
        factors = np.repeat(factors, 3)
    elif factors.size != 3:
        raise ValueError(f"scale_matrix factor must be a scalar or length-3, got {factors.size}")
    return _compose_about(np.diag(factors), center)

reflection_matrix

reflection_matrix(
    normal: vec3 | Sequence[float],
    center: vec3 | Sequence[float] | None = None,
) -> wp.mat44

Homogeneous matrix reflecting through the plane with the given normal and point.

Parameters:

Name Type Description Default
normal vec3 | Sequence[float]

Length-3 plane normal; normalized internally.

required
center vec3 | Sequence[float] | None

A point on the mirror plane, the origin when None.

None

Returns:

Type Description
mat44

A TransformKind.REFLECTION matrix. Applying it to a mesh reverses face winding -- transform_mesh and Trimesh.transform do that for you, transform_points does not.

Raises:

Type Description
ValueError

If normal is zero-length or not finite, so no plane is defined.

See Also

rotation_matrix triwarp.points.half_space_mask

Source code in triwarp/transform.py
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
def reflection_matrix(
    normal: wp.vec3 | Sequence[float], center: wp.vec3 | Sequence[float] | None = None
) -> wp.mat44:
    """
    Homogeneous matrix reflecting through the plane with the given ``normal`` and point.

    Parameters
    ----------
    normal
        Length-3 plane normal; normalized internally.
    center
        A point on the mirror plane, the origin when ``None``.

    Returns
    -------
    wp.mat44
        A `TransformKind.REFLECTION` matrix. Applying it to a mesh reverses face winding --
        [`transform_mesh`][triwarp.transform.transform_mesh] and
        [`Trimesh.transform`][triwarp.mesh.Trimesh.transform] do that for you,
        [`transform_points`][triwarp.transform.transform_points] does not.

    Raises
    ------
    ValueError
        If ``normal`` is zero-length or not finite, so no plane is defined.

    See Also
    --------
    [`rotation_matrix`][triwarp.transform.rotation_matrix]
    [`triwarp.points.half_space_mask`][]
    """
    direction = _vec3_host(normal, "normal")
    norm = float(np.linalg.norm(direction))
    # See rotation_matrix's identical guard for why this is `not np.isfinite` and not `== 0.0`.
    if not np.isfinite(norm) or norm <= 0.0:
        raise ValueError("reflection_matrix requires a finite, non-zero normal")
    direction = direction / norm
    return _compose_about(np.eye(3) - 2.0 * np.outer(direction, direction), center)

normal_matrix

normal_matrix(matrix: mat44 | array[mat44]) -> wp.mat33

Inverse transpose of a transform's linear block: the map that carries normals.

Parameters:

Name Type Description Default
matrix mat44 | array[mat44]

4x4 transform with an invertible linear block, scalar or (1,) device array.

required

Returns:

Type Description
mat33

The 3x3 matrix transform_normals applies. For an isometry this equals the linear block itself.

Raises:

Type Description
ValueError

If the linear block is singular to within ORTHOGONALITY_RTOL -- the same tolerance-based test classify_transform uses, not an exact det == 0 check, so a near-singular block (e.g. one axis scaled by 1e-40) raises here instead of silently inverting into an overflowing wp.mat33.

See Also

transform_normals classify_transform

Source code in triwarp/transform.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
def normal_matrix(matrix: wp.mat44 | wp.array[wp.mat44]) -> wp.mat33:
    """
    Inverse transpose of a transform's linear block: the map that carries normals.

    Parameters
    ----------
    matrix
        ``4x4`` transform with an invertible linear block, scalar or ``(1,)`` device array.

    Returns
    -------
    wp.mat33
        The ``3x3`` matrix [`transform_normals`][triwarp.transform.transform_normals] applies.
        For an isometry this equals the linear block itself.

    Raises
    ------
    ValueError
        If the linear block is singular to within
        [`ORTHOGONALITY_RTOL`][triwarp.transform.ORTHOGONALITY_RTOL] -- the same tolerance-based
        test [`classify_transform`][triwarp.transform.classify_transform] uses, not an exact
        ``det == 0`` check, so a near-singular block (e.g. one axis scaled by ``1e-40``) raises
        here instead of silently inverting into an overflowing ``wp.mat33``.

    See Also
    --------
    [`transform_normals`][triwarp.transform.transform_normals]
    [`classify_transform`][triwarp.transform.classify_transform]
    """
    linear = matrix_to_numpy(matrix)[:3, :3]
    if _is_singular(linear, ORTHOGONALITY_RTOL):
        raise ValueError("normal_matrix requires an invertible linear block, got a singular one")
    return wp.mat33(*np.linalg.inv(linear).T.flatten())

classify_transform

classify_transform(
    matrix: mat44 | array[mat44],
    *,
    rtol: float = ORTHOGONALITY_RTOL,
) -> TransformKind

Decide what an affine transform preserves: lengths, angles, orientation, or only connectivity.

Reads the 3x3 linear block's Gram matrix R Rt. When it is a positive multiple of the identity the transform is a similarity, and the multiple is the squared scale; the sign of det R then separates rotations from reflections, and an identity block separates a pure translation. Everything else is TransformKind.AFFINE.

Parameters:

Name Type Description Default
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array. A device array costs one host readback.

required
rtol float

Relative tolerance on the Gram matrix, defaulting to ORTHOGONALITY_RTOL. A matrix composed from many float32 rotations eventually drifts past any fixed tolerance and is classified TransformKind.AFFINE -- conservative, never wrong, but it costs the caller a cache. Pass assume= to Trimesh.transform rather than widening this.

ORTHOGONALITY_RTOL

Returns:

Type Description
TransformKind

The strongest class the matrix satisfies.

Notes

Singularity is decided from the linear block's singular values, not from its determinant: a block is rank-deficient to within rtol when its smallest singular value is at most an rtol fraction of its largest -- the reciprocal condition number, which stays correct however anisotropic the block is. A determinant-vs-scale test would not: a non-uniform scale can have an arbitrarily small determinant relative to its trace while remaining perfectly invertible (diag(1000, 1, 1) is TransformKind.AFFINE, not singular, despite a determinant 500x below its isotropic-scale expectation).

Orientation is not part of the answer except at TransformKind.REFLECTION, which is the unit-scale mirror. A mirroring scale is TransformKind.SIMILARITY, so a caller that needs to know whether winding flips must read det of the linear block rather than switching on this result.

Examples:

kind = tw.transform.classify_transform(tw.transform.rotation_matrix((0.0, 0.0, 1.0), 0.5))
See Also

TransformKind Trimesh.transform

Source code in triwarp/transform.py
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
def classify_transform(
    matrix: wp.mat44 | wp.array[wp.mat44], *, rtol: float = ORTHOGONALITY_RTOL
) -> TransformKind:
    """
    Decide what an affine transform preserves: lengths, angles, orientation, or only connectivity.

    Reads the ``3x3`` linear block's Gram matrix ``R Rt``. When it is a positive multiple of the
    identity the transform is a similarity, and the multiple is the squared scale; the sign of
    ``det R`` then separates rotations from reflections, and an identity block separates a pure
    translation. Everything else is `TransformKind.AFFINE`.

    Parameters
    ----------
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array. A device array costs one host readback.
    rtol
        Relative tolerance on the Gram matrix, defaulting to
        [`ORTHOGONALITY_RTOL`][triwarp.transform.ORTHOGONALITY_RTOL]. A matrix composed from many
        ``float32`` rotations eventually drifts past any fixed tolerance and is classified
        `TransformKind.AFFINE` -- conservative, never wrong, but it costs the caller a cache. Pass
        ``assume=`` to [`Trimesh.transform`][triwarp.mesh.Trimesh.transform] rather than widening
        this.

    Returns
    -------
    TransformKind
        The strongest class the matrix satisfies.

    Notes
    -----
    Singularity is decided from the linear block's singular values, not from its determinant: a
    block is rank-deficient to within ``rtol`` when its smallest singular value is at most an
    ``rtol`` fraction of its largest -- the reciprocal condition number, which stays correct
    however anisotropic the block is. A determinant-vs-scale test would not: a non-uniform scale
    can have an arbitrarily small determinant relative to its trace while remaining perfectly
    invertible (``diag(1000, 1, 1)`` is `TransformKind.AFFINE`, not singular, despite a determinant
    500x below its isotropic-scale expectation).

    Orientation is **not** part of the answer except at `TransformKind.REFLECTION`, which is the
    unit-scale mirror. A mirroring scale is `TransformKind.SIMILARITY`, so a caller that needs to
    know whether winding flips must read ``det`` of the linear block rather than switching on this
    result.

    Examples
    --------
    ```python
    kind = tw.transform.classify_transform(tw.transform.rotation_matrix((0.0, 0.0, 1.0), 0.5))
    ```

    See Also
    --------
    [`TransformKind`][triwarp.transform.TransformKind]
    [`Trimesh.transform`][triwarp.mesh.Trimesh.transform]
    """
    host = matrix_to_numpy(matrix)
    linear, offset = host[:3, :3], host[:3, 3]
    # The bottom row has to be [0, 0, 0, 1] for the map to be affine at all. A projective matrix
    # is not merely "some other affine map": ``wp.transform_point`` applies it without the
    # perspective divide, so nothing downstream is meaningful -- report the bottom rung.
    if not np.allclose(host[3], np.array([0.0, 0.0, 0.0, 1.0]), rtol=rtol, atol=rtol):
        return TransformKind.SINGULAR

    gram = linear @ linear.T
    scale_sq = _scale_sq_from_gram(gram)
    determinant = float(np.linalg.det(linear))
    if _is_singular(linear, rtol):
        return TransformKind.SINGULAR
    if not np.allclose(gram, scale_sq * np.eye(3), rtol=rtol, atol=rtol * scale_sq):
        return TransformKind.AFFINE

    # Scale is tested before the determinant sign, so a *mirroring* similarity (a uniform scale
    # of -2, say) is reported as SIMILARITY and not as REFLECTION: it does not preserve lengths,
    # and REFLECTION promises that it does. Orientation reversal is a separate axis from the
    # metric class -- read it off ``det`` directly, the way
    # [`transform_mesh`][triwarp.transform.transform_mesh] does.
    if abs(scale_sq - 1.0) > rtol:
        return TransformKind.SIMILARITY
    if determinant < 0.0:
        return TransformKind.REFLECTION
    if np.allclose(linear, np.eye(3), rtol=rtol, atol=rtol):
        translates = not np.allclose(offset, 0.0, rtol=rtol, atol=rtol)
        return TransformKind.TRANSLATION if translates else TransformKind.IDENTITY
    return TransformKind.RIGID

transform_scale

transform_scale(matrix: mat44 | array[mat44]) -> float

Uniform scale factor of a similarity transform.

Parameters:

Name Type Description Default
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array.

required

Returns:

Type Description
float

The positive factor by which lengths scale. Meaningful only when classify_transform returns TransformKind.SIMILARITY or a stronger class; for a general TransformKind.AFFINE matrix it is the root-mean-square of the linear block's singular values and no single factor exists.

See Also

classify_transform

Source code in triwarp/transform.py
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def transform_scale(matrix: wp.mat44 | wp.array[wp.mat44]) -> float:
    """
    Uniform scale factor of a similarity transform.

    Parameters
    ----------
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array.

    Returns
    -------
    float
        The positive factor by which lengths scale. Meaningful only when
        [`classify_transform`][triwarp.transform.classify_transform] returns
        `TransformKind.SIMILARITY` or a stronger class; for a general
        `TransformKind.AFFINE` matrix it is the root-mean-square of the linear block's singular
        values and no single factor exists.

    See Also
    --------
    [`classify_transform`][triwarp.transform.classify_transform]
    """
    linear = matrix_to_numpy(matrix)[:3, :3]
    return float(math.sqrt(_scale_sq_from_gram(linear @ linear.T)))

as_mat44

as_mat44(matrix: mat44 | array[mat44]) -> wp.mat44

Read a transform parameter as a scalar wp.mat44, whichever form it arrives in.

Every entry point here accepts a (1,) device array. For transform_points and transform_mesh the array's pointer reaches the kernel directly, so a fitted transform never leaves the device; every other entry point -- the orientation flip, the classification, the normal map, transform_vectors -- needs the value on the host regardless of which form it arrives in, and this is the one place it crosses.

Parameters:

Name Type Description Default
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array.

required

Returns:

Type Description
mat44

The matrix as a host value, returned unchanged when it already is one.

Raises:

Type Description
ValueError

If matrix is a device array without exactly one entry.

Notes

A device array costs one host readback. Every caller here is making a host branch over a whole launch, so the matrix has to cross either way. Calling this more than once on the same unchanged matrix within one operation (e.g. once for classification, once for the normal map) pays the readback again each time -- resolve it to a host value once and pass that value down instead, the way Trimesh.transform does.

See Also

classify_transform

Source code in triwarp/transform.py
676
677
678
679
680
681
682
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
def as_mat44(matrix: wp.mat44 | wp.array[wp.mat44]) -> wp.mat44:
    """
    Read a transform parameter as a scalar ``wp.mat44``, whichever form it arrives in.

    Every entry point here accepts a ``(1,)`` device array. For
    [`transform_points`][triwarp.transform.transform_points] and
    [`transform_mesh`][triwarp.transform.transform_mesh] the array's pointer reaches the kernel
    directly, so a fitted transform never leaves the device; every other entry point -- the
    orientation flip, the classification, the normal map, `transform_vectors` -- needs the value on
    the host regardless of which form it arrives in, and this is the one place it crosses.

    Parameters
    ----------
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array.

    Returns
    -------
    wp.mat44
        The matrix as a host value, returned unchanged when it already is one.

    Raises
    ------
    ValueError
        If ``matrix`` is a device array without exactly one entry.

    Notes
    -----
    A device array costs one host readback. Every caller here is making a host branch over a whole
    launch, so the matrix has to cross either way. Calling this more than once on the same
    unchanged ``matrix`` within one operation (e.g. once for classification, once for the normal
    map) pays the readback again each time -- resolve it to a host value once and pass that value
    down instead, the way [`Trimesh.transform`][triwarp.mesh.Trimesh.transform] does.

    See Also
    --------
    [`classify_transform`][triwarp.transform.classify_transform]
    """
    if not isinstance(matrix, wp.array):
        return matrix
    if int(matrix.shape[0]) != 1:
        raise ValueError(
            f"as_mat44 requires a length-1 wp.array[wp.mat44], got shape {tuple(matrix.shape)}"
        )
    return matrix.list()[0]

matrix_to_numpy

matrix_to_numpy(matrix: mat44 | array[mat44]) -> np.ndarray

Read a transform parameter back as a host (4, 4) float64 array.

For host matrix arithmetic that has no wp.mat44 equivalent -- composing with a NumPy matrix built elsewhere, or reading a row/column out directly. Every decision this module makes on a transform (classify_transform, reverses_orientation, normal_matrix) goes through this.

Parameters:

Name Type Description Default
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array.

required

Returns:

Type Description
ndarray

(4, 4) float64 copy.

Raises:

Type Description
ValueError

If matrix is a device array without exactly one entry.

See Also

as_mat44

Source code in triwarp/transform.py
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
def matrix_to_numpy(matrix: wp.mat44 | wp.array[wp.mat44]) -> np.ndarray:
    """
    Read a transform parameter back as a host ``(4, 4)`` ``float64`` array.

    For host matrix arithmetic that has no ``wp.mat44`` equivalent -- composing with a NumPy
    matrix built elsewhere, or reading a row/column out directly. Every decision this module makes
    on a transform ([`classify_transform`][triwarp.transform.classify_transform],
    [`reverses_orientation`][triwarp.transform.reverses_orientation],
    [`normal_matrix`][triwarp.transform.normal_matrix]) goes through this.

    Parameters
    ----------
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array.

    Returns
    -------
    np.ndarray
        ``(4, 4)`` ``float64`` copy.

    Raises
    ------
    ValueError
        If ``matrix`` is a device array without exactly one entry.

    See Also
    --------
    [`as_mat44`][triwarp.transform.as_mat44]
    """
    return np.array(as_mat44(matrix), dtype=np.float64).reshape(4, 4)

reverses_orientation

reverses_orientation(matrix: mat44 | array[mat44]) -> bool

Whether a transform mirrors, so that face winding must be reversed to keep normals outward.

Parameters:

Name Type Description Default
matrix mat44 | array[mat44]

4x4 transform, scalar or (1,) device array.

required

Returns:

Type Description
bool

True when the linear block has a negative determinant.

Notes

Deliberately separate from classify_transform: orientation is an axis of its own, and only TransformKind.REFLECTION implies it. A mirroring scale classifies as TransformKind.SIMILARITY and still reverses winding, so a caller that switches on the kind alone gets it wrong.

See Also

transform_mesh classify_transform

Source code in triwarp/transform.py
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
def reverses_orientation(matrix: wp.mat44 | wp.array[wp.mat44]) -> bool:
    """
    Whether a transform mirrors, so that face winding must be reversed to keep normals outward.

    Parameters
    ----------
    matrix
        ``4x4`` transform, scalar or ``(1,)`` device array.

    Returns
    -------
    bool
        ``True`` when the linear block has a negative determinant.

    Notes
    -----
    Deliberately separate from [`classify_transform`][triwarp.transform.classify_transform]:
    orientation is an axis of its own, and only `TransformKind.REFLECTION` implies it. A mirroring
    *scale* classifies as `TransformKind.SIMILARITY` and still reverses winding, so a caller that
    switches on the kind alone gets it wrong.

    See Also
    --------
    [`transform_mesh`][triwarp.transform.transform_mesh]
    [`classify_transform`][triwarp.transform.classify_transform]
    """
    return float(np.linalg.det(matrix_to_numpy(matrix)[:3, :3])) < 0.0