Skip to content

measures

triwarp.measures

What the whole mesh adds up to: the volume it encloses, its moments and its Euler characteristic.

Every function here reduces the entire surface to a single host-side number (or a small tuple of them), which is why they are gathered rather than left beside the per-element fields they sum over. Each therefore costs at least one host readback and forces a device synchronisation -- call them once and keep the answer, and reach for triwarp.reduce when a reduction should stay on the device.

Three of the four are mass properties of the enclosed solid, and all three are only meaningful on a closed, consistently wound surface -- is_volume is the check for that, and make_volume the repair. volume is the signed volume, moments adds the centre of mass and the inertia tensor, and surface_centroid is the one member that is a normalized total rather than a sum: the area-weighted mean of the per-face centroids. It is a property of the shell, so it differs from moments' centre of mass on any solid whose mass is not distributed like its surface -- and it needs no watertightness, since a surface has a centroid whether or not it bounds anything.

euler_characteristic is the odd one out: a topological invariant rather than a measurement, counted off the index buffer with no geometry involved. It sits here because it is a whole-mesh integer, and it is what homology_generators derives the genus from.

volume

volume(
    vertices: array[vec3] | array[vec3d],
    faces: array[int32],
) -> float

Signed volume enclosed by the mesh.

Sum of per-face signed tetrahedron volumes measured from the origin (dot(v0, cross(v1, v2)) / 6); for a closed, consistently wound surface this is independent of the reference point, and its sign follows the orientation of the face normals (positive for outward-facing normals). For an open or inconsistently wound mesh the result is not a meaningful volume.

Parameters:

Name Type Description Default
vertices array[vec3] | array[vec3d]

(n_vertices,) mesh vertex positions, wp.vec3 or wp.vec3d. The accumulation follows: pass vec3d where the sum's low digits matter, as filter_laplacian's volume constraint does.

required
faces array[int32]

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

required

Returns:

Type Description
float

Signed volume, accumulated in vertices' scalar type. 0.0 for an empty mesh.

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

face_signed_volumes The per-face primitive this sums. is_volume Whether the sum means anything on this mesh. make_volume Make it mean something. moments The same volume in float64, plus the centre of mass and inertia. filter_laplacian The float64 consumer: its volume constraint rescales the mesh to hold this fixed. trimesh.Trimesh.volume

Source code in triwarp/measures.py
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
def volume(vertices: wp.array[wp.vec3] | wp.array[wp.vec3d], faces: wp.array[wp.int32]) -> float:
    """
    Signed volume enclosed by the mesh.

    Sum of per-face signed tetrahedron volumes measured from the origin
    (``dot(v0, cross(v1, v2)) / 6``); for a closed, consistently wound surface this is
    independent of the reference point, and its sign follows the orientation of the face
    normals (positive for outward-facing normals). For an open or inconsistently wound mesh
    the result is not a meaningful volume.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions, ``wp.vec3`` or ``wp.vec3d``. The accumulation
        follows: pass ``vec3d`` where the sum's low digits matter, as
        [`filter_laplacian`][triwarp.smoothing.filter_laplacian]'s volume constraint does.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.

    Returns
    -------
    float
        Signed volume, accumulated in ``vertices``' scalar type. ``0.0`` for an empty mesh.

    Raises
    ------
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    See Also
    --------
    [`face_signed_volumes`][triwarp.triangles.face_signed_volumes]
        The per-face primitive this sums.
    [`is_volume`][triwarp.validation.is_volume]
        Whether the sum means anything on this mesh.
    [`make_volume`][triwarp.repair.make_volume]
        Make it mean something.
    [`moments`][triwarp.measures.moments]
        The same volume in float64, plus the centre of mass and inertia.
    [`filter_laplacian`][triwarp.smoothing.filter_laplacian]
        The ``float64`` consumer: its volume constraint rescales the mesh to hold this fixed.
    [`trimesh.Trimesh.volume`][]
    """
    require_same_device(vertices=vertices, faces=faces)
    if int(faces.shape[0]) == 0:
        return 0.0
    return tw.reduce.sum(tw.triangles.face_signed_volumes(vertices, faces))

surface_centroid

surface_centroid(
    vertices: array[vec3], faces: array[int32]
) -> wp.vec3

Area-weighted centroid of the mesh surface: the mean of the per-face centroids by area.

A normalized total rather than a sum, and a property of the shell alone -- unlike moments' centre of mass it needs no watertightness and no consistent winding, because a surface has a centroid whether or not it bounds a solid. The two differ on any solid whose mass is not distributed like its surface.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
faces array[int32]

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

required

Returns:

Type Description
vec3

Area-weighted mean of per-face centroids. All-NaN when the weights sum to zero -- an empty faces, or a mesh whose every face is degenerate -- since no weighted mean of the face centroids exists in either case.

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

moments The centre of mass of the enclosed solid, which this is not. face_centroids The per-face barycentres this averages. centroid The unweighted mean of a point cloud, and the only other centroid in the package. trimesh.Trimesh.centroid

Source code in triwarp/measures.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 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
def surface_centroid(vertices: wp.array[wp.vec3], faces: wp.array[wp.int32]) -> wp.vec3:
    """
    Area-weighted centroid of the mesh surface: the mean of the per-face centroids by area.

    A *normalized* total rather than a sum, and a property of the shell alone -- unlike
    [`moments`][triwarp.measures.moments]' centre of mass it needs no watertightness and no
    consistent winding, because a surface has a centroid whether or not it bounds a solid. The two
    differ on any solid whose mass is not distributed like its surface.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.

    Returns
    -------
    wp.vec3
        Area-weighted mean of per-face centroids. All-``NaN`` when the weights sum to zero --
        an empty ``faces``, or a mesh whose every face is degenerate -- since no weighted mean of
        the face centroids exists in either case.

    Raises
    ------
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    See Also
    --------
    [`moments`][triwarp.measures.moments]
        The centre of mass of the enclosed *solid*, which this is not.
    [`face_centroids`][triwarp.triangles.face_centroids]
        The per-face barycentres this averages.
    [`centroid`][triwarp.points.centroid]
        The unweighted mean of a point cloud, and the only other ``centroid`` in the package.
    [`trimesh.Trimesh.centroid`][]
    """
    require_same_device(vertices=vertices, faces=faces)
    f = faces.shape[0] // 3
    if f == 0:
        return wp.vec3(float("nan"), float("nan"), float("nan"))
    device = vertices.device
    out_centroid = wp.zeros(3, dtype=wp.float32, device=device)
    out_total_area = wp.zeros(1, dtype=wp.float32, device=device)
    if prefers_tiled_reduction(device):
        wp.launch_tiled(
            kernel_measures.centroid_tiled,
            dim=[(f + TILE_1D - 1) // TILE_1D],
            inputs=[vertices, faces, wp.int32(f), out_centroid, out_total_area],
            block_dim=TILE_1D,
            device=device,
        )
    else:
        n_slices = slice_count(f, device)
        wp.launch(
            kernel_measures.centroid_sliced,
            dim=n_slices,
            inputs=[vertices, faces, wp.int32(f), wp.int32(n_slices), out_centroid, out_total_area],
            device=device,
        )
    # Two unavoidable readbacks: the return type is a host-side wp.vec3, so the sums have to
    # cross to the host to be divided.
    total_area = float(read_scalar(out_total_area, 0))
    if total_area == 0.0:
        # Every face degenerate: the weights are all zero, so there is no weighted mean. Same
        # answer as the empty mesh above, and the same shape as ``moments``' zero-volume guard --
        # a ``ZeroDivisionError`` out of a mesh that is merely degenerate would be a surprise.
        # Taken before the second readback, which has nothing to divide.
        return wp.vec3(float("nan"), float("nan"), float("nan"))
    weighted = out_centroid.numpy()
    return wp.vec3(
        float(weighted[0]) / total_area,
        float(weighted[1]) / total_area,
        float(weighted[2]) / total_area,
    )

moments

moments(
    vertices: array[vec3], faces: array[int32]
) -> tuple[float, wp.vec3, wp.mat33d]

Mass properties of the solid bounded by the mesh: volume, centre of mass and inertia tensor.

Integrates over the enclosed solid at unit density by summing the contribution of every (origin, face) tetrahedron, so the result is only meaningful for a closed, consistently wound surface -- the same precondition volume carries, and is_volume is the check for it.

The integrals accumulate in float64 even though the positions are float32: the second moments scale as length ** 5, so a float32 sum loses their low digits on any sizeable mesh.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
faces array[int32]

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

required

Returns:

Name Type Description
volume float

Signed volume, identical to volume up to its float32 accumulation.

center_of_mass vec3

Volume centroid, i.e. the first moment divided by the volume. This is not surface_centroid, the area-weighted centre of the surface; the two differ on any solid whose mass is not distributed like its shell.

inertia mat33d

(3, 3) inertia tensor about the centre of mass, at unit density. float64, like the integrals it is assembled from -- a wp.mat33 would discard exactly the low digits this function accumulates in double precision to keep.

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

Notes

igl.moments returns (m0, m1, m2) where m1 is the first moment -- the centre of mass times the mass -- rather than the centre of mass itself, and m2 is already referred to the centre of mass (verified against a translated mesh, not assumed). This function returns the decoded forms, so the transform between the two is m1 / m0.

Three host readbacks are unavoidable here: all three returns are host-side values, so the ten accumulated sums have to cross the device boundary to be combined.

See Also

volume surface_centroid is_volume trimesh.Trimesh.moment_inertia igl.moments

Source code in triwarp/measures.py
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
262
263
264
265
def moments(
    vertices: wp.array[wp.vec3], faces: wp.array[wp.int32]
) -> tuple[float, wp.vec3, wp.mat33d]:
    """
    Mass properties of the solid bounded by the mesh: volume, centre of mass and inertia tensor.

    Integrates over the enclosed solid at unit density by summing the contribution of every
    (origin, face) tetrahedron, so the result is only meaningful for a **closed, consistently
    wound** surface -- the same precondition [`volume`][triwarp.measures.volume] carries, and
    [`is_volume`][triwarp.validation.is_volume] is the check for it.

    The integrals accumulate in ``float64`` even though the positions are ``float32``: the second
    moments scale as ``length ** 5``, so a ``float32`` sum loses their low digits on any sizeable
    mesh.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.

    Returns
    -------
    volume : float
        Signed volume, identical to [`volume`][triwarp.measures.volume] up to its ``float32``
        accumulation.
    center_of_mass : wp.vec3
        Volume centroid, i.e. the first moment divided by the volume. This is **not**
        [`surface_centroid`][triwarp.measures.surface_centroid], the area-weighted centre of the
        *surface*; the two differ on any solid whose mass is not distributed like its shell.
    inertia : wp.mat33d
        ``(3, 3)`` inertia tensor about the centre of mass, at unit density. ``float64``, like the
        integrals it is assembled from -- a ``wp.mat33`` would discard exactly the low digits this
        function accumulates in double precision to keep.

    Raises
    ------
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    Notes
    -----
    ``igl.moments`` returns ``(m0, m1, m2)`` where ``m1`` is the first moment -- the centre of mass
    times the mass -- rather than the centre of mass itself, and ``m2`` is already referred to the
    centre of mass (verified against a translated mesh, not assumed). This function returns the
    decoded forms, so the transform between the two is ``m1 / m0``.

    Three host readbacks are unavoidable here: all three returns are host-side values, so the ten
    accumulated sums have to cross the device boundary to be combined.

    See Also
    --------
    [`volume`][triwarp.measures.volume]
    [`surface_centroid`][triwarp.measures.surface_centroid]
    [`is_volume`][triwarp.validation.is_volume]
    [`trimesh.Trimesh.moment_inertia`][]
    ``igl.moments``
    """
    require_same_device(vertices=vertices, faces=faces)
    device = vertices.device
    n_faces = int(faces.shape[0]) // 3
    if n_faces == 0:
        return 0.0, wp.vec3(float("nan"), float("nan"), float("nan")), wp.mat33d()

    volumes = wp.empty(n_faces, dtype=wp.float64, device=device)
    first = wp.empty(n_faces, dtype=wp.vec3d, device=device)
    squares = wp.empty(n_faces, dtype=wp.vec3d, device=device)
    products = wp.empty(n_faces, dtype=wp.vec3d, device=device)
    wp.launch(
        kernel_measures.moment_integrands,
        dim=n_faces,
        inputs=[vertices, faces, volumes, first, squares, products],
        device=device,
    )

    # Four device reductions, each returning its total to the host: every return here is a
    # host-side value, so ten sums have to cross -- but only the ten, not the per-face integrands.
    # ``wp.utils.array_sum`` reduces a ``wp.vec3d`` array componentwise, so the three vector groups
    # need no kernel of their own. Reading them with ``.numpy().sum(axis=0)`` instead would copy
    # the whole ``(n_faces,)`` ``vec3d`` buffers to the host just to add them there.
    total_volume = float(wp.utils.array_sum(volumes))
    first_moment = np.asarray(list(wp.utils.array_sum(first)), dtype=np.float64)
    integral_squares = np.asarray(list(wp.utils.array_sum(squares)), dtype=np.float64)
    integral_products = np.asarray(list(wp.utils.array_sum(products)), dtype=np.float64)

    center = first_moment / total_volume if total_volume != 0.0 else np.full(3, np.nan)

    # Inertia about the origin from the raw integrals, then shifted to the centre of mass. Nine
    # scalars of host float64 arithmetic, packed into a wp.mat33d so no caller needs numpy to read
    # the answer.
    x2, y2, z2 = integral_squares
    xy, xz, yz = integral_products
    inertia = np.array(
        [[y2 + z2, -xy, -xz], [-xy, x2 + z2, -yz], [-xz, -yz, x2 + y2]], dtype=np.float64
    )
    if total_volume != 0.0:
        shift = total_volume * (float(center @ center) * np.eye(3) - np.outer(center, center))
        inertia = inertia - shift

    return total_volume, wp.vec3(*center), wp.mat33d(*inertia.ravel())

euler_characteristic

euler_characteristic(faces: array[int32]) -> int

Euler characteristic V - E + F of the mesh (topological invariant).

Counts distinct referenced vertices, unique undirected edges, and faces, matching trimesh.Trimesh.euler_number (which uses referenced vertices, edges_unique, and faces). For a closed genus-g surface this equals 2 - 2 * g.

Parameters:

Name Type Description Default
faces array[int32]

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

required

Returns:

Type Description
int

(#distinct referenced vertices) - (#unique edges) + (#faces). 0 for an empty mesh.

See Also

edges_unique trimesh.Trimesh.euler_number

Source code in triwarp/measures.py
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
def euler_characteristic(faces: wp.array[wp.int32]) -> int:
    """
    Euler characteristic ``V - E + F`` of the mesh (topological invariant).

    Counts distinct referenced vertices, unique undirected edges, and faces, matching
    [`trimesh.Trimesh.euler_number`][] (which uses referenced vertices, ``edges_unique``, and
    faces). For a closed genus-``g`` surface this equals ``2 - 2 * g``.

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

    Returns
    -------
    int
        ``(#distinct referenced vertices) - (#unique edges) + (#faces)``. ``0`` for an empty mesh.

    See Also
    --------
    [`edges_unique`][triwarp.edges.edges_unique]
    [`trimesh.Trimesh.euler_number`][]
    """
    n_faces = int(faces.shape[0]) // 3
    if n_faces == 0:
        return 0

    n_referenced = int(tw.grouping.unique_1d(faces).shape[0])
    unique_edges, _ = tw.edges.edges_unique(faces, n_vertices=tw.array.index_bound(faces))
    n_edges = int(unique_edges.shape[0])
    return n_referenced - n_edges + n_faces