Skip to content

edges

triwarp.edges

Triangle edges: the directed corner edges, the deduplicated undirected set, and their lengths.

Three representations, and which one a caller wants depends on what an edge means to them:

  • directed corner edges (faces_to_edges) — three per face, in corner order, so index 3 * f + k is face f's edge k. This is the halfedge indexing triwarp.halfedge builds on.
  • unique undirected edges (edges_unique) — one per mesh edge, with edges_unique_inverse mapping each corner back to it.
  • per-face length tables (face_edge_lengths) — an (n_faces, 3) table in the opposite-corner column order the cotangent formulas want, which is the intrinsic description triwarp.laplacian consumes.

faces_to_edges

faces_to_edges(
    faces: array[int32], sorted: bool = False
) -> twt.Array2dInt32

Directed triangle edges from a flat (i0, i1, i2) index buffer.

For each face emits three directed edges (i0, i1), (i1, i2), (i2, i0) in row-major order, matching trimesh.geometry.faces_to_edges.

Parameters:

Name Type Description Default
faces array[int32]

Length-3 * n_faces wp.int32 buffer of consecutive vertex-index triples.

required
sorted bool

If True, each output row has its smaller vertex index first (undirected edges).

False

Returns:

Type Description
Array2dInt32

Shape (n_faces * 3, 2). Empty (0, 2) array when n_faces == 0.

See Also

trimesh.geometry.faces_to_edges

Source code in triwarp/edges.py
27
28
29
30
31
32
33
34
35
36
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
def faces_to_edges(
    faces: wp.array[wp.int32],
    sorted: bool = False,  # noqa: A002
) -> twt.Array2dInt32:
    """
    Directed triangle edges from a flat ``(i0, i1, i2)`` index buffer.

    For each face emits three directed edges ``(i0, i1)``, ``(i1, i2)``, ``(i2, i0)`` in
    row-major order, matching [`trimesh.geometry.faces_to_edges`][].

    Parameters
    ----------
    faces
        Length-``3 * n_faces`` ``wp.int32`` buffer of consecutive vertex-index triples.
    sorted
        If ``True``, each output row has its smaller vertex index first (undirected edges).

    Returns
    -------
    twt.Array2dInt32
        Shape ``(n_faces * 3, 2)``. Empty ``(0, 2)`` array when ``n_faces == 0``.

    See Also
    --------
    [`trimesh.geometry.faces_to_edges`][]
    """
    n_faces = int(faces.shape[0]) // 3
    edges = twt.empty_2d((n_faces * 3, 2), wp.int32, device=faces.device)
    wp.launch(
        kernel_edges.faces_to_edges,
        dim=n_faces,
        inputs=[faces, wp.bool(sorted), edges],
        device=faces.device,
    )
    return twt.as_array2d(edges, wp.int32)

edges_face

edges_face(faces: array[int32]) -> wp.array[wp.int32]

Face index for each directed edge produced by faces_to_edges.

Returns an array of length n_faces * 3 where entry 3*f + k equals f, matching trimesh.Trimesh.edges_face.

Parameters:

Name Type Description Default
faces array[int32]

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

required

Returns:

Type Description
array[int32]

Length n_faces * 3 face indices on faces.device.

See Also

trimesh.Trimesh.edges_face

Source code in triwarp/edges.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def edges_face(faces: wp.array[wp.int32]) -> wp.array[wp.int32]:
    """
    Face index for each directed edge produced by [`faces_to_edges`][triwarp.edges.faces_to_edges].

    Returns an array of length ``n_faces * 3`` where entry ``3*f + k`` equals ``f``,
    matching [`trimesh.Trimesh.edges_face`][].

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

    Returns
    -------
    wp.array[wp.int32]
        Length ``n_faces * 3`` face indices on ``faces.device``.

    See Also
    --------
    [`trimesh.Trimesh.edges_face`][]
    """
    n_faces = int(faces.shape[0]) // 3
    return arange_repeat(n_faces * 3, 3, faces.device)

edges_unique

edges_unique(
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> tuple[twt.Array2dInt32, wp.array[wp.int32]]

Return unique undirected edges and their inverse mapping into the sorted edge list.

Equivalent to trimesh.Trimesh.edges_unique and trimesh.Trimesh.edges_unique_inverse computed together.

Parameters:

Name Type Description Default
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) sorted edges (each row min-first). When None, built from faces.

None
n_vertices int | None

Total number of vertices (used as the hash base). When None, inferred from edges_sorted with a device-host sync.

None

Returns:

Name Type Description
unique_edges Array2dInt32

Shape (m, 2) unique undirected vertex pairs, m <= n_faces * 3.

inverse array[int32]

Length n_faces * 3. unique_edges[inverse[i]] == edges_sorted[i].

Raises:

Type Description
TypeError

If edges_sorted is given and is not a rank-2 wp.int32 array.

ValueError

If edges_sorted is given and does not have exactly two columns.

RuntimeError

If faces and edges_sorted are not all on one device.

See Also

trimesh.Trimesh.edges_unique trimesh.Trimesh.edges_unique_inverse

Source code in triwarp/edges.py
 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
def edges_unique(
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> tuple[twt.Array2dInt32, wp.array[wp.int32]]:
    """
    Return unique undirected edges and their inverse mapping into the sorted edge list.

    Equivalent to [`trimesh.Trimesh.edges_unique`][] and
    [`trimesh.Trimesh.edges_unique_inverse`][] computed together.

    Parameters
    ----------
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed ``(n_faces * 3, 2)`` sorted edges (each row min-first).
        When ``None``, built from ``faces``.
    n_vertices
        Total number of vertices (used as the hash base). When ``None``, inferred from
        ``edges_sorted`` with a device-host sync.

    Returns
    -------
    unique_edges : twt.Array2dInt32
        Shape ``(m, 2)`` unique undirected vertex pairs, ``m <= n_faces * 3``.
    inverse : wp.array[wp.int32]
        Length ``n_faces * 3``. ``unique_edges[inverse[i]] == edges_sorted[i]``.

    Raises
    ------
    TypeError
        If ``edges_sorted`` is given and is not a rank-2 ``wp.int32`` array.
    ValueError
        If ``edges_sorted`` is given and does not have exactly two columns.
    RuntimeError
        If ``faces`` and ``edges_sorted`` are not all on one device.

    See Also
    --------
    [`trimesh.Trimesh.edges_unique`][]
    [`trimesh.Trimesh.edges_unique_inverse`][]
    """
    require_same_device(faces=faces, edges_sorted=edges_sorted)
    if edges_sorted is not None:
        twt.ensure_edge_pairs(edges_sorted, "edges_sorted")
    n_faces = int(faces.shape[0]) // 3
    device = faces.device

    if n_faces == 0:
        empty_edges = twt.empty_2d((0, 2), wp.int32, device=device)
        empty_inv = wp.empty(0, dtype=wp.int32, device=device)
        return empty_edges, empty_inv

    if edges_sorted is None:
        edges_sorted = faces_to_edges(faces, sorted=True)

    if n_vertices is None:
        n_vertices = tw.array.index_bound(edges_sorted)

    keys = tw.grouping.hash_indices_rows(edges_sorted, max_index=n_vertices)
    unique_keys, inverse = tw.grouping.unique_1d(keys, return_inverse=True)
    n_unique = int(unique_keys.shape[0])

    first_occ = tw.grouping.first_occurrence_indices(inverse, n_unique)

    unique_edges_out = tw.array.gather(edges_sorted, first_occ)

    return twt.as_array2d(unique_edges_out, wp.int32), inverse

edges_unique_inverse

edges_unique_inverse(
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> wp.array[wp.int32]

Inverse mapping from sorted edges into edges_unique.

Maps faces_to_edges (sorted) into edges_unique: edges_unique(faces)[edges_unique_inverse(faces)] == faces_to_edges(faces, sorted=True)

Parameters:

Name Type Description Default
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed sorted edges. When None, built from faces.

None
n_vertices int | None

Total vertex count. When None, inferred from edges_sorted with a device-host sync.

None

Returns:

Type Description
array[int32]

Length n_faces * 3 inverse indices.

Raises:

Type Description
TypeError

If edges_sorted is given and is not a rank-2 wp.int32 array.

ValueError

If edges_sorted is given and does not have exactly two columns.

RuntimeError

If faces and edges_sorted are not all on one device.

See Also

trimesh.Trimesh.edges_unique_inverse

Source code in triwarp/edges.py
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
def edges_unique_inverse(
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> wp.array[wp.int32]:
    """
    Inverse mapping from sorted edges into [`edges_unique`][triwarp.edges.edges_unique].

    Maps [`faces_to_edges`][triwarp.edges.faces_to_edges] (sorted) into `edges_unique`:
    ``edges_unique(faces)[edges_unique_inverse(faces)] == faces_to_edges(faces, sorted=True)``

    Parameters
    ----------
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed sorted edges. When ``None``, built from ``faces``.
    n_vertices
        Total vertex count. When ``None``, inferred from ``edges_sorted`` with a device-host sync.

    Returns
    -------
    wp.array[wp.int32]
        Length ``n_faces * 3`` inverse indices.

    Raises
    ------
    TypeError
        If ``edges_sorted`` is given and is not a rank-2 ``wp.int32`` array.
    ValueError
        If ``edges_sorted`` is given and does not have exactly two columns.
    RuntimeError
        If ``faces`` and ``edges_sorted`` are not all on one device.

    See Also
    --------
    [`trimesh.Trimesh.edges_unique_inverse`][]
    """
    require_same_device(faces=faces, edges_sorted=edges_sorted)
    return edges_unique(faces, edges_sorted=edges_sorted, n_vertices=n_vertices)[1]

edges_unique_length

edges_unique_length(
    vertices: array[vec3],
    faces: array[int32],
    unique_edges: Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> wp.array[wp.float32]

Euclidean length of each unique undirected edge.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
unique_edges Array2dInt32 | None

Optional precomputed unique edges (m, 2). When None, computed from faces.

None
n_vertices int | None

Total vertex count passed to edges_unique. Ignored when unique_edges is already provided.

None

Returns:

Type Description
array[float32]

Length m edge lengths on faces.device.

Raises:

Type Description
TypeError

If unique_edges is not a rank-2 wp.int32 array.

ValueError

If unique_edges does not have exactly two columns.

RuntimeError

If vertices, faces and unique_edges are not all on one device.

See Also

trimesh.Trimesh.edges_unique_length

Source code in triwarp/edges.py
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
def edges_unique_length(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    unique_edges: twt.Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> wp.array[wp.float32]:
    """
    Euclidean length of each unique undirected edge.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    unique_edges
        Optional precomputed unique edges ``(m, 2)``. When ``None``, computed from ``faces``.
    n_vertices
        Total vertex count passed to [`edges_unique`][triwarp.edges.edges_unique]. Ignored when
        ``unique_edges`` is already provided.

    Returns
    -------
    wp.array[wp.float32]
        Length ``m`` edge lengths on ``faces.device``.

    Raises
    ------
    TypeError
        If ``unique_edges`` is not a rank-2 ``wp.int32`` array.
    ValueError
        If ``unique_edges`` does not have exactly two columns.
    RuntimeError
        If ``vertices``, ``faces`` and ``unique_edges`` are not all on one device.

    See Also
    --------
    [`trimesh.Trimesh.edges_unique_length`][]
    """
    require_same_device(vertices=vertices, faces=faces, unique_edges=unique_edges)
    if unique_edges is None:
        unique_edges, _ = edges_unique(faces, n_vertices=n_vertices)

    return _edge_lengths(vertices, unique_edges, "unique_edges")

edges_length

edges_length(
    vertices: array[vec3],
    faces: array[int32],
    edges_in: Array2dInt32 | None = None,
) -> wp.array[wp.float32]

Euclidean length of every directed edge (one per face half-edge).

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
edges_in Array2dInt32 | None

Optional precomputed directed edges (n_faces * 3, 2). When None, computed from faces.

None

Returns:

Type Description
array[float32]

Length n_faces * 3 edge lengths on faces.device.

Raises:

Type Description
TypeError

If edges_in is not a rank-2 wp.int32 array.

ValueError

If edges_in does not have exactly two columns.

RuntimeError

If vertices, faces and edges_in are not all on one device.

Source code in triwarp/edges.py
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
def edges_length(
    vertices: wp.array[wp.vec3], faces: wp.array[wp.int32], edges_in: twt.Array2dInt32 | None = None
) -> wp.array[wp.float32]:
    """
    Euclidean length of every directed edge (one per face half-edge).

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_in
        Optional precomputed directed edges ``(n_faces * 3, 2)``. When ``None``, computed
        from ``faces``.

    Returns
    -------
    wp.array[wp.float32]
        Length ``n_faces * 3`` edge lengths on ``faces.device``.

    Raises
    ------
    TypeError
        If ``edges_in`` is not a rank-2 ``wp.int32`` array.
    ValueError
        If ``edges_in`` does not have exactly two columns.
    RuntimeError
        If ``vertices``, ``faces`` and ``edges_in`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, edges_in=edges_in)
    if edges_in is None:
        edges_in = faces_to_edges(faces)

    return _edge_lengths(vertices, edges_in, "edges_in")

face_edge_lengths

face_edge_lengths(
    vertices: array[vec3], faces: array[int32]
) -> twt.Array2dFloat32

Per-face edge lengths, in the intrinsic column order the cotangent formulas expect.

Column e holds the length of the edge opposite corner e, matching cotmatrix_entries_intrinsic and igl::cotmatrix_entries' intrinsic overload. Unlike edges_unique_length this is a per-corner table: an interior edge appears twice, which is what lets the entries be perturbed per face.

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
Array2dFloat32

(n_faces, 3) edge lengths on vertices.device.

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

mollify_intrinsic edges_unique_length

Source code in triwarp/edges.py
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
def face_edge_lengths(vertices: wp.array[wp.vec3], faces: wp.array[wp.int32]) -> twt.Array2dFloat32:
    """
    Per-face edge lengths, in the intrinsic column order the cotangent formulas expect.

    Column ``e`` holds the length of the edge *opposite* corner ``e``, matching
    [`cotmatrix_entries_intrinsic`][triwarp.laplacian.cotmatrix_entries_intrinsic] and
    ``igl::cotmatrix_entries``' intrinsic overload. Unlike
    [`edges_unique_length`][triwarp.edges.edges_unique_length] this is a per-*corner* table: an
    interior edge appears twice, which is what lets the entries be perturbed per face.

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

    Returns
    -------
    twt.Array2dFloat32
        ``(n_faces, 3)`` edge lengths on ``vertices.device``.

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

    See Also
    --------
    [`mollify_intrinsic`][triwarp.laplacian.mollify_intrinsic]
    [`edges_unique_length`][triwarp.edges.edges_unique_length]
    """
    require_same_device(vertices=vertices, faces=faces)
    device = vertices.device
    n_faces = int(faces.shape[0]) // 3
    lengths = twt.empty_2d((n_faces, 3), wp.float32, device=device)
    if n_faces == 0:
        return twt.as_array2d(lengths, wp.float32)
    wp.launch(
        kernel_edges.face_edge_lengths,
        dim=n_faces,
        inputs=[vertices, faces, lengths],
        device=device,
    )
    return twt.as_array2d(lengths, wp.float32)

mean_edge_length

mean_edge_length(
    vertices: array[vec3], faces: array[int32]
) -> float

Mean length of the 3 * n_faces per-face edges, counting a shared edge once per face.

Every face contributes all three of its edges, so an edge shared by two faces is counted twice and a boundary edge once -- which is what separates this from mean_unique_edge_length, the same average over each edge once. That weighting is deliberate, not an oversight: it is the average libigl's CurvatureCalculator::getAverageEdge computes, which is what igl::principal_curvature calls to set its sphere-search radius, and what principal_curvature therefore uses here.

There are two edge averages, and they are not interchangeable

mean_unique_edge_length counts each edge once, matching igl::avg_edge_length and MeshLab's avg_edge_length. The two agree exactly on a closed manifold mesh -- every edge has two incident faces there, so the doubling is uniform -- and diverge on anything with a boundary or a non-manifold edge: measured 0.452405 against 0.449910 on an open half-torus and 0.293087 against 0.291590 on a hemisphere.

Reach for this one when reproducing libigl's curvature; reach for the unique-edge one when reproducing anything else, including the heat method's timestep.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required

Returns:

Type Description
float

Mean length over the 3 * n_faces per-face edges. 0.0 when n_faces == 0.

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

mean_unique_edge_length The same average taken over the unique edges instead. edges_length The per-face lengths this averages.

Source code in triwarp/edges.py
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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
def mean_edge_length(vertices: wp.array[wp.vec3], faces: wp.array[wp.int32]) -> float:
    """
    Mean length of the ``3 * n_faces`` per-face edges, counting a shared edge once per face.

    Every face contributes all three of its edges, so an edge shared by two faces is counted twice
    and a boundary edge once -- which is what separates this from
    [`mean_unique_edge_length`][triwarp.edges.mean_unique_edge_length], the same average over each
    edge once. That weighting is deliberate, not an oversight: it is the average
    libigl's ``CurvatureCalculator::getAverageEdge`` computes, which is what
    ``igl::principal_curvature`` calls to set its sphere-search radius, and what
    [`principal_curvature`][triwarp.curvature.principal_curvature] therefore uses here.

    !!! note "There are two edge averages, and they are not interchangeable"
        [`mean_unique_edge_length`][triwarp.edges.mean_unique_edge_length] counts each edge once,
        matching ``igl::avg_edge_length`` and MeshLab's ``avg_edge_length``. The two agree exactly
        on a closed manifold mesh -- every edge has two incident faces there, so the doubling is
        uniform -- and diverge on anything with a boundary or a non-manifold edge: measured
        **0.452405 against 0.449910** on an open half-torus and **0.293087 against 0.291590** on a
        hemisphere.

        Reach for this one when reproducing libigl's curvature; reach for the unique-edge one when
        reproducing anything else, including the heat method's timestep.

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

    Returns
    -------
    float
        Mean length over the ``3 * n_faces`` per-face edges. ``0.0`` when ``n_faces == 0``.

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

    See Also
    --------
    [`mean_unique_edge_length`][triwarp.edges.mean_unique_edge_length]
        The same average taken over the unique edges instead.
    [`edges_length`][triwarp.edges.edges_length]
        The per-face lengths this averages.
    """
    require_same_device(vertices=vertices, faces=faces)
    n_faces = int(faces.shape[0]) // 3
    if n_faces == 0:
        return 0.0
    return tw.reduce.mean(edges_length(vertices, faces))

mean_unique_edge_length

mean_unique_edge_length(
    vertices: array[vec3], faces: array[int32]
) -> float

Mean length of the unique undirected edges.

Each edge is counted once, however many faces share it. This is the definition igl::avg_edge_length and MeshLab's avg_edge_length both use, and the one igl::heat_geodesics picks its diffusion timestep from -- so it is what heat_geodesic and vector_heat_operators use here.

See mean_edge_length for the per-face average, how far the two diverge on an open mesh, and why libigl carries both.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required

Returns:

Type Description
float

Mean length over the unique undirected edges. 0.0 when n_faces == 0.

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

mean_edge_length The same average taken over the per-face edges instead. edges_unique_length The per-edge lengths this averages.

Source code in triwarp/edges.py
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
def mean_unique_edge_length(vertices: wp.array[wp.vec3], faces: wp.array[wp.int32]) -> float:
    """
    Mean length of the **unique** undirected edges.

    Each edge is counted once, however many faces share it. This is the definition
    ``igl::avg_edge_length`` and MeshLab's ``avg_edge_length`` both use, and the one
    ``igl::heat_geodesics`` picks its diffusion timestep from -- so it is what
    [`heat_geodesic`][triwarp.heat.heat_geodesic] and
    [`vector_heat_operators`][triwarp.heat.vector_heat_operators] use here.

    See [`mean_edge_length`][triwarp.edges.mean_edge_length] for the per-face average, how far the
    two diverge on an open mesh, and why libigl carries both.

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

    Returns
    -------
    float
        Mean length over the unique undirected edges. ``0.0`` when ``n_faces == 0``.

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

    See Also
    --------
    [`mean_edge_length`][triwarp.edges.mean_edge_length]
        The same average taken over the per-face edges instead.
    [`edges_unique_length`][triwarp.edges.edges_unique_length]
        The per-edge lengths this averages.
    """
    require_same_device(vertices=vertices, faces=faces)
    n_faces = int(faces.shape[0]) // 3
    if n_faces == 0:
        return 0.0
    return tw.reduce.mean(edges_unique_length(vertices, faces, n_vertices=int(vertices.shape[0])))