Skip to content

holes

triwarp.holes

Closing mesh boundary holes, and stitching two open meshes along a rim.

Every boundary of a triangle mesh is an ordered vertex loop (boundary_loops, the analog of trimesh.repair.fill_holes's nx.cycle_basis and igl::boundary_loop_all). Each loop of B vertices is sealed with a purely topological triangulation — no smoothing or refinement:

  • fill_fan fans B - 2 triangles from the loop's first vertex, reusing only existing vertices (trimesh.repair.fill_holes(use_fan=True)).
  • fill_cone inserts one centroid vertex per hole and cones B triangles onto it (igl::topological_hole_fill, trimesh.repair.stitch(insert_vertices=True)).
  • fill_min_weight instead computes the minimum-weight triangulation of each loop (the Liepa/Klincsek interval DP): the B - 2 triangles over the existing loop vertices that minimize a geometric metric (plane-normalized circumcircle by default, with a min-area fallback), avoiding chords that would duplicate existing mesh edges. This is the robust, general-purpose filler for non-convex and non-planar holes; it adds no vertices.

Because boundary_loops orders each loop following the face-winding direction of the boundary half-edges, a fill triangle sharing a rim edge is emitted reversed on that edge so its winding is consistent with the adjacent original face. This assumes the input mesh is consistently wound; run make_winding_consistent first on meshes with mixed winding.

Joining two open meshes across one boundary loop each is the same family and lives here too: stitch and its variants, over the loop-level engines stitch_loops and stitch_loops_min_weight. It is the same minimum-weight machinery -- the same interval DP, the same rim bookkeeping and the same metric vocabulary -- applied to a band between two rims rather than a cap over one, which is why the two halves share a module rather than a private import list.

For a smooth, well-graded patch, fill_smooth and stitch_smooth run a three-stage pipeline on top of the min-weight fill/stitch: the patch is refined to a target edge length with Delaunay edge flips (subdivide_region_to_size) and its new interior vertices are smoothed into the surrounding surface — a sharp-boundary umbrella solve (smooth_region_fixed_rim) followed by a cross-boundary least-squares solve (smooth_region), with an optional natural_smooth collar that blends the patch into the neighbouring surface.

Not every boundary wants closing. extend_hole and build_bottom extrude a rim -- out to a plane you place, or down to a base fitted under each rim's own lowest point -- which leaves the mesh open with a planar rim that a min-weight fill then closes without folding; that two-step is how a scanned shell becomes a printable solid. And bridge_edges and bridge_edges_smooth are the local form of stitching: they join one boundary edge to another with a small patch or a curved strip, leaving the rest of both boundaries open, which is what joins two tubes at a chosen seam or adds a handle where the rim family would consume the whole loop. join_closest_components is the driver over the first of those: it settles which edges to bridge, welding several open shells into one connected surface with one rim, for a filler to close afterwards.

The return shape follows from that: a filler that only triangulates existing rim vertices returns faces alone, while one that inserts a vertex -- a cone's apex, a refined patch's interior, an extrusion's projected ring -- returns (vertices, faces), because the position buffer grew too.

Every filler returns a buffer independent of faces, including on the no-op path where there was no hole to fill, so a caller may write into the result without disturbing its input.

fill_fan

fill_fan(
    vertices: array[vec3],
    faces: array[int32],
    preserve_largest_hole: bool = False,
) -> wp.array[wp.int32]

Fill every boundary hole with a triangle fan from each loop's first vertex.

A boundary loop of B vertices is sealed with B - 2 triangles all sharing the loop's first vertex, reusing only existing vertices (trimesh.repair.fill_holes(use_fan=True)). The fill is topological: a fan is only geometrically ideal for convex holes. The vertex buffer is unchanged.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
preserve_largest_hole bool

When True, leave the single largest boundary loop (greatest perimeter) open and fill only the rest. This turns a mesh with a known disk-like topology into a single-boundary disk, which is the input a robust UV parametrization expects.

False

Returns:

Type Description
array[int32]

Flat face buffer of the original faces followed by the new fill triangles, on faces.device. A watertight or empty mesh — or, with preserve_largest_hole, a mesh whose only hole is the largest — is returned unchanged (a copy).

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

fill_cone fill_min_weight Far more robust on a non-convex or non-planar hole, and it also adds no vertices. boundary_loops make_winding_consistent

Notes

Fill winding is consistent with the adjacent faces only when the input mesh is consistently wound (see make_winding_consistent).

Source code in triwarp/holes.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def fill_fan(
    vertices: wp.array[wp.vec3], faces: wp.array[wp.int32], preserve_largest_hole: bool = False
) -> wp.array[wp.int32]:
    """
    Fill every boundary hole with a triangle fan from each loop's first vertex.

    A boundary loop of ``B`` vertices is sealed with ``B - 2`` triangles all sharing the loop's
    first vertex, reusing only existing vertices (``trimesh.repair.fill_holes(use_fan=True)``).
    The fill is topological: a fan is only geometrically ideal for convex holes. The vertex
    buffer is unchanged.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    preserve_largest_hole
        When ``True``, leave the single largest boundary loop (greatest perimeter) open and fill
        only the rest. This turns a mesh with a known disk-like topology into a single-boundary
        disk, which is the input a robust UV parametrization expects.

    Returns
    -------
    wp.array[wp.int32]
        Flat face buffer of the original faces followed by the new fill triangles, on
        ``faces.device``. A watertight or empty mesh — or, with ``preserve_largest_hole``, a mesh
        whose only hole is the largest — is returned unchanged (a copy).

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

    See Also
    --------
    [`fill_cone`][triwarp.holes.fill_cone]
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
        Far more robust on a non-convex or non-planar hole, and it also adds no vertices.
    [`boundary_loops`][triwarp.boundary.boundary_loops]
    [`make_winding_consistent`][triwarp.repair.make_winding_consistent]

    Notes
    -----
    Fill winding is consistent with the adjacent faces only when the input mesh is consistently
    wound (see [`make_winding_consistent`][triwarp.repair.make_winding_consistent]).
    """
    require_same_device(vertices=vertices, faces=faces)
    device = faces.device
    n_faces = int(faces.shape[0]) // 3
    packed = _hole_loops(vertices, faces, preserve_largest_hole) if n_faces > 0 else None
    if packed is None:
        return wp.clone(faces)

    flat_loops, loop_starts = packed.flat_loops, packed.starts
    n_loops, total = packed.n_loops, packed.total
    n_tri = total - 2 * n_loops
    fill_faces = wp.empty(3 * n_tri, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.fan_faces,
        dim=n_loops,
        inputs=[flat_loops, loop_starts, packed.sizes, fill_faces],
        device=device,
    )
    return tw.array.concatenate([faces, fill_faces])

fill_cone

fill_cone(
    vertices: array[vec3],
    faces: array[int32],
    preserve_largest_hole: bool = False,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Fill every boundary hole by coning it onto a new centroid vertex.

A boundary loop of B vertices is sealed with B triangles fanning from one new vertex placed at the loop's centroid (igl::topological_hole_fill, trimesh.repair.stitch(insert_vertices=True)). Unlike fill_fan this appends one vertex per hole, so the vertex buffer grows.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
preserve_largest_hole bool

When True, leave the single largest boundary loop (greatest perimeter) open and fill only the rest. This turns a mesh with a known disk-like topology into a single-boundary disk, which is the input a robust UV parametrization expects.

False

Returns:

Name Type Description
new_vertices array[vec3]

Original vertices followed by one centroid per filled hole, on vertices.device.

new_faces array[int32]

Original faces followed by the new cone triangles, on faces.device. A watertight or empty mesh — or, with preserve_largest_hole, a mesh whose only hole is the largest — is returned unchanged (copies).

Raises:

Type Description
RuntimeError

If vertices and faces are not all on one device.

See Also

fill_fan fill_min_weight Far more robust on a non-convex or non-planar hole, and unlike this it adds no vertices. boundary_loops make_winding_consistent

Notes

Fill winding is consistent with the adjacent faces only when the input mesh is consistently wound (see make_winding_consistent).

Source code in triwarp/holes.py
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
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
def fill_cone(
    vertices: wp.array[wp.vec3], faces: wp.array[wp.int32], preserve_largest_hole: bool = False
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Fill every boundary hole by coning it onto a new centroid vertex.

    A boundary loop of ``B`` vertices is sealed with ``B`` triangles fanning from one new vertex
    placed at the loop's centroid (``igl::topological_hole_fill``,
    ``trimesh.repair.stitch(insert_vertices=True)``). Unlike
    [`fill_fan`][triwarp.holes.fill_fan] this appends one vertex per hole, so
    the vertex buffer grows.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    preserve_largest_hole
        When ``True``, leave the single largest boundary loop (greatest perimeter) open and fill
        only the rest. This turns a mesh with a known disk-like topology into a single-boundary
        disk, which is the input a robust UV parametrization expects.

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Original vertices followed by one centroid per filled hole, on ``vertices.device``.
    new_faces : wp.array[wp.int32]
        Original faces followed by the new cone triangles, on ``faces.device``. A watertight or
        empty mesh — or, with ``preserve_largest_hole``, a mesh whose only hole is the largest —
        is returned unchanged (copies).

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

    See Also
    --------
    [`fill_fan`][triwarp.holes.fill_fan]
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
        Far more robust on a non-convex or non-planar hole, and unlike this it adds no vertices.
    [`boundary_loops`][triwarp.boundary.boundary_loops]
    [`make_winding_consistent`][triwarp.repair.make_winding_consistent]

    Notes
    -----
    Fill winding is consistent with the adjacent faces only when the input mesh is consistently
    wound (see [`make_winding_consistent`][triwarp.repair.make_winding_consistent]).
    """
    require_same_device(vertices=vertices, faces=faces)
    device = faces.device
    n_faces = int(faces.shape[0]) // 3
    packed = _hole_loops(vertices, faces, preserve_largest_hole) if n_faces > 0 else None
    if packed is None:
        return wp.clone(vertices), wp.clone(faces)

    flat_loops, loop_starts = packed.flat_loops, packed.starts
    n_loops, total = packed.n_loops, packed.total
    n_vertices = int(vertices.shape[0])

    centroids = wp.empty(n_loops, dtype=wp.vec3, device=device)
    fill_faces = wp.empty(3 * total, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.cone_fill,
        dim=n_loops,
        inputs=[
            vertices,
            flat_loops,
            loop_starts,
            packed.sizes,
            wp.int32(n_vertices),
            centroids,
            fill_faces,
        ],
        device=device,
    )
    return (tw.array.concatenate([vertices, centroids]), tw.array.concatenate([faces, fill_faces]))

fill_min_weight

fill_min_weight(
    vertices: array[vec3],
    faces: array[int32],
    metric: str = "plane_normalized",
    resolve_multiple_edges: bool = True,
    preserve_largest_hole: bool = False,
    smooth_boundary: bool = True,
) -> wp.array[wp.int32]

Fill every boundary hole with a minimum-weight triangulation over its existing vertices.

The classic Liepa/Klincsek interval dynamic program: each boundary loop of B vertices is sealed with the B - 2 triangles that minimize a geometric metric, reusing only existing vertices (the vertex buffer is unchanged). This is far more robust than fill_fan for non-convex or non-planar holes and, unlike fill_cone, adds no vertices.

The O(B^3) DP runs on device as one parallel kernel launch per triangulation span, and every hole is solved in the same launches: the per-loop B x B tables are packed into one ragged buffer, so the launch count is max(B) - 1 for the whole mesh rather than B - 1 per hole, and the chord test, the plane normals and the min-area fallback decision are likewise one pass each. Only the O(B) traceback is host-side, over a single predecessor buffer. A mesh with many small holes therefore costs about what one hole costs.

Each span launch puts a block on every interval rather than a thread, with the block's lanes striding the apex loop and a two-stage tile reduction picking the winner: the interval grid is only n_loops * (max(B) - span) wide, so a mesh with a couple of long rims would otherwise have only a few hundred threads carrying the whole cubic term. The reduction reproduces the DP's smallest-apex tie-break exactly, which it has to, because the tie decides the triangles (see kernels/holes.py::fill_dp_span_tiled).

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
metric str

Which fill metric to minimize:

  • "plane_normalized" (default) — circumcircle-diameter times aspect ratio, penalizing triangles flipped or tilted more than 60 degrees off the hole plane; falls back to "min_area" when the best triangulation is still bad (non-planar/degenerate).
  • "min_area" — summed triangle area; never rejects a triangulation as bad, which is what makes it the fallback the other metrics fall back to. That is a statement about the metric only: see resolve_multiple_edges for the one thing that can still leave an interval unfilled under it.
  • "circumscribed" — summed circumcircle diameter.
  • "plane" — circumcircle diameter with a flipped-normal penalty.
  • "min_tri_angle" — maximizes the minimal triangle angle.
  • "edge_length" — summed new-edge length.
  • "universal" — circumcircle diameter plus a dihedral-smoothing edge term; the smooth, general-purpose choice.
  • "max_dihedral" — minimizes the maximal dihedral angle.
  • "complex_fill" — area/aspect triangle term plus a strong dihedral edge term.

The dihedral (edge-based) metrics — universal, max_dihedral, complex_fill, edge_length — blend into the surrounding surface via smooth_boundary.

'plane_normalized'
resolve_multiple_edges bool

When True (default), forbid the triangulation from creating a chord that duplicates an existing mesh edge, avoiding non-manifold results on pinched holes. Such a chord is forbidden outright rather than re-routed, and the constraint binds under every metric including the min_area fallback. An interval with no admissible apex left contributes no triangles and is skipped silently, so a hole whose every triangulation is blocked comes back still open rather than raising — pass False to trade that for a possibly non-manifold fill.

True
preserve_largest_hole bool

When True, leave the single largest boundary loop (greatest perimeter) open and fill only the rest — the single-boundary disk a robust UV parametrization expects.

False
smooth_boundary bool

When True (default), the dihedral edge metrics also score the hole rim edges against the existing adjacent faces, so the patch blends smoothly into the surface instead of turning sharply at the boundary. No effect on the triangle-only metrics.

True

Returns:

Type Description
array[int32]

Flat face buffer of the original faces followed by the fill triangles, on faces.device. A watertight or empty mesh — or, with preserve_largest_hole, a mesh whose only hole is the largest — is returned unchanged (a copy).

Raises:

Type Description
ValueError

If metric is not one of the supported metric names.

RuntimeError

If vertices and faces are not all on one device.

See Also

fill_fan fill_cone boundary_loops make_winding_consistent

Notes

Fill winding is consistent with the adjacent faces only when the input mesh is consistently wound (see make_winding_consistent).

Source code in triwarp/holes.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
def fill_min_weight(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    metric: str = "plane_normalized",
    resolve_multiple_edges: bool = True,
    preserve_largest_hole: bool = False,
    smooth_boundary: bool = True,
) -> wp.array[wp.int32]:
    """
    Fill every boundary hole with a minimum-weight triangulation over its existing vertices.

    The classic Liepa/Klincsek interval dynamic program: each boundary loop of ``B`` vertices is
    sealed with the ``B - 2`` triangles that minimize a geometric metric, reusing only existing
    vertices (the vertex buffer is unchanged). This is far more robust
    than [`fill_fan`][triwarp.holes.fill_fan] for non-convex or non-planar holes
    and, unlike [`fill_cone`][triwarp.holes.fill_cone], adds no vertices.

    The ``O(B^3)`` DP runs on device as one parallel kernel launch per triangulation span, and
    **every hole is solved in the same launches**: the per-loop ``B x B`` tables are packed into one
    ragged buffer, so the launch count is ``max(B) - 1`` for the whole mesh rather than ``B - 1``
    per hole, and the chord test, the plane normals and the min-area fallback decision are likewise
    one pass each. Only the ``O(B)`` traceback is host-side, over a single predecessor buffer. A
    mesh with many small holes therefore costs about what one hole costs.

    Each span launch puts a **block** on every interval rather than a thread, with the block's lanes
    striding the apex loop and a two-stage tile reduction picking the winner: the interval grid is
    only ``n_loops * (max(B) - span)`` wide, so a mesh with a couple of long rims would otherwise
    have only a few hundred threads carrying the whole cubic term. The reduction reproduces the DP's
    smallest-apex tie-break exactly, which it has to, because the tie decides the triangles (see
    ``kernels/holes.py::fill_dp_span_tiled``).

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    metric
        Which fill metric to minimize:

        - ``"plane_normalized"`` (default) — circumcircle-diameter times aspect ratio, penalizing
          triangles flipped or tilted more than 60 degrees off the hole plane; falls back to
          ``"min_area"`` when the best triangulation is still bad (non-planar/degenerate).
        - ``"min_area"`` — summed triangle area; never rejects a triangulation as bad, which is
          what makes it the fallback the other metrics fall back *to*. That is a statement about
          the metric only: see ``resolve_multiple_edges`` for the one thing that can still leave an
          interval unfilled under it.
        - ``"circumscribed"`` — summed circumcircle diameter.
        - ``"plane"`` — circumcircle diameter with a flipped-normal penalty.
        - ``"min_tri_angle"`` — maximizes the minimal triangle angle.
        - ``"edge_length"`` — summed new-edge length.
        - ``"universal"`` — circumcircle diameter plus a dihedral-smoothing edge term; the smooth,
          general-purpose choice.
        - ``"max_dihedral"`` — minimizes the maximal dihedral angle.
        - ``"complex_fill"`` — area/aspect triangle term plus a strong dihedral edge term.

        The dihedral (edge-based) metrics — ``universal``, ``max_dihedral``, ``complex_fill``,
        ``edge_length`` — blend into the surrounding surface via ``smooth_boundary``.
    resolve_multiple_edges
        When ``True`` (default), forbid the triangulation from creating a chord that duplicates an
        existing mesh edge, avoiding non-manifold results on pinched holes. Such a chord is
        forbidden outright rather than re-routed, and the constraint binds under **every** metric
        including the ``min_area`` fallback. An interval with no admissible apex left contributes
        no triangles and is skipped silently, so a hole whose every triangulation is blocked comes
        back still open rather than raising — pass ``False`` to trade that for a possibly
        non-manifold fill.
    preserve_largest_hole
        When ``True``, leave the single largest boundary loop (greatest perimeter) open and fill
        only the rest — the single-boundary disk a robust UV parametrization expects.
    smooth_boundary
        When ``True`` (default), the dihedral edge metrics also score the hole
        rim edges against the existing adjacent faces, so the patch blends smoothly into the surface
        instead of turning sharply at the boundary. No effect on the triangle-only metrics.

    Returns
    -------
    wp.array[wp.int32]
        Flat face buffer of the original faces followed by the fill triangles, on ``faces.device``.
        A watertight or empty mesh — or, with ``preserve_largest_hole``, a mesh whose only hole is
        the largest — is returned unchanged (a copy).

    Raises
    ------
    ValueError
        If ``metric`` is not one of the supported metric names.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    See Also
    --------
    [`fill_fan`][triwarp.holes.fill_fan]
    [`fill_cone`][triwarp.holes.fill_cone]
    [`boundary_loops`][triwarp.boundary.boundary_loops]
    [`make_winding_consistent`][triwarp.repair.make_winding_consistent]

    Notes
    -----
    Fill winding is consistent with the adjacent faces only when the input mesh is consistently
    wound (see [`make_winding_consistent`][triwarp.repair.make_winding_consistent]).
    """
    require_same_device(vertices=vertices, faces=faces)
    _check_fill_metric(metric)
    n_faces = int(faces.shape[0]) // 3
    if n_faces == 0:
        return wp.clone(faces)
    edges_sorted = tw.edges.faces_to_edges(faces, sorted=True)
    loops = _hole_loops(vertices, faces, preserve_largest_hole, edges_sorted)
    if loops is None:
        return wp.clone(faces)
    return _fill_packed_loops(
        vertices, faces, loops, metric, resolve_multiple_edges, smooth_boundary, edges_sorted
    )

fill_loops_min_weight

fill_loops_min_weight(
    vertices: array[vec3],
    faces: array[int32],
    loops: list[array[int32]],
    metric: str,
    resolve_multiple_edges: bool,
    smooth_boundary: bool = True,
) -> wp.array[wp.int32]

Min-weight-triangulate the given boundary loops and append the fill faces.

The loop-selecting form of fill_min_weight: same engine, but the caller names which boundary loops to close rather than getting every one of them. Both wrappers hand the packed loops to one shared private engine, so the triangulation is identical where the loop sets are. Every loop is sealed together by the interval DP under metric (with a min_area fallback where the primary metric yields a bad triangulation), reusing only existing vertices. Cost is set by the longest loop, not by the loop count; see fill_min_weight.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
loops list[array[int32]]

Boundary loops to fill, as ordered vertex-index arrays (>= 3 vertices each), e.g. from boundary_loops.

required
metric str

Fill metric name; see fill_min_weight.

required
resolve_multiple_edges bool

When True, forbid chords that duplicate an existing mesh edge.

required
smooth_boundary bool True

Returns:

Type Description
array[int32]

Flat face buffer of faces followed by the fill triangles for loops, on faces.device. Unchanged (a copy) when loops is empty.

Raises:

Type Description
ValueError

If metric is not one of the names fill_min_weight lists.

RuntimeError

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

See Also

fill_min_weight

Source code in triwarp/holes.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
def fill_loops_min_weight(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    loops: list[wp.array[wp.int32]],
    metric: str,
    resolve_multiple_edges: bool,
    smooth_boundary: bool = True,
) -> wp.array[wp.int32]:
    """
    Min-weight-triangulate the given boundary ``loops`` and append the fill faces.

    The loop-selecting form of [`fill_min_weight`][triwarp.holes.fill_min_weight]: same engine, but
    the caller names which boundary loops to close rather than getting every one of them. Both
    wrappers hand the packed loops to one shared private engine, so the triangulation is identical
    where the loop sets are. Every loop is sealed **together** by the interval DP under ``metric``
    (with a ``min_area`` fallback where the primary metric yields a bad triangulation), reusing
    only existing vertices. Cost is set by the longest loop, not by the loop count; see
    [`fill_min_weight`][triwarp.holes.fill_min_weight].

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    loops
        Boundary loops to fill, as ordered vertex-index arrays (``>= 3`` vertices each), e.g. from
        [`boundary_loops`][triwarp.boundary.boundary_loops].
    metric
        Fill metric name; see [`fill_min_weight`][triwarp.holes.fill_min_weight].
    resolve_multiple_edges
        When ``True``, forbid chords that duplicate an existing mesh edge.
    smooth_boundary
        See [`fill_min_weight`][triwarp.holes.fill_min_weight].

    Returns
    -------
    wp.array[wp.int32]
        Flat face buffer of ``faces`` followed by the fill triangles for ``loops``, on
        ``faces.device``. Unchanged (a copy) when ``loops`` is empty.

    Raises
    ------
    ValueError
        If ``metric`` is not one of the names
        [`fill_min_weight`][triwarp.holes.fill_min_weight] lists.
    RuntimeError
        If ``vertices``, ``faces`` and ``loops`` are not all on one device.

    See Also
    --------
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
    """
    require_same_device(vertices=vertices, faces=faces, loops=loops)
    _check_fill_metric(metric)
    if len(loops) == 0:
        return wp.clone(faces)
    return _fill_packed_loops(
        vertices, faces, _pack_loops(loops), metric, resolve_multiple_edges, smooth_boundary
    )

fill_small

fill_small(
    vertices: array[vec3],
    faces: array[int32],
    max_perimeter: float | None = None,
    *,
    max_edges: int | None = None,
) -> wp.array[wp.int32]

Fill only the small boundary loops, sized either by perimeter or by boundary-edge count.

Intended open boundaries (large loops) are left untouched; spurious small holes are sealed by the shared min-weight interval DP (fill_loops_min_weight). Used by triwarp.reconstruction.triangulate_point_cloud to seal small gaps left by sparse or non-uniform point-cloud sampling.

Exactly one of the two thresholds is given, and they are genuinely different questions rather than two spellings of one:

  • max_perimeter is a length, so it is scale-dependent and sampling-independent -- the right threshold when "small" means small on the object.
  • max_edges is a count, so it is scale-independent and sampling-dependent -- the right threshold when "small" means few triangles to patch, which is what bounds the O(B^3) interval DP behind the fill.

Neither converts into the other without knowing the rim's sampling, and the reference implementations split the same way: two of the three take a boundary-edge count and only one takes a length. On a mesh whose rims are sampled unevenly the two thresholds select opposite loops -- tests/test_holes.py builds exactly that case, a 16-vertex rim of perimeter 2.41 beside a 5-vertex rim of perimeter 3.16.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
faces array[int32]

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

required
max_perimeter float | None

Only boundary loops with perimeter at most this value are filled. Positional for backward compatibility. Mutually exclusive with max_edges.

None
max_edges int | None

Only boundary loops with at most this many boundary edges are filled -- inclusive, so a 24-edge rim is filled at max_edges=24 and left open at 23. A closed loop has as many edges as vertices, so this is also its vertex count. Mutually exclusive with max_perimeter.

None

Returns:

Type Description
array[int32]

Flat face buffer of faces followed by the fill triangles for the small loops, on faces.device. A copy of faces when no boundary loop meets the threshold.

Raises:

Type Description
ValueError

If neither max_perimeter nor max_edges is given, or if both are.

RuntimeError

If vertices and faces are not all on one device.

Notes

max_edges costs nothing to evaluate: the loop sizes are already host-side metadata of the packed loops, so that branch skips the segmented perimeter launch and its readback entirely.

The inclusive bound is stated because it is easy to get wrong from the outside -- one reference whose parameter this matches documents itself as "less than" and is measurably "at most".

See Also

fill_min_weight fill_loops_min_weight fillable_loop_mask Which loops the DP can fill at all, a separate question from which are small.

Source code in triwarp/holes.py
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
def fill_small(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    max_perimeter: float | None = None,
    *,
    max_edges: int | None = None,
) -> wp.array[wp.int32]:
    """
    Fill only the *small* boundary loops, sized either by perimeter or by boundary-edge count.

    Intended open boundaries (large loops) are left untouched; spurious small holes are sealed by
    the shared min-weight interval DP
    ([`fill_loops_min_weight`][triwarp.holes.fill_loops_min_weight]). Used by
    [`triwarp.reconstruction.triangulate_point_cloud`][triwarp.reconstruction.triangulate_point_cloud]
    to seal small gaps left by sparse or non-uniform point-cloud sampling.

    Exactly one of the two thresholds is given, and they are genuinely different questions rather
    than two spellings of one:

    - ``max_perimeter`` is a **length**, so it is scale-dependent and sampling-independent -- the
      right threshold when "small" means *small on the object*.
    - ``max_edges`` is a **count**, so it is scale-independent and sampling-dependent -- the right
      threshold when "small" means *few triangles to patch*, which is what bounds the ``O(B^3)``
      interval DP behind the fill.

    Neither converts into the other without knowing the rim's sampling, and the reference
    implementations split the same way: two of the three take a boundary-edge count and only one
    takes a length. On a mesh whose rims are sampled unevenly the two thresholds select **opposite**
    loops -- ``tests/test_holes.py`` builds exactly that case, a 16-vertex rim of perimeter 2.41
    beside a 5-vertex rim of perimeter 3.16.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    max_perimeter
        Only boundary loops with perimeter at most this value are filled. Positional for backward
        compatibility. Mutually exclusive with ``max_edges``.
    max_edges
        Only boundary loops with at most this many boundary edges are filled -- **inclusive**, so a
        24-edge rim is filled at ``max_edges=24`` and left open at 23. A closed loop has as many
        edges as vertices, so this is also its vertex count. Mutually exclusive with
        ``max_perimeter``.

    Returns
    -------
    wp.array[wp.int32]
        Flat face buffer of ``faces`` followed by the fill triangles for the small loops, on
        ``faces.device``. A copy of ``faces`` when no boundary loop meets the threshold.

    Raises
    ------
    ValueError
        If neither ``max_perimeter`` nor ``max_edges`` is given, or if both are.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    Notes
    -----
    ``max_edges`` costs nothing to evaluate: the loop sizes are already host-side metadata of the
    packed loops, so that branch skips the segmented perimeter launch and its readback entirely.

    The inclusive bound is stated because it is easy to get wrong from the outside -- one reference
    whose parameter this matches documents itself as "less than" and is measurably "at most".

    See Also
    --------
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
    [`fill_loops_min_weight`][triwarp.holes.fill_loops_min_weight]
    [`fillable_loop_mask`][triwarp.holes.fillable_loop_mask]
        Which loops the DP can fill at all, a separate question from which are small.
    """
    require_same_device(vertices=vertices, faces=faces)
    if (max_perimeter is None) == (max_edges is None):
        raise ValueError("pass exactly one of max_perimeter or max_edges")

    packed = _hole_loops(vertices, faces)
    if packed is None:
        return wp.clone(faces)

    if max_edges is not None:
        small_np = packed.sizes_np <= max_edges
    else:
        # One segmented-sum launch measures every rim, so the selection costs a single readback
        # rather than a copy of the whole vertex buffer plus one of each loop.
        small_np = packed.perimeters(vertices) <= max_perimeter
    if not small_np.any():
        return wp.clone(faces)
    if not small_np.all():
        kept = zip(_unpack_loops(packed), small_np, strict=True)
        packed = _pack_loops([loop for loop, keep in kept if keep])
    return _fill_packed_loops(vertices, faces, packed, "plane_normalized", True, True)

fill_smooth

fill_smooth(
    vertices: array[vec3],
    faces: array[int32],
    metric: str = "plane_normalized",
    *,
    triangulate_only: bool = False,
    max_edge: float | None = None,
    max_edge_splits: int = 1000,
    max_angle_change_after_flip: float = math.radians(30.0),
    smooth_curvature: bool = True,
    natural_smooth: bool = False,
    edge_weights: str = "cotan",
    preserve_largest_hole: bool = False,
    resolve_multiple_edges: bool = True,
    smooth_boundary: bool = True,
    refine: Literal["max_edge", "density"] = "max_edge",
    return_patch: bool = False,
) -> (
    tuple[wp.array[wp.vec3], wp.array[wp.int32]]
    | tuple[
        wp.array[wp.vec3],
        wp.array[wp.int32],
        wp.array[wp.bool],
    ]
)

Fill every boundary hole with a smooth, refined patch.

Runs the full three-stage pipeline: a minimum-weight triangulation seals each hole over its existing rim vertices (fill_min_weight), the patch is then subdivided to max_edge with Delaunay edge flips (subdivide_region_to_size), and finally the new interior patch vertices are smoothed into the surrounding surface (smooth_region_fixed_rim then smooth_region). Unlike the purely topological fillers, this produces a well-graded, curvature-continuous patch.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
metric str

Minimum-weight fill metric; see fill_min_weight.

'plane_normalized'
triangulate_only bool

When True, only fill (no subdivision or smoothing) — equivalent to fill_min_weight.

False
max_edge float | None

Target maximum patch edge length. None (default) derives it from the mean rim edge length of the holes being filled; a sequential budget target has no parallel analogue.

None
max_edge_splits int

Soft cap on the number of edge splits during subdivision.

1000
max_angle_change_after_flip float

Dihedral-angle-change gate for the Delaunay flip pass (default 30°).

radians(30.0)
smooth_curvature bool

When True (default), smooth the new patch vertices after subdivision.

True
natural_smooth bool

When True, additionally grow a collar around the patch and smooth it so the patch blends into the surrounding surface.

False
edge_weights str

Laplacian edge weights for the cross-boundary smooth solve: "cotan" (default) or "unit".

'cotan'
preserve_largest_hole bool

When True, leave the single largest boundary loop open.

False
resolve_multiple_edges bool

When True (default), forbid fill chords that duplicate existing mesh edges.

True
smooth_boundary bool

When True (default), also run the cross-boundary smooth solve so the patch is C¹ across its rim. Also tunes the fill metric's rim edge terms.

True
refine Literal['max_edge', 'density']

Which subdivision criterion the refinement stage uses. "max_edge" (default) bisects patch edges longer than max_edge; "density" splits patch triangles at their centroid while their sampling is coarser than the surrounding mesh's, which is Liepa's criterion and what the reference hole fillers do. The two differ only on a graded neighbourhood, where a single target length cannot be right at both ends of the grading: "density" keeps the patch triangle size closer to the local surrounding scale there than a single max_edge target can. max_edge and max_edge_splits are ignored under "density".

'max_edge'
return_patch bool

When True, also return a length-n_out_faces wp.bool mask of the patch faces.

False

Returns:

Name Type Description
new_vertices array[vec3]

Original vertices followed by the inserted patch vertices, on vertices.device.

new_faces array[int32]

Original faces followed by the patch faces.

patch_mask array[bool]

Only when return_patch is True: mask of the patch faces in new_faces.

Raises:

Type Description
ValueError

If metric, edge_weights or refine is unknown.

RuntimeError

If vertices and faces are not all on one device.

See Also

fill_min_weight stitch_smooth subdivide_region_to_size

Notes

The three stages are: minimum-weight fill, refine the patch to the target edge length, then smooth its new interior vertices into the surrounding surface.

Winding is consistent with the surrounding faces only for a consistently wound input.

Source code in triwarp/holes.py
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
def fill_smooth(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    metric: str = "plane_normalized",
    *,
    triangulate_only: bool = False,
    max_edge: float | None = None,
    max_edge_splits: int = 1000,
    max_angle_change_after_flip: float = math.radians(30.0),
    smooth_curvature: bool = True,
    natural_smooth: bool = False,
    edge_weights: str = "cotan",
    preserve_largest_hole: bool = False,
    resolve_multiple_edges: bool = True,
    smooth_boundary: bool = True,
    refine: Literal["max_edge", "density"] = "max_edge",
    return_patch: bool = False,
) -> (
    tuple[wp.array[wp.vec3], wp.array[wp.int32]]
    | tuple[wp.array[wp.vec3], wp.array[wp.int32], wp.array[wp.bool]]
):
    """
    Fill every boundary hole with a smooth, refined patch.

    Runs the full three-stage pipeline: a minimum-weight triangulation seals each hole over its
    existing rim vertices ([`fill_min_weight`][triwarp.holes.fill_min_weight]),
    the patch is then subdivided to ``max_edge`` with Delaunay edge flips
    ([`subdivide_region_to_size`][triwarp.remesh.subdivide_region_to_size]), and finally the new
    interior patch vertices are smoothed into the surrounding surface
    ([`smooth_region_fixed_rim`][triwarp.smoothing.smooth_region_fixed_rim]
    then [`smooth_region`][triwarp.smoothing.smooth_region]). Unlike the purely
    topological fillers, this produces a well-graded, curvature-continuous patch.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    metric
        Minimum-weight fill metric; see
        [`fill_min_weight`][triwarp.holes.fill_min_weight].
    triangulate_only
        When ``True``, only fill (no subdivision or smoothing) — equivalent to
        [`fill_min_weight`][triwarp.holes.fill_min_weight].
    max_edge
        Target maximum patch edge length. ``None`` (default) derives it from the mean rim edge
        length of the holes being filled; a sequential budget target has no parallel analogue.
    max_edge_splits
        Soft cap on the number of edge splits during subdivision.
    max_angle_change_after_flip
        Dihedral-angle-change gate for the Delaunay flip pass (default 30°).
    smooth_curvature
        When ``True`` (default), smooth the new patch vertices after subdivision.
    natural_smooth
        When ``True``, additionally grow a collar around the patch and smooth it so the patch
        blends into the surrounding surface.
    edge_weights
        Laplacian edge weights for the cross-boundary smooth solve: ``"cotan"`` (default) or
        ``"unit"``.
    preserve_largest_hole
        When ``True``, leave the single largest boundary loop open.
    resolve_multiple_edges
        When ``True`` (default), forbid fill chords that duplicate existing mesh edges.
    smooth_boundary
        When ``True`` (default), also run the cross-boundary smooth solve so the patch is C¹ across
        its rim. Also tunes the fill metric's rim edge terms.
    refine
        Which subdivision criterion the refinement stage uses. ``"max_edge"`` (default) bisects
        patch edges longer than ``max_edge``; ``"density"`` splits patch triangles at their centroid
        while their sampling is coarser than the surrounding mesh's, which is Liepa's criterion and
        what the reference hole fillers do. The two differ only on a **graded** neighbourhood, where
        a single target length cannot be right at both ends of the grading: ``"density"`` keeps the
        patch triangle size closer to the local surrounding scale there than a single ``max_edge``
        target can. ``max_edge`` and ``max_edge_splits`` are ignored under ``"density"``.
    return_patch
        When ``True``, also return a length-``n_out_faces`` ``wp.bool`` mask of the patch faces.

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Original vertices followed by the inserted patch vertices, on ``vertices.device``.
    new_faces : wp.array[wp.int32]
        Original faces followed by the patch faces.
    patch_mask : wp.array[wp.bool]
        Only when ``return_patch`` is ``True``: mask of the patch faces in ``new_faces``.

    Raises
    ------
    ValueError
        If ``metric``, ``edge_weights`` or ``refine`` is unknown.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    See Also
    --------
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
    [`stitch_smooth`][triwarp.holes.stitch_smooth]
    [`subdivide_region_to_size`][triwarp.remesh.subdivide_region_to_size]

    Notes
    -----
    The three stages are: minimum-weight fill, refine the patch to the target edge length, then
    smooth its new interior vertices into the surrounding surface.

    Winding is consistent with the surrounding faces only for a consistently wound input.
    """
    require_same_device(vertices=vertices, faces=faces)
    _check_fill_metric(metric)
    if edge_weights not in ("cotan", "unit"):
        raise ValueError(f"edge_weights must be 'cotan' or 'unit', got {edge_weights!r}")
    _check_refine(refine)

    device = faces.device
    n_faces = int(faces.shape[0]) // 3
    packed = _hole_loops(vertices, faces, preserve_largest_hole) if n_faces > 0 else None
    if packed is None:
        empty = _patch_mask(int(faces.shape[0]) // 3, int(faces.shape[0]) // 3, device)
        result = (wp.clone(vertices), wp.clone(faces))
        return (*result, empty) if return_patch else result

    n_faces_before = n_faces
    n_vertices_before = int(vertices.shape[0])
    faces_filled = _fill_packed_loops(
        vertices, faces, packed, metric, resolve_multiple_edges, smooth_boundary
    )
    n_faces_after = int(faces_filled.shape[0]) // 3
    patch_mask = _patch_mask(n_faces_before, n_faces_after, device)

    if triangulate_only:
        result = (wp.clone(vertices), faces_filled)
        return (*result, patch_mask) if return_patch else result

    target_edge = max_edge if max_edge is not None else _mean_rim_edge_length(vertices, packed)
    new_vertices, new_faces, out_patch = tw.smoothing.refine_and_smooth_region(
        vertices,
        faces_filled,
        n_vertices_before,
        patch_mask,
        target_edge,
        max_edge_splits,
        max_angle_change_after_flip,
        smooth_curvature,
        smooth_boundary,
        natural_smooth,
        edge_weights,
        refine=refine,
    )
    return (new_vertices, new_faces, out_patch) if return_patch else (new_vertices, new_faces)

refill_region

refill_region(
    vertices: array[vec3],
    faces: array[int32],
    face_mask: array[bool],
    metric: str = "plane_normalized",
    *,
    triangulate_only: bool = False,
    max_edge: float | None = None,
    smooth_curvature: bool = True,
    refine: Literal["max_edge", "density"] = "max_edge",
    return_patch: bool = False,
) -> (
    tuple[wp.array[wp.vec3], wp.array[wp.int32]]
    | tuple[
        wp.array[wp.vec3],
        wp.array[wp.int32],
        wp.array[wp.bool],
    ]
)

Replace a face region with a fresh patch: delete it, then fill the hole nicely.

fill_smooth applied to a region rather than to the mesh's own holes -- the operation for rebuilding a piece of surface that is wrong rather than missing: a self-intersecting band, a noisy patch, a set of faces a user painted. The region is removed, the rims that opens are triangulated by the same minimum-weight DP, and the patch is refined and smoothed to match its surroundings.

Only the rims the deletion opened are filled. An input that already had a boundary keeps it, which is what makes this usable on an open mesh at all -- triwarp.selection.delete_region_keep_boundary is where that distinction is drawn.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions on the target device.

required
faces array[int32]

Length-3 * n_faces flat triangle index buffer.

required
face_mask array[bool]

Length-n_faces wp.bool array, True for each face to replace.

required
metric str

Minimum-weight fill metric, as in fill_min_weight.

'plane_normalized'
triangulate_only bool

Stop after the minimum-weight triangulation, skipping the refinement and smoothing. The patch is then n - 2 triangles per rim of n vertices and adds no new vertices.

False
max_edge float | None

Target edge length for the refinement. None uses the mean rim edge length, so the patch arrives at roughly the surrounding mesh's resolution.

None
smooth_curvature bool

Minimize curvature rather than area when smoothing the patch, as in fill_smooth.

True
refine Literal['max_edge', 'density']

Subdivision criterion for the refinement stage; see fill_smooth.

'max_edge'
return_patch bool

Also return the per-face mask of the new patch, for a caller that wants to keep working on it.

False

Returns:

Type Description
tuple[array[vec3], array[int32]] | tuple[..., array[bool]]

(vertices, faces) of the rebuilt mesh, plus the patch mask when return_patch is True. When the mask selects nothing, or selects faces whose removal opens no new rim, the surviving mesh is returned with an all-False patch.

Raises:

Type Description
ValueError

If metric or refine is not one of the supported names, or face_mask does not have one entry per face.

RuntimeError

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

Examples:

bad = tw.validation.face_self_intersecting_mask(v, f)
patched_v, patched_f = tw.holes.refill_region(v, f, bad)
See Also

fill_smooth The same pipeline over the mesh's existing holes, when nothing needs deleting first. triwarp.selection.delete_region_keep_boundary The first half, when the rims are wanted rather than filled. triwarp.repair.remove_folded_faces One of several ways to produce the mask this takes.

Source code in triwarp/holes.py
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
def refill_region(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    face_mask: wp.array[wp.bool],
    metric: str = "plane_normalized",
    *,
    triangulate_only: bool = False,
    max_edge: float | None = None,
    smooth_curvature: bool = True,
    refine: Literal["max_edge", "density"] = "max_edge",
    return_patch: bool = False,
) -> (
    tuple[wp.array[wp.vec3], wp.array[wp.int32]]
    | tuple[wp.array[wp.vec3], wp.array[wp.int32], wp.array[wp.bool]]
):
    """
    Replace a face region with a fresh patch: delete it, then fill the hole nicely.

    [`fill_smooth`][triwarp.holes.fill_smooth] applied to a region rather than to the mesh's own
    holes -- the operation for *rebuilding* a piece of surface that is wrong rather than missing: a
    self-intersecting band, a noisy patch, a set of faces a user painted. The region is removed, the
    rims that opens are triangulated by the same minimum-weight DP, and the patch is refined and
    smoothed to match its surroundings.

    Only the rims the deletion **opened** are filled. An input that already had a boundary keeps it,
    which is what makes this usable on an open mesh at all --
    [`triwarp.selection.delete_region_keep_boundary`][triwarp.selection.delete_region_keep_boundary]
    is where that distinction is drawn.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions on the target device.
    faces
        Length-``3 * n_faces`` flat triangle index buffer.
    face_mask
        Length-``n_faces`` ``wp.bool`` array, ``True`` for each face to **replace**.
    metric
        Minimum-weight fill metric, as in [`fill_min_weight`][triwarp.holes.fill_min_weight].
    triangulate_only
        Stop after the minimum-weight triangulation, skipping the refinement and smoothing. The
        patch is then ``n - 2`` triangles per rim of ``n`` vertices and adds no new vertices.
    max_edge
        Target edge length for the refinement. ``None`` uses the mean rim edge length, so the
        patch arrives at roughly the surrounding mesh's resolution.
    smooth_curvature
        Minimize curvature rather than area when smoothing the patch, as in
        [`fill_smooth`][triwarp.holes.fill_smooth].
    refine
        Subdivision criterion for the refinement stage; see
        [`fill_smooth`][triwarp.holes.fill_smooth].
    return_patch
        Also return the per-face mask of the new patch, for a caller that wants to keep working on
        it.

    Returns
    -------
    tuple[wp.array[wp.vec3], wp.array[wp.int32]] | tuple[..., wp.array[wp.bool]]
        ``(vertices, faces)`` of the rebuilt mesh, plus the patch mask when ``return_patch`` is
        ``True``. When the mask selects nothing, or selects faces whose removal opens no new rim,
        the surviving mesh is returned with an all-``False`` patch.

    Raises
    ------
    ValueError
        If ``metric`` or ``refine`` is not one of the supported names, or ``face_mask`` does not
        have one entry per face.
    RuntimeError
        If ``vertices``, ``faces`` and ``face_mask`` are not all on one device.

    Examples
    --------
    ```python
    bad = tw.validation.face_self_intersecting_mask(v, f)
    patched_v, patched_f = tw.holes.refill_region(v, f, bad)
    ```

    See Also
    --------
    [`fill_smooth`][triwarp.holes.fill_smooth]
        The same pipeline over the mesh's existing holes, when nothing needs deleting first.
    [`triwarp.selection.delete_region_keep_boundary`][triwarp.selection.delete_region_keep_boundary]
        The first half, when the rims are wanted rather than filled.
    [`triwarp.repair.remove_folded_faces`][triwarp.repair.remove_folded_faces]
        One of several ways to produce the mask this takes.
    """
    require_same_device(vertices=vertices, faces=faces, face_mask=face_mask)
    _check_fill_metric(metric)
    _check_refine(refine)

    kept_vertices, kept_faces, new_loops = tw.selection.delete_region_keep_boundary(
        vertices, faces, face_mask
    )
    device = faces.device
    n_kept_faces = int(kept_faces.shape[0]) // 3
    if not new_loops:
        empty = _patch_mask(n_kept_faces, n_kept_faces, device)
        result = (kept_vertices, kept_faces)
        return (*result, empty) if return_patch else result

    packed = _pack_loops(new_loops)
    faces_filled = _fill_packed_loops(kept_vertices, kept_faces, packed, metric, True, True)
    patch_mask = _patch_mask(n_kept_faces, int(faces_filled.shape[0]) // 3, device)
    if triangulate_only:
        result = (kept_vertices, faces_filled)
        return (*result, patch_mask) if return_patch else result

    target_edge = max_edge if max_edge is not None else _mean_rim_edge_length(kept_vertices, packed)
    new_vertices, new_faces, out_patch = tw.smoothing.refine_and_smooth_region(
        kept_vertices,
        faces_filled,
        int(kept_vertices.shape[0]),
        patch_mask,
        target_edge,
        1000,
        math.radians(30.0),
        smooth_curvature,
        True,
        False,
        "cotan",
        refine=refine,
    )
    return (new_vertices, new_faces, out_patch) if return_patch else (new_vertices, new_faces)

extend_hole

extend_hole(
    vertices: array[vec3],
    faces: array[int32],
    plane_normal: vec3,
    plane_origin: vec3,
    loops: Sequence[array[int32]] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Extend every boundary rim out to a plane, adding the ruled surface between them.

Each rim vertex is projected orthogonally onto the plane, and the rim is bridged to that ring of projections with two triangles per rim edge. The result is open again -- the new rim lies in the plane -- which is what makes this the step before a flat cap rather than a cap itself: the extended rim is planar, so fill_min_weight closes it with a triangulation that has no reason to fold.

The typical use is turning a scanned shell into a solid: extend its rims to a base plane, then fill. Doing that in one step with a min-weight fill instead gives a curved cap over the original rim, which is a different shape and usually not the wanted one.

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
plane_normal vec3

The plane's normal. Need not be unit length in principle, but is assumed to be -- the projection scales with it otherwise. Normalize it.

required
plane_origin vec3

A point on the target plane.

required
loops Sequence[array[int32]] | None

Rims to extend. None extends every one, via boundary_loops; pass a subset to extend only those.

None

Returns:

Name Type Description
vertices array[vec3]

The input positions, unchanged and in order, with one projected vertex appended per rim vertex.

faces array[int32]

The input faces with the bridge triangles appended -- two per rim edge.

Raises:

Type Description
TypeError

If any loop is not a rank-1 wp.int32 array.

RuntimeError

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

See Also

fill_min_weight Closes the planar rim this leaves. build_bottom The same extension with the plane fitted to each rim, instead of placed by the caller. stitch_loops Bridges two rims that both already exist, where this generates the second one. boundary_loops

Notes

The rim may cross the plane

Nothing here checks which side of the plane a rim vertex is on. A rim straddling it gets an extension that folds through the plane, which is geometrically what "project each vertex" means and is almost never wanted -- place the plane clear of the rim, and check with bounds.aabb if the input is not yours.

Source code in triwarp/holes.py
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
def extend_hole(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    plane_normal: wp.vec3,
    plane_origin: wp.vec3,
    loops: Sequence[wp.array[wp.int32]] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Extend every boundary rim out to a plane, adding the ruled surface between them.

    Each rim vertex is projected orthogonally onto the plane, and the rim is bridged to that ring of
    projections with two triangles per rim edge. The result is open again -- the new rim lies
    *in* the plane -- which is what makes this the step before a flat cap rather than a cap itself:
    the extended rim is planar, so
    [`fill_min_weight`][triwarp.holes.fill_min_weight] closes it with a triangulation that has no
    reason to fold.

    The typical use is turning a scanned shell into a solid: extend its rims to a base plane, then
    fill. Doing that in one step with a min-weight fill instead gives a curved cap over the original
    rim, which is a different shape and usually not the wanted one.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.
    plane_normal
        The plane's normal. Need not be unit length in principle, but **is assumed to be** -- the
        projection scales with it otherwise. Normalize it.
    plane_origin
        A point on the target plane.
    loops
        Rims to extend. ``None`` extends every one, via
        [`boundary_loops`][triwarp.boundary.boundary_loops]; pass a subset to extend only those.

    Returns
    -------
    vertices : wp.array[wp.vec3]
        The input positions, unchanged and in order, with one projected vertex appended per rim
        vertex.
    faces : wp.array[wp.int32]
        The input faces with the bridge triangles appended -- two per rim edge.

    Raises
    ------
    TypeError
        If any loop is not a rank-1 ``wp.int32`` array.
    RuntimeError
        If ``vertices``, ``faces`` and ``loops`` are not all on one device.

    See Also
    --------
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
        Closes the planar rim this leaves.
    [`build_bottom`][triwarp.holes.build_bottom]
        The same extension with the plane fitted to each rim, instead of placed by the caller.
    [`stitch_loops`][triwarp.holes.stitch_loops]
        Bridges two rims that both already exist, where this generates the second one.
    [`boundary_loops`][triwarp.boundary.boundary_loops]

    Notes
    -----
    !!! note "The rim may cross the plane"
        Nothing here checks which side of the plane a rim vertex is on. A rim straddling it gets an
        extension that folds through the plane, which is geometrically what "project each vertex"
        means and is almost never wanted -- place the plane clear of the rim, and check with
        [`bounds.aabb`][triwarp.bounds.aabb] if the input is not yours.
    """
    require_same_device(vertices=vertices, faces=faces, loops=loops)
    packed = _packed_rims(vertices, faces, loops)
    if packed is None:
        return wp.clone(vertices), wp.clone(faces)
    origins = wp.full(packed.n_loops, plane_origin, dtype=wp.vec3, device=faces.device)
    return _extend_packed_rims(vertices, faces, packed, plane_normal, origins)

build_bottom

build_bottom(
    vertices: array[vec3],
    faces: array[int32],
    direction: vec3,
    hole_extension: float = 0.0,
    loops: Sequence[array[int32]] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Extend every boundary rim down to a flat base placed under its own lowest vertex.

This is extend_hole with the plane chosen for you: for each rim separately, the plane with normal direction through that rim's most extreme vertex in the -direction sense, pushed a further hole_extension past it. So the base sits flush with the lowest point of the rim and nothing folds -- which is what makes this the safe form when the rim is not level and a hand-placed plane would cut through it.

Each rim gets its own plane. A mesh with two rims at different heights therefore gets two bases, not one shared one; pass a single-element loops to bottom just one of them.

The result is still open -- the base is a rim in the plane, not a cap. Follow with fill_min_weight to close it.

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
direction vec3

The "up" direction the base is placed against: the plane's normal, and the rim is extended towards -direction. Assumed unit length -- hole_extension is measured in its units and the projection scales with it otherwise.

required
hole_extension float

Extra distance past the extreme vertex, along -direction. 0.0 puts the base exactly through the lowest rim vertex, which leaves that vertex unmoved.

0.0
loops Sequence[array[int32]] | None

Rims to extend. None extends every one, via boundary_loops.

None

Returns:

Name Type Description
vertices array[vec3]

The input positions, unchanged and in order, with one projected vertex appended per rim vertex.

faces array[int32]

The input faces with the bridge triangles appended -- two per rim edge.

Raises:

Type Description
TypeError

If any loop is not a rank-1 wp.int32 array.

RuntimeError

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

See Also

extend_hole The general form, where the plane is yours to place. fill_min_weight Closes the flat rim this leaves.

Source code in triwarp/holes.py
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
def build_bottom(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    direction: wp.vec3,
    hole_extension: float = 0.0,
    loops: Sequence[wp.array[wp.int32]] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Extend every boundary rim down to a flat base placed under its own lowest vertex.

    This is [`extend_hole`][triwarp.holes.extend_hole] with the plane chosen for you: for each rim
    separately, the plane with normal ``direction`` through that rim's most extreme vertex in the
    ``-direction`` sense, pushed a further ``hole_extension`` past it. So the base sits flush with
    the lowest point of the rim and nothing folds -- which is what makes this the safe form when the
    rim is not level and a hand-placed plane would cut through it.

    Each rim gets its **own** plane. A mesh with two rims at different heights therefore gets two
    bases, not one shared one; pass a single-element ``loops`` to bottom just one of them.

    The result is still open -- the base is a rim in the plane, not a cap. Follow with
    [`fill_min_weight`][triwarp.holes.fill_min_weight] to close it.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.
    direction
        The "up" direction the base is placed against: the plane's normal, and the rim is extended
        towards ``-direction``. Assumed unit length -- ``hole_extension`` is measured in its units
        and the projection scales with it otherwise.
    hole_extension
        Extra distance past the extreme vertex, along ``-direction``. ``0.0`` puts the base exactly
        through the lowest rim vertex, which leaves that vertex unmoved.
    loops
        Rims to extend. ``None`` extends every one, via
        [`boundary_loops`][triwarp.boundary.boundary_loops].

    Returns
    -------
    vertices : wp.array[wp.vec3]
        The input positions, unchanged and in order, with one projected vertex appended per rim
        vertex.
    faces : wp.array[wp.int32]
        The input faces with the bridge triangles appended -- two per rim edge.

    Raises
    ------
    TypeError
        If any loop is not a rank-1 ``wp.int32`` array.
    RuntimeError
        If ``vertices``, ``faces`` and ``loops`` are not all on one device.

    See Also
    --------
    [`extend_hole`][triwarp.holes.extend_hole]
        The general form, where the plane is yours to place.
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
        Closes the flat rim this leaves.
    """
    require_same_device(vertices=vertices, faces=faces, loops=loops)
    device = faces.device
    packed = _packed_rims(vertices, faces, loops)
    if packed is None:
        return wp.clone(vertices), wp.clone(faces)

    extremes = wp.full(packed.n_loops, math.inf, dtype=wp.float32, device=device)
    wp.launch(
        kernel_holes.loop_extreme_projection,
        dim=packed.total,
        inputs=[vertices, packed.flat_loops, packed.loop_id, direction, extremes],
        device=device,
    )
    origins = wp.empty(packed.n_loops, dtype=wp.vec3, device=device)
    wp.map(
        kernel_holes.plane_origin_from_extreme,
        extremes,
        direction,
        wp.float32(hole_extension),
        out=origins,
    )
    return _extend_packed_rims(vertices, faces, packed, direction, origins)

fillable_loop_mask

fillable_loop_mask(
    vertices: array[vec3],
    faces: array[int32],
    loops: Sequence[array[int32]] | None = None,
) -> wp.array[wp.bool]

Which boundary loops a min-weight fill can close without producing an invalid mesh.

fill_min_weight triangulates a rim over its own vertices, which fails in two combinatorial ways that have nothing to do with the metric. This names them per loop, so the policy is the caller's rather than a flag's:

  • A repeated vertex. A rim that visits one vertex twice is pinched there, and any triangulation of it folds through that pinch. A vertex shared by two different rims is the same pinch spread across two loops, and disqualifies both.
  • A chord. Two loop vertices that are not neighbours along the rim but are already joined by a mesh edge. The fill may propose that pair as a fill edge, and the mesh already has one, so that edge ends up with three faces. This is what fill_min_weight(resolve_multiple_edges=True) repairs after the fact, and a True here is exactly the case where that repair has nothing to do.

Conservative on the chord, and not the reference predicate

The chord test is sufficient, not necessary: a chord-free loop cannot produce a duplicated edge whatever the dynamic program chooses, but a loop with a chord may still fill cleanly if the program happens to avoid it. A four-vertex rim with one diagonal already present is the smallest example -- there are two triangulations and only one of them collides. So read False as "check the result", not as "cannot be filled".

It is also not a port of the reference predicate that flags faces complicating a hole, which measures something else: on that same square it flags no face. And the reference's third predicate, whether a loop is the "outer" one, is deliberately absent -- which boundary of an open surface is outer is a property of an embedding, not of the mesh, so there is nothing intrinsic to compute. The practical form of that question, which rim is the big one, is already answered by fill_min_weight(preserve_largest_hole=True) and by loop_perimeters.

A known gap: a seam and a pinch at the same vertex

The repeated-vertex check above catches a pinch because boundary_loops's underlying walk represents it as a loop that visits one vertex twice. But when that same vertex also sits on a non-orientable seam, the walk has no 2-regular path to fall back on at all and instead silently drops one of the vertex's two outgoing boundary edges (see triwarp.boundary._needs_unoriented_boundary_walk's docstring) -- so the loop this function sees is simply shorter than the true rim, with no repeated vertex to flag. Such a loop can pass this mask as fillable while not actually closing along real mesh edges. No fixture in this package's test suite combines a seam and a pinch at one vertex, so this gap is documented rather than guarded against; closing it needs boundary_loops_batched itself to detect and report the dropped edge, which is a separate, larger change.

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
loops Sequence[array[int32]] | None

The boundary loops to test. When None they are computed with boundary_loops; pass them when you already have them, since the returned mask is indexed by their order and a caller almost always needs both.

None

Returns:

Type Description
array[bool]

One entry per loop, True where the loop is simple, shares no vertex with another loop, and is chord-free, on faces.device. True is a guarantee; False is a warning, per the note above.

Raises:

Type Description
TypeError

If any loop is not a rank-1 wp.int32 array.

RuntimeError

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

See Also

fill_min_weight The fill this predicts the safety of. boundary_loops Produces the loops, in the order this mask indexes. loop_perimeters Ranks the same loops by size, which is the other question a caller asks about a rim.

Source code in triwarp/holes.py
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
def fillable_loop_mask(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    loops: Sequence[wp.array[wp.int32]] | None = None,
) -> wp.array[wp.bool]:
    """
    Which boundary loops a min-weight fill can close without producing an invalid mesh.

    [`fill_min_weight`][triwarp.holes.fill_min_weight] triangulates a rim over its **own** vertices,
    which fails in two combinatorial ways that have nothing to do with the metric. This names them
    per loop, so the policy is the caller's rather than a flag's:

    * **A repeated vertex.** A rim that visits one vertex twice is pinched there, and any
      triangulation of it folds through that pinch. A vertex shared by two *different* rims is the
      same pinch spread across two loops, and disqualifies both.
    * **A chord.** Two loop vertices that are *not* neighbours along the rim but are already joined
      by a mesh edge. The fill may propose that pair as a fill edge, and the mesh already has one,
      so that edge ends up with three faces. This is what
      ``fill_min_weight(resolve_multiple_edges=True)`` repairs *after* the fact, and a ``True`` here
      is exactly the case where that repair has nothing to do.

    !!! note "Conservative on the chord, and not the reference predicate"
        The chord test is **sufficient, not necessary**: a chord-free loop cannot produce a
        duplicated edge whatever the dynamic program chooses, but a loop *with* a chord may still
        fill cleanly if the program happens to avoid it. A four-vertex rim with one diagonal already
        present is the smallest example -- there are two triangulations and only one of them
        collides. So read ``False`` as "check the result", not as "cannot be filled".

        It is also **not** a port of the reference predicate that flags faces *complicating* a
        hole, which measures something else: on that same square it flags **no** face. And the
        reference's third predicate, whether a loop is the "outer" one, is deliberately absent --
        which boundary of an open surface is outer is a property of an embedding, not of the mesh,
        so there is nothing intrinsic to compute. The practical form of that question, *which rim
        is the big one*, is already
        answered by ``fill_min_weight(preserve_largest_hole=True)`` and by
        [`loop_perimeters`][triwarp.boundary.loop_perimeters].

    !!! warning "A known gap: a seam *and* a pinch at the same vertex"
        The repeated-vertex check above catches a pinch because
        [`boundary_loops`][triwarp.boundary.boundary_loops]'s underlying walk represents it as a
        loop that visits one vertex twice. But when that same vertex also sits on a non-orientable
        seam, the walk has no 2-regular path to fall back on at all and instead silently *drops*
        one of the vertex's two outgoing boundary edges (see
        ``triwarp.boundary._needs_unoriented_boundary_walk``'s docstring) -- so the loop this
        function sees is simply **shorter** than the true rim, with
        no repeated vertex to flag. Such a loop can pass this mask as fillable while not actually
        closing along real mesh edges. No fixture in this package's test suite combines a seam and
        a pinch at one vertex, so this gap is documented rather than guarded against; closing it
        needs [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched] itself to detect
        and report the dropped edge, which is a separate, larger change.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.
    loops
        The boundary loops to test. When ``None`` they are computed with
        [`boundary_loops`][triwarp.boundary.boundary_loops]; pass them when you already have them,
        since the returned mask is indexed by their order and a caller almost always needs both.

    Returns
    -------
    wp.array[wp.bool]
        One entry per loop, ``True`` where the loop is simple, shares no vertex with another loop,
        and is chord-free, on ``faces.device``. ``True`` is a guarantee; ``False`` is a warning,
        per the note above.

    Raises
    ------
    TypeError
        If any loop is not a rank-1 ``wp.int32`` array.
    RuntimeError
        If ``vertices``, ``faces`` and ``loops`` are not all on one device.

    See Also
    --------
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
        The fill this predicts the safety of.
    [`boundary_loops`][triwarp.boundary.boundary_loops]
        Produces the loops, in the order this mask indexes.
    [`loop_perimeters`][triwarp.boundary.loop_perimeters]
        Ranks the same loops by size, which is the other question a caller asks about a rim.
    """
    require_same_device(vertices=vertices, faces=faces, loops=loops)
    device = faces.device
    if loops is None:
        loops = tw.boundary.boundary_loops(vertices, faces)
    loops = list(loops)
    for loop in loops:
        twt.ensure_ndim(loop, 1, dtype=wp.int32)
    if not loops:
        return wp.empty(0, dtype=wp.bool, device=device)

    # ``vertices`` is the domain, so its length is the bound ``index_bound`` would go to the
    # device to re-derive. An unreferenced vertex only widens the two tables below, which are
    # indexed by vertex id.
    n_vertices = int(vertices.shape[0])
    # **One** readback for all the loops, not one each: they are concatenated on the device first,
    # and the sizes are already on the host. A scan mesh carries dozens of rims, so a per-loop
    # readback would cost a sync apiece.
    #
    # A readback at all because the position table cannot be built on the device: a pinched loop
    # wants two entries for one vertex, and the pinch is what disqualifies it. Its size is bounded
    # by the *boundary* rather than by the mesh.
    sizes = [int(loop.shape[0]) for loop in loops]
    flat_np = tw.array.concatenate(list(loops), copy=False).numpy()
    bounds = np.cumsum([0, *sizes])
    loops_np = [flat_np[bounds[index] : bounds[index + 1]] for index in range(len(sizes))]
    fillable_np = np.ones(len(loops_np), dtype=bool)
    for index, loop_np in enumerate(loops_np):
        if np.unique(loop_np).shape[0] != loop_np.shape[0]:
            fillable_np[index] = False  # pinched within itself, and would corrupt the table below

    # A vertex on two different rims is a pinch *between* loops: filling either one leaves that
    # vertex non-manifold. It also cannot be represented in the two vertex-indexed tables below,
    # which hold one owner apiece -- so writing the loops in order would let the later loop
    # silently overwrite the earlier one's ownership and hide the earlier one's chords, returning
    # ``True`` for a loop that has one. ``True`` is a guarantee, so every loop touching a shared
    # vertex answers ``False`` and is kept out of the tables. Counting owners up front rather
    # than resolving collisions in the write loop keeps the answer independent of loop order.
    #
    # The count runs over **every** loop, including the self-pinched ones the pass above already
    # disqualified. Dropping those first would be counting the wrong thing: a self-pinch is a
    # non-manifold vertex whoever else touches it, so a clean loop sharing it is exactly as
    # unfillable as the pinched one, and excluding the pinched loop from the tally made that
    # clean loop answer ``True``. ``np.unique`` per loop is what keeps a loop's own repeat from
    # counting as a second owner -- the count is of *owning loops*, not of occurrences.
    owner_count = np.bincount(
        np.concatenate([np.unique(loop_np) for loop_np in loops_np]), minlength=n_vertices
    )
    for index, loop_np in enumerate(loops_np):
        if fillable_np[index] and (owner_count[loop_np] > 1).any():
            fillable_np[index] = False

    loop_of_vertex_np = np.full(n_vertices, -1, dtype=np.int32)
    position_np = np.zeros(n_vertices, dtype=np.int32)
    for index, loop_np in enumerate(loops_np):
        if fillable_np[index]:
            loop_of_vertex_np[loop_np] = index
            position_np[loop_np] = np.arange(loop_np.shape[0], dtype=np.int32)

    if fillable_np.any():
        unique_edges, _inverse = tw.edges.edges_unique(faces, n_vertices=n_vertices)
        has_chord = wp.zeros(len(loops_np), dtype=wp.bool, device=device)
        wp.launch(
            kernel_holes.mark_loops_with_chords,
            dim=int(unique_edges.shape[0]),
            inputs=[
                unique_edges,
                wp.array(loop_of_vertex_np, dtype=wp.int32, device=device),
                wp.array(position_np, dtype=wp.int32, device=device),
                wp.array(np.array(sizes, dtype=np.int32), dtype=wp.int32, device=device),
                has_chord,
            ],
            device=device,
        )
        fillable_np &= ~has_chord.numpy()
    return wp.array(fillable_np, dtype=wp.bool, device=device)

stitch

stitch(
    vertices_a: array[vec3],
    faces_a: array[int32],
    vertices_b: array[vec3],
    faces_b: array[int32],
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Stitch two single-boundary open meshes into one watertight mesh.

Extracts the single boundary loop of each mesh (boundary_loops) and joins them with stitch_loops. Each mesh must have exactly one boundary loop -- a restriction of this entry point, not of the seam construction; use stitch_loops directly to join specific loops of multi-boundary meshes. No smoothing is applied.

Parameters:

Name Type Description Default
vertices_a array[vec3]

(n_vertices,) vertex positions of each mesh.

required
vertices_b array[vec3]

(n_vertices,) vertex positions of each mesh.

required
faces_a array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
faces_b array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required

Returns:

Name Type Description
new_vertices array[vec3]

Concatenated vertices (larger-boundary mesh first), on faces_a.device.

new_faces array[int32]

Concatenated, reindexed faces followed by the bridge triangles, on faces_a.device.

Raises:

Type Description
ValueError

If either mesh does not have exactly one boundary loop of at least 3 vertices.

RuntimeError

If vertices_a, faces_a, vertices_b and faces_b are not all on one device.

See Also

stitch_loops stitch_min_weight boundary_loops

Source code in triwarp/holes.py
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
def stitch(
    vertices_a: wp.array[wp.vec3],
    faces_a: wp.array[wp.int32],
    vertices_b: wp.array[wp.vec3],
    faces_b: wp.array[wp.int32],
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Stitch two single-boundary open meshes into one watertight mesh.

    Extracts the single boundary loop of each mesh
    ([`boundary_loops`][triwarp.boundary.boundary_loops]) and joins them with
    [`stitch_loops`][triwarp.holes.stitch_loops]. Each mesh must have
    exactly one boundary loop -- a restriction of this entry point, not of the seam construction;
    use ``stitch_loops`` directly to join specific loops of multi-boundary meshes. **No
    smoothing** is applied.

    Parameters
    ----------
    vertices_a, vertices_b
        ``(n_vertices,)`` vertex positions of each mesh.
    faces_a, faces_b
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffers of each mesh.

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Concatenated vertices (larger-boundary mesh first), on ``faces_a.device``.
    new_faces : wp.array[wp.int32]
        Concatenated, reindexed faces followed by the bridge triangles, on ``faces_a.device``.

    Raises
    ------
    ValueError
        If either mesh does not have exactly one boundary loop of at least 3 vertices.
    RuntimeError
        If ``vertices_a``, ``faces_a``, ``vertices_b`` and ``faces_b`` are not all on one device.

    See Also
    --------
    [`stitch_loops`][triwarp.holes.stitch_loops]
    [`stitch_min_weight`][triwarp.holes.stitch_min_weight]
    [`boundary_loops`][triwarp.boundary.boundary_loops]
    """
    require_same_device(
        vertices_a=vertices_a, faces_a=faces_a, vertices_b=vertices_b, faces_b=faces_b
    )
    loop_a, loop_b = _single_boundary_loops(
        vertices_a, faces_a, vertices_b, faces_b, caller="stitch"
    )
    return stitch_loops(vertices_a, faces_a, loop_a, vertices_b, faces_b, loop_b)

stitch_min_weight

stitch_min_weight(
    vertices_a: array[vec3],
    faces_a: array[int32],
    vertices_b: array[vec3],
    faces_b: array[int32],
    metric: str = "complex_stitch",
    up_dir: tuple[float, float, float] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Stitch two single-boundary open meshes with a minimum-weight band.

Like stitch but joins the two rims with the metric-minimizing band of stitch_loops_min_weight instead of the greedy correspondence. Each mesh must have exactly one boundary loop.

Parameters:

Name Type Description Default
vertices_a array[vec3]

(n_vertices,) vertex positions of each mesh.

required
vertices_b array[vec3]

(n_vertices,) vertex positions of each mesh.

required
faces_a array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
faces_b array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
metric str

Stitch metric; see stitch_loops_min_weight.

'complex_stitch'
up_dir tuple[float, float, float] | None

Up direction for the "vertical" metric.

None

Returns:

Name Type Description
new_vertices array[vec3]

Concatenated vertices, on faces_a.device.

new_faces array[int32]

Concatenated, reindexed faces followed by the band triangles.

Raises:

Type Description
ValueError

If either mesh does not have exactly one boundary loop of at least 3 vertices, or metric is unknown.

RuntimeError

If vertices_a, faces_a, vertices_b and faces_b are not all on one device.

See Also

stitch_loops_min_weight stitch

Notes

The band is the minimum-weight two-loop stitch; see stitch_min_weight for the metrics.

Source code in triwarp/holes.py
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
def stitch_min_weight(
    vertices_a: wp.array[wp.vec3],
    faces_a: wp.array[wp.int32],
    vertices_b: wp.array[wp.vec3],
    faces_b: wp.array[wp.int32],
    metric: str = "complex_stitch",
    up_dir: tuple[float, float, float] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Stitch two single-boundary open meshes with a minimum-weight band.

    Like [`stitch`][triwarp.holes.stitch] but joins the two rims with the metric-minimizing band
    of [`stitch_loops_min_weight`][triwarp.holes.stitch_loops_min_weight]
    instead of the greedy correspondence. Each mesh must have exactly one boundary loop.

    Parameters
    ----------
    vertices_a, vertices_b
        ``(n_vertices,)`` vertex positions of each mesh.
    faces_a, faces_b
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffers of each mesh.
    metric
        Stitch metric; see
        [`stitch_loops_min_weight`][triwarp.holes.stitch_loops_min_weight].
    up_dir
        Up direction for the ``"vertical"`` metric.

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Concatenated vertices, on ``faces_a.device``.
    new_faces : wp.array[wp.int32]
        Concatenated, reindexed faces followed by the band triangles.

    Raises
    ------
    ValueError
        If either mesh does not have exactly one boundary loop of at least 3 vertices, or ``metric``
        is unknown.
    RuntimeError
        If ``vertices_a``, ``faces_a``, ``vertices_b`` and ``faces_b`` are not all on one device.

    See Also
    --------
    [`stitch_loops_min_weight`][triwarp.holes.stitch_loops_min_weight]
    [`stitch`][triwarp.holes.stitch]

    Notes
    -----
    The band is the minimum-weight two-loop stitch; see
    [`stitch_min_weight`][triwarp.holes.stitch_min_weight] for the metrics.
    """
    require_same_device(
        vertices_a=vertices_a, faces_a=faces_a, vertices_b=vertices_b, faces_b=faces_b
    )
    loop_a, loop_b = _single_boundary_loops(
        vertices_a, faces_a, vertices_b, faces_b, caller="stitch_min_weight"
    )
    return stitch_loops_min_weight(
        vertices_a, faces_a, loop_a, vertices_b, faces_b, loop_b, metric, up_dir
    )

stitch_smooth

stitch_smooth(
    vertices_a: array[vec3],
    faces_a: array[int32],
    vertices_b: array[vec3],
    faces_b: array[int32],
    metric: str = "complex_stitch",
    up_dir: tuple[float, float, float] | None = None,
    *,
    triangulate_only: bool = False,
    max_edge: float | None = None,
    max_edge_splits: int = 1000,
    max_angle_change_after_flip: float = math.radians(30.0),
    smooth_curvature: bool = True,
    natural_smooth: bool = False,
    edge_weights: str = "cotan",
    refine: Literal["max_edge", "density"] = "max_edge",
    return_patch: bool = False,
) -> (
    tuple[wp.array[wp.vec3], wp.array[wp.int32]]
    | tuple[
        wp.array[wp.vec3],
        wp.array[wp.int32],
        wp.array[wp.bool],
    ]
)

Stitch two open meshes with a smooth, refined band.

Like stitch_min_weight but the connecting band is then subdivided and smoothed by the same finisher as fill_smooth. Each mesh must have exactly one boundary loop. The cross-boundary smooth solve is always applied.

Parameters:

Name Type Description Default
vertices_a array[vec3]

(n_vertices,) vertex positions of each mesh.

required
vertices_b array[vec3]

(n_vertices,) vertex positions of each mesh.

required
faces_a array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
faces_b array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
metric str

Stitch metric; see stitch_loops_min_weight.

'complex_stitch'
up_dir tuple[float, float, float] | None

Up direction for the "vertical" stitch metric.

None
triangulate_only bool False
max_edge float | None None
max_edge_splits int 1000
max_angle_change_after_flip float radians(30.0)
smooth_curvature bool True
natural_smooth bool False
edge_weights str 'cotan'
refine Literal['max_edge', 'density'] 'max_edge'
return_patch bool False

Returns:

Name Type Description
new_vertices array[vec3]

Concatenated vertices followed by any inserted band vertices, on faces_a.device.

new_faces array[int32]

Concatenated, reindexed faces followed by the band faces.

patch_mask array[bool]

Only when return_patch is True: mask of the band faces.

Raises:

Type Description
ValueError

If either mesh lacks exactly one boundary loop, or metric / edge_weights / refine is unknown.

RuntimeError

If vertices_a, faces_a, vertices_b and faces_b are not all on one device.

See Also

stitch_min_weight fill_smooth

Notes

The three stages are: minimum-weight stitch, refine the band to the target edge length, then smooth its new interior vertices into both surrounding surfaces.

Source code in triwarp/holes.py
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
def stitch_smooth(
    vertices_a: wp.array[wp.vec3],
    faces_a: wp.array[wp.int32],
    vertices_b: wp.array[wp.vec3],
    faces_b: wp.array[wp.int32],
    metric: str = "complex_stitch",
    up_dir: tuple[float, float, float] | None = None,
    *,
    triangulate_only: bool = False,
    max_edge: float | None = None,
    max_edge_splits: int = 1000,
    max_angle_change_after_flip: float = math.radians(30.0),
    smooth_curvature: bool = True,
    natural_smooth: bool = False,
    edge_weights: str = "cotan",
    refine: Literal["max_edge", "density"] = "max_edge",
    return_patch: bool = False,
) -> (
    tuple[wp.array[wp.vec3], wp.array[wp.int32]]
    | tuple[wp.array[wp.vec3], wp.array[wp.int32], wp.array[wp.bool]]
):
    """
    Stitch two open meshes with a smooth, refined band.

    Like [`stitch_min_weight`][triwarp.holes.stitch_min_weight] but the connecting band is then
    subdivided and smoothed by the same finisher as
    [`fill_smooth`][triwarp.holes.fill_smooth]. Each mesh must have exactly one
    boundary loop. The cross-boundary smooth solve is always applied.

    Parameters
    ----------
    vertices_a, vertices_b
        ``(n_vertices,)`` vertex positions of each mesh.
    faces_a, faces_b
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffers of each mesh.
    metric
        Stitch metric; see
        [`stitch_loops_min_weight`][triwarp.holes.stitch_loops_min_weight].
    up_dir
        Up direction for the ``"vertical"`` stitch metric.
    triangulate_only
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    max_edge
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    max_edge_splits
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    max_angle_change_after_flip
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    smooth_curvature
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    natural_smooth
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    edge_weights
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    refine
        See [`fill_smooth`][triwarp.holes.fill_smooth].
    return_patch
        See [`fill_smooth`][triwarp.holes.fill_smooth].

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Concatenated vertices followed by any inserted band vertices, on ``faces_a.device``.
    new_faces : wp.array[wp.int32]
        Concatenated, reindexed faces followed by the band faces.
    patch_mask : wp.array[wp.bool]
        Only when ``return_patch`` is ``True``: mask of the band faces.

    Raises
    ------
    ValueError
        If either mesh lacks exactly one boundary loop, or ``metric`` / ``edge_weights`` /
        ``refine`` is unknown.
    RuntimeError
        If ``vertices_a``, ``faces_a``, ``vertices_b`` and ``faces_b`` are not all on one device.

    See Also
    --------
    [`stitch_min_weight`][triwarp.holes.stitch_min_weight]
    [`fill_smooth`][triwarp.holes.fill_smooth]

    Notes
    -----
    The three stages are: minimum-weight stitch, refine the band to the target edge length, then
    smooth its new interior vertices into both surrounding surfaces.
    """
    require_same_device(
        vertices_a=vertices_a, faces_a=faces_a, vertices_b=vertices_b, faces_b=faces_b
    )
    _check_stitch_metric(metric)
    if edge_weights not in ("cotan", "unit"):
        raise ValueError(f"edge_weights must be 'cotan' or 'unit', got {edge_weights!r}")
    _check_refine(refine)

    loop_a, loop_b = _single_boundary_loops(
        vertices_a, faces_a, vertices_b, faces_b, caller="stitch_smooth"
    )

    device = faces_a.device
    n_faces_before = (int(faces_a.shape[0]) + int(faces_b.shape[0])) // 3
    n_vertices_before = int(vertices_a.shape[0]) + int(vertices_b.shape[0])
    combined_vertices, combined_faces = stitch_loops_min_weight(
        vertices_a, faces_a, loop_a, vertices_b, faces_b, loop_b, metric, up_dir
    )
    n_faces_after = int(combined_faces.shape[0]) // 3
    patch_mask = _patch_mask(n_faces_before, n_faces_after, device)

    if triangulate_only:
        result = (combined_vertices, combined_faces)
        return (*result, patch_mask) if return_patch else result

    # Independent copies, not views: ``boundary_loops`` returns slices of one shared packed buffer
    # and ``_mean_rim_edge_length`` must not alias it. ``wp.clone`` says exactly that; the round
    # trip through ``.numpy()`` these used to take said nothing and crossed the bus twice. The
    # second loop's shift into the combined numbering is elementwise, so it maps on the device.
    shifted_loop_b = wp.empty(int(loop_b.shape[0]), dtype=wp.int32, device=device)
    wp.map(wp.add, loop_b, wp.int32(int(vertices_a.shape[0])), out=shifted_loop_b)
    rim_loops = [wp.clone(loop_a), shifted_loop_b]
    target_edge = (
        max_edge if max_edge is not None else _mean_rim_edge_length(combined_vertices, rim_loops)
    )
    new_vertices, new_faces, out_patch = tw.smoothing.refine_and_smooth_region(
        combined_vertices,
        combined_faces,
        n_vertices_before,
        patch_mask,
        target_edge,
        max_edge_splits,
        max_angle_change_after_flip,
        smooth_curvature,
        True,  # the reference pipeline forces the cross-boundary smooth here
        natural_smooth,
        edge_weights,
        refine=refine,
    )
    return (new_vertices, new_faces, out_patch) if return_patch else (new_vertices, new_faces)

stitch_loops

stitch_loops(
    vertices_a: array[vec3],
    faces_a: array[int32],
    loop_a: array[int32],
    vertices_b: array[vec3],
    faces_b: array[int32],
    loop_b: array[int32],
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Join two meshes by triangulating the band between one boundary loop on each.

The two rims are zippered into a watertight seam by a greedy, order-preserving correspondence -- the gap-bridging problem Barequet and Sharir (1995) pose, solved here by the standard minimal-perimeter heuristic: every A-edge is matched to the B vertex minimizing the added-triangle perimeter, the association is rotated so its shortest pair comes first, and a longest-increasing-subsequence correction forces the matching to be monotone (non-self-intersecting). No smoothing or refinement is applied — the seam reuses only the two loops' existing vertices, adding len(loop_a) + len(loop_b) bridge triangles.

The larger loop is treated as A (the meshes are swapped internally when len(loop_a) < len(loop_b)), so the output is invariant to argument order except when the two loops are the same length: the swap is on a strict inequality, so equal-length rims keep the caller's order, and A and B are not interchangeable — A's edges are matched to B's vertices, not the reverse — so two equal-length rims can produce a different seam either way round. Both loops must wind following the face orientation, as returned by boundary_loops; the two boundaries are assumed to wind in opposite directions (as two open meshes facing each other do), so loop A is reversed to align them.

Parameters:

Name Type Description Default
vertices_a array[vec3]

(n_vertices,) vertex positions of each mesh.

required
vertices_b array[vec3]

(n_vertices,) vertex positions of each mesh.

required
faces_a array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
faces_b array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
loop_a array[int32]

Ordered vertex-index loops (>= 3 vertices each) around the boundary to join on each mesh, indexing vertices_a / vertices_b respectively.

required
loop_b array[int32]

Ordered vertex-index loops (>= 3 vertices each) around the boundary to join on each mesh, indexing vertices_a / vertices_b respectively.

required

Returns:

Name Type Description
new_vertices array[vec3]

Concatenation of vertices_a then vertices_b (larger loop first), on the device of the larger loop's mesh — which is the caller's faces_a unless the swap above fired.

new_faces array[int32]

Original faces (B reindexed by len(vertices_a)) followed by the bridge triangles, on that same device.

Raises:

Type Description
ValueError

If either loop has fewer than 3 vertices.

RuntimeError

If vertices_a, faces_a, loop_a, vertices_b, faces_b and loop_b are not all on one device.

See Also

stitch_loops_min_weight The more robust choice, and the one to reach for when seam quality matters: it minimizes a triangulation metric over the whole rim pair instead of correcting a greedy correspondence. stitch boundary_loops concatenate

Notes

The correspondence and its monotonicity correction are inherently sequential and run on the host over the length-len(loop_a) association array; the O(N·M) perimeter matrix, the reductions, and the triangle emission run in Warp kernels, and the perimeter matrix itself is never copied off the device.

This is the fast one, and that is the reason to keep it. It is metric-free and makes one O(N·M) pass, where stitch_loops_min_weight fills the same size table with N + M sequential launches along the anti-diagonals, and that gap widens with rim size. The DP is never the cheaper option -- prefer it for the seam it produces, not for speed.

Source code in triwarp/holes.py
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
def stitch_loops(
    vertices_a: wp.array[wp.vec3],
    faces_a: wp.array[wp.int32],
    loop_a: wp.array[wp.int32],
    vertices_b: wp.array[wp.vec3],
    faces_b: wp.array[wp.int32],
    loop_b: wp.array[wp.int32],
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Join two meshes by triangulating the band between one boundary loop on each.

    The two rims are zippered into a watertight seam by a greedy, order-preserving correspondence
    -- the gap-bridging problem Barequet and Sharir (1995) pose, solved here by the standard
    minimal-perimeter heuristic: every A-edge is matched to the B vertex minimizing the
    added-triangle perimeter, the association is rotated so its shortest pair comes first, and a
    longest-increasing-subsequence correction forces the matching to be monotone
    (non-self-intersecting). **No smoothing or refinement** is applied — the seam reuses
    only the two loops' existing vertices, adding ``len(loop_a) + len(loop_b)`` bridge triangles.

    The larger loop is treated as A (the meshes are swapped internally when
    ``len(loop_a) < len(loop_b)``), so the output is invariant to argument order **except when the
    two loops are the same length**: the swap is on a strict inequality, so equal-length rims keep
    the caller's order, and A and B are not interchangeable — A's edges are matched to B's
    vertices, not the reverse — so two equal-length rims can produce a different seam either way
    round. Both loops must
    wind following the face orientation, as returned by
    [`boundary_loops`][triwarp.boundary.boundary_loops]; the two boundaries are assumed to wind in
    opposite directions (as two open meshes facing each other do), so loop A is reversed to align
    them.

    Parameters
    ----------
    vertices_a, vertices_b
        ``(n_vertices,)`` vertex positions of each mesh.
    faces_a, faces_b
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffers of each mesh.
    loop_a, loop_b
        Ordered vertex-index loops (``>= 3`` vertices each) around the boundary to join on each
        mesh, indexing ``vertices_a`` / ``vertices_b`` respectively.

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Concatenation of ``vertices_a`` then ``vertices_b`` (larger loop first), on the device of
        the **larger** loop's mesh — which is the caller's ``faces_a`` unless the swap above fired.
    new_faces : wp.array[wp.int32]
        Original faces (B reindexed by ``len(vertices_a)``) followed by the bridge triangles, on
        that same device.

    Raises
    ------
    ValueError
        If either loop has fewer than 3 vertices.
    RuntimeError
        If ``vertices_a``, ``faces_a``, ``loop_a``, ``vertices_b``, ``faces_b`` and ``loop_b`` are
        not all on one device.

    See Also
    --------
    [`stitch_loops_min_weight`][triwarp.holes.stitch_loops_min_weight]
        The more robust choice, and the one to reach for when seam quality matters: it minimizes a
        triangulation metric over the whole rim pair instead of correcting a greedy correspondence.
    [`stitch`][triwarp.holes.stitch]
    [`boundary_loops`][triwarp.boundary.boundary_loops]
    [`concatenate`][triwarp.combine.concatenate]

    Notes
    -----
    The correspondence and its monotonicity correction are inherently sequential and run on the
    host over the length-``len(loop_a)`` association array; the O(N·M) perimeter matrix, the
    reductions, and the triangle emission run in Warp kernels, and the perimeter matrix itself is
    never copied off the device.

    **This is the fast one, and that is the reason to keep it.** It is metric-free and makes one
    O(N·M) pass, where
    [`stitch_loops_min_weight`][triwarp.holes.stitch_loops_min_weight] fills the same size table
    with ``N + M`` sequential launches along the anti-diagonals, and that gap widens with rim size.
    The DP is never the cheaper option -- prefer it for the seam it produces, not for speed.
    """
    require_same_device(
        vertices_a=vertices_a,
        faces_a=faces_a,
        loop_a=loop_a,
        vertices_b=vertices_b,
        faces_b=faces_b,
        loop_b=loop_b,
    )
    n = int(loop_a.shape[0])
    m = int(loop_b.shape[0])
    if n < m:
        vertices_a, vertices_b = vertices_b, vertices_a
        faces_a, faces_b = faces_b, faces_a
        loop_a, loop_b = loop_b, loop_a
        n, m = m, n
    # Read *after* the swap: every allocation and launch below is on the A mesh's device, and the
    # swap is what decides which of the caller's two meshes that is.
    device = faces_a.device
    if m < 3:
        raise ValueError(f"each boundary loop must have at least 3 vertices, got {n} and {m}")
    n_vertices_a = int(vertices_a.shape[0])

    # Reverse loop A so both rims wind the same way, then take rim positions.
    flipped_loop_a = wp.empty(n, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.cyclic_gather,
        dim=n,
        inputs=[loop_a, wp.int32(n), wp.int32(0), wp.bool(True), wp.int32(0), flipped_loop_a],
        device=device,
    )
    a_pos = tw.array.gather(vertices_a, flipped_loop_a)
    b_pos = tw.array.gather(vertices_b, loop_b)

    # perimeters[i, j] = |a_i - b_j| + |a_{i+1} - b_j| for A-edge i and B-vertex j.
    perimeters = twt.empty_2d((n, m), wp.float32, device=device)
    wp.launch(
        kernel_holes.boundary_perimeters,
        dim=(n, m),
        inputs=[a_pos, b_pos, wp.int32(n), perimeters],
        device=device,
    )

    col_min = wp.empty(n, dtype=wp.int32, device=device)
    val_min = wp.empty(n, dtype=wp.float32, device=device)
    wp.launch(
        kernel_holes.row_argmin,
        dim=n,
        inputs=[perimeters, wp.int32(m), col_min, val_min],
        device=device,
    )

    shift = wp.empty(2, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.global_argmin,
        dim=1,
        inputs=[col_min, val_min, wp.int32(n), shift],
        device=device,
    )
    shift_np = shift.numpy()
    shift_a = int(shift_np[0])
    shift_b = int(shift_np[1])

    edge_dev = wp.empty(n, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.rolled_edge_map,
        dim=n,
        inputs=[col_min, wp.int32(shift_a), wp.int32(shift_b), wp.int32(n), wp.int32(m), edge_dev],
        device=device,
    )
    edge = edge_dev.numpy()

    row_roll = shift_a
    col_roll = shift_b

    # Wraparound-group fix: if the lowest-index group is split across the ends of ``edge``, roll
    # A so the group is contiguous. This has to run *before* the monotonicity pass below, which
    # reads ``edge`` as a linear sequence and would otherwise see one group as two.
    if edge[-1] == edge[0]:
        trailing = int(np.argmin(np.flip(edge) == edge[0]))
        if trailing > 0:
            edge = np.roll(edge, trailing)
            row_roll = (shift_a - trailing) % n

    # Monotonicity correction: re-pick the B vertex of every edge outside the longest
    # non-decreasing subsequence within the bracket of its stable neighbours, so the matching is
    # order-preserving. The sentinel value ``m`` closes the last bracket.
    edge_ext = np.append(edge, np.int32(m))
    out_edge = wp.array(edge_ext.astype(np.int32), dtype=wp.int32, device=device)
    if not np.all(np.diff(edge) >= 0):
        unsorted_indices = _non_increasing_indices(edge_ext)
        stable_indices = np.delete(np.arange(edge_ext.size), unsorted_indices)
        next_indices = stable_indices[np.searchsorted(stable_indices, unsorted_indices)]
        wp.launch(
            kernel_holes.resolve_corrections,
            dim=1,
            inputs=[
                perimeters,
                wp.array(unsorted_indices.astype(np.int32), dtype=wp.int32, device=device),
                wp.array(next_indices.astype(np.int32), dtype=wp.int32, device=device),
                wp.int32(int(unsorted_indices.size)),
                wp.int32(row_roll),
                wp.int32(col_roll),
                wp.int32(n),
                wp.int32(m),
                out_edge,
            ],
            device=device,
        )

    # Rolled loops referencing the concatenated vertex buffer (A first, then B offset).
    roll_a = wp.empty(n, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.cyclic_gather,
        dim=n,
        inputs=[
            flipped_loop_a,
            wp.int32(n),
            wp.int32(row_roll),
            wp.bool(False),
            wp.int32(0),
            roll_a,
        ],
        device=device,
    )
    roll_b = wp.empty(m, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.cyclic_gather,
        dim=m,
        inputs=[
            loop_b,
            wp.int32(m),
            wp.int32(col_roll),
            wp.bool(False),
            wp.int32(n_vertices_a),
            roll_b,
        ],
        device=device,
    )

    bridge_a = wp.empty(3 * n, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.bridge_a_faces,
        dim=n,
        inputs=[roll_a, roll_b, out_edge, wp.int32(n), bridge_a],
        device=device,
    )
    bridge_b = wp.empty(3 * m, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.bridge_b_faces,
        dim=m,
        inputs=[roll_a, roll_b, out_edge, wp.int32(n), wp.int32(m), bridge_b],
        device=device,
    )

    combined_vertices, combined_faces = tw.combine.concatenate(
        [(vertices_a, faces_a), (vertices_b, faces_b)]
    )
    return combined_vertices, tw.array.concatenate([combined_faces, bridge_a, bridge_b])

stitch_loops_min_weight

stitch_loops_min_weight(
    vertices_a: array[vec3],
    faces_a: array[int32],
    loop_a: array[int32],
    vertices_b: array[vec3],
    faces_b: array[int32],
    loop_b: array[int32],
    metric: str = "complex_stitch",
    up_dir: tuple[float, float, float] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Join two meshes with a minimum-weight cylindrical band between one boundary loop on each.

The two rims are aligned at their closest vertex pair and zippered by the band of len(loop_a) + len(loop_b) triangles that minimizes a stitch metric, found by a grid dynamic program over the two loops (dp[i, j] = best band consuming i A-edges and j B-edges; each anti-diagonal is one parallel kernel launch). Reuses only the loops' existing vertices. The metric-free greedy stitch_loops remains available.

Parameters:

Name Type Description Default
vertices_a array[vec3]

(n_vertices,) vertex positions of each mesh.

required
vertices_b array[vec3]

(n_vertices,) vertex positions of each mesh.

required
faces_a array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
faces_b array[int32]

Length-3 * n_faces wp.int32 flat triangle index buffers of each mesh.

required
loop_a array[int32]

Ordered vertex-index loops (>= 3 vertices each) around the boundary to join on each.

required
loop_b array[int32]

Ordered vertex-index loops (>= 3 vertices each) around the boundary to join on each.

required
metric str

Stitch metric to minimize:

  • "complex_stitch" (default) — triangle aspect ratio plus a dihedral-smoothness edge term between adjacent band triangles and the surface.
  • "edge_length_stitch" — summed connection-edge length.
  • "vertical" — penalizes band area and normal deviation from up_dir; pass up_dir.
'complex_stitch'
up_dir tuple[float, float, float] | None

Up direction for the "vertical" metric (defaults to (0, 0, 1)); ignored otherwise.

None

Returns:

Name Type Description
new_vertices array[vec3]

Concatenation of vertices_a then vertices_b, on faces_a.device.

new_faces array[int32]

Original faces (B reindexed by len(vertices_a)) followed by the band triangles.

Raises:

Type Description
ValueError

If either loop has fewer than 3 vertices, or metric is unknown.

RuntimeError

If vertices_a, faces_a, loop_a, vertices_b, faces_b and loop_b are not all on one device.

See Also

stitch_loops stitch_min_weight

Source code in triwarp/holes.py
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
def stitch_loops_min_weight(
    vertices_a: wp.array[wp.vec3],
    faces_a: wp.array[wp.int32],
    loop_a: wp.array[wp.int32],
    vertices_b: wp.array[wp.vec3],
    faces_b: wp.array[wp.int32],
    loop_b: wp.array[wp.int32],
    metric: str = "complex_stitch",
    up_dir: tuple[float, float, float] | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Join two meshes with a **minimum-weight** cylindrical band between one boundary loop on each.

    The two rims are aligned at their closest vertex pair and zippered by the band of
    ``len(loop_a) + len(loop_b)`` triangles that minimizes a stitch metric, found by a grid dynamic
    program over the two loops (``dp[i, j]`` = best band consuming ``i`` A-edges and ``j`` B-edges;
    each anti-diagonal is one parallel kernel launch). Reuses only the
    loops' existing vertices. The metric-free greedy
    [`stitch_loops`][triwarp.holes.stitch_loops] remains available.

    Parameters
    ----------
    vertices_a, vertices_b
        ``(n_vertices,)`` vertex positions of each mesh.
    faces_a, faces_b
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffers of each mesh.
    loop_a, loop_b
        Ordered vertex-index loops (``>= 3`` vertices each) around the boundary to join on each.
    metric
        Stitch metric to minimize:

        - ``"complex_stitch"`` (default) — triangle aspect ratio plus a dihedral-smoothness edge
          term between adjacent band triangles and the surface.
        - ``"edge_length_stitch"`` — summed connection-edge length.
        - ``"vertical"`` — penalizes band area and normal deviation from ``up_dir``; pass
          ``up_dir``.
    up_dir
        Up direction for the ``"vertical"`` metric (defaults to ``(0, 0, 1)``); ignored otherwise.

    Returns
    -------
    new_vertices : wp.array[wp.vec3]
        Concatenation of ``vertices_a`` then ``vertices_b``, on ``faces_a.device``.
    new_faces : wp.array[wp.int32]
        Original faces (B reindexed by ``len(vertices_a)``) followed by the band triangles.

    Raises
    ------
    ValueError
        If either loop has fewer than 3 vertices, or ``metric`` is unknown.
    RuntimeError
        If ``vertices_a``, ``faces_a``, ``loop_a``, ``vertices_b``, ``faces_b`` and ``loop_b`` are
        not all on one device.

    See Also
    --------
    [`stitch_loops`][triwarp.holes.stitch_loops]
    [`stitch_min_weight`][triwarp.holes.stitch_min_weight]
    """
    require_same_device(
        vertices_a=vertices_a,
        faces_a=faces_a,
        loop_a=loop_a,
        vertices_b=vertices_b,
        faces_b=faces_b,
        loop_b=loop_b,
    )
    _check_stitch_metric(metric)
    n_a = int(loop_a.shape[0])
    n_b = int(loop_b.shape[0])
    if n_a < 3 or n_b < 3:
        raise ValueError(f"each boundary loop must have at least 3 vertices, got {n_a} and {n_b}")

    device = faces_a.device
    metric_id = _STITCH_METRIC_IDS[metric]
    offset = int(vertices_a.shape[0])

    # Reverse loop A so the two rims wind oppositely (facing), then align both at the closest pair.
    # ``la`` / ``lb`` stay on the host for the sequential band traceback, but the closest-pair
    # search, rim gathers and rim-opposite lookups all run on device. This preamble (two readbacks,
    # the closest-pair gather, the host roll and two more uploads) is a fixed cost that the
    # anti-diagonal DP below dominates and outgrows as the rims lengthen.
    la = loop_a.numpy()[::-1].copy()
    lb = loop_b.numpy().copy()
    a_rim = tw.array.gather(vertices_a, wp.array(la, dtype=wp.int32, device=device))
    b_rim = tw.array.gather(vertices_b, wp.array(lb, dtype=wp.int32, device=device))
    start_a, start_b = _closest_loop_pair(a_rim, b_rim)
    la = np.roll(la, -start_a)
    lb = np.roll(lb, -start_b)

    la_wp = wp.array(la, dtype=wp.int32, device=device)
    lb_wp = wp.array(lb, dtype=wp.int32, device=device)
    a_pos = tw.array.gather(vertices_a, la_wp)
    b_pos = tw.array.gather(vertices_b, lb_wp)
    table_a = _EdgeTable(vertices_a, faces_a, tw.edges.faces_to_edges(faces_a, sorted=True))
    table_b = _EdgeTable(vertices_b, faces_b, tw.edges.faces_to_edges(faces_b, sorted=True))
    # The stitch DP works on exactly one rim per side, so each rim is its own one-loop batch.
    a_opp, a_opp_valid = table_a.rim_opposite(_PackedLoops(la_wp, np.array([n_a], dtype=np.int64)))
    b_opp, b_opp_valid = table_b.rim_opposite(_PackedLoops(lb_wp, np.array([n_b], dtype=np.int64)))
    up = wp.vec3(*(up_dir if up_dir is not None else (0.0, 0.0, 1.0)))

    dp = twt.as_array2d(
        wp.full((n_a + 1, n_b + 1), _BAD_TRIANGULATION_METRIC, dtype=wp.float32, device=device),
        wp.float32,
    )
    wp.launch(kernel_holes.set_dp_origin, dim=1, inputs=[dp], device=device)
    came = twt.as_array2d(wp.full((n_a + 1, n_b + 1), -1, dtype=wp.int32, device=device), wp.int32)
    # Built once, outside the loop: the ten values below are the same on every one of the
    # ``n_a + n_b`` launches, and a wp.launch argument costs host time linearly on both devices, so
    # bundling them once here rather than passing all ten on every launch is a real saving.
    tables = kernel_holes.StitchTables()
    tables.a_pos = a_pos
    tables.b_pos = b_pos
    tables.a_opp = a_opp
    tables.a_opp_valid = a_opp_valid
    tables.b_opp = b_opp
    tables.b_opp_valid = b_opp_valid
    tables.up = up
    tables.metric_id = wp.int32(metric_id)
    tables.n_a = wp.int32(n_a)
    tables.n_b = wp.int32(n_b)
    for diag in range(1, n_a + n_b + 1):
        wp.launch(
            kernel_holes.stitch_dp_diag,
            dim=min(diag, n_a) - max(0, diag - n_b) + 1,
            inputs=[tables, wp.int32(diag), dp, came],
            device=device,
        )
    came_np = came.numpy()

    band = _stitch_band_triangles(came_np, la, lb, n_a, n_b, offset)
    combined_vertices, combined_faces = tw.combine.concatenate(
        [(vertices_a, faces_a), (vertices_b, faces_b)]
    )
    band_faces = wp.array(band.reshape(-1), dtype=wp.int32, device=device)
    return combined_vertices, tw.array.concatenate([combined_faces, band_faces])

bridge_edges

bridge_edges(
    vertices: array[vec3],
    faces: array[int32],
    edge_a: tuple[int, int],
    edge_b: tuple[int, int],
    validate: bool = True,
    boundary_edges: Array2dInt32 | None = None,
) -> wp.array[wp.int32]

Join two boundary edges with a two-triangle patch, leaving the rest of both rims open.

Where stitch_loops consumes two complete rims, this is the local operation underneath it: pick one edge on each side and close only that gap. Two open rims become one, so a bridge is how a tube is joined to another tube at a chosen seam, how a partially torn boundary is tacked back together, and -- when both edges lie on the same rim -- how a handle is added, since bridging one loop to itself splits it into two.

The patch is the quadrilateral (a1, a0, b1, b0) split along the a0 - b0 diagonal, so it adds two triangles and no vertices. When the two edges already share a vertex -- they are consecutive along one rim -- that quadrilateral is a triangle and only one is added.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions. Only read to validate the two edges; the patch itself is purely topological, and nothing moves.

required
faces array[int32]

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

required
edge_a tuple[int, int]

A boundary edge as (v0, v1), directed the way its face winds it -- a row of oriented_boundary_edges.

required
edge_b tuple[int, int]

The second boundary edge, in the same direction convention.

required
validate bool

Check that both edges are boundary edges of faces and that the patch would not duplicate an existing edge. Costs one pass over the mesh edges and one readback; pass False when the edges came from oriented_boundary_edges and the pairing is known good.

True
boundary_edges Array2dInt32 | None

The rim table of faces, when the caller already has one — as any caller that picked edge_a and edge_b out of oriented_boundary_edges does. Read only under validate, and only for the on-rim half of it: the chord-duplicate half always runs. Trusted, not checked, so it must have been built from this same faces; None rebuilds it. This is the middle setting between paying for the whole check and turning it off — it keeps the half a rim-derived pair cannot satisfy by construction.

None

Returns:

Type Description
array[int32]

The input faces with the patch appended, on faces.device. The appended block is the tail -- 3 entries longer than the input for the shared-vertex case, 6 otherwise -- so its size is what says which case was taken.

Raises:

Type Description
ValueError

If the two edges are the same edge, if validate is set and either is not a boundary edge of faces, or if validate is set and the patch would create a second edge between a pair of vertices that already share one (which would leave the mesh non-manifold).

RuntimeError

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

Examples:

edges_np = tw.boundary.oriented_boundary_edges(open_v, open_f).numpy()
bridged_f = tw.holes.bridge_edges(
    open_v, open_f, tuple(edges_np[0]), tuple(edges_np[len(edges_np) // 2])
)
See Also

bridge_edges_smooth The multi-segment form, which curves the patch and adds vertices. join_closest_components The driver over this, when the pair to bridge is not the caller's to name. stitch_loops Joins two rims completely, where this joins one edge of each. oriented_boundary_edges Produces the edges this takes, already in the right direction.

Source code in triwarp/holes.py
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
def bridge_edges(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edge_a: tuple[int, int],
    edge_b: tuple[int, int],
    validate: bool = True,
    boundary_edges: twt.Array2dInt32 | None = None,
) -> wp.array[wp.int32]:
    """
    Join two boundary edges with a two-triangle patch, leaving the rest of both rims open.

    Where [`stitch_loops`][triwarp.holes.stitch_loops] consumes two *complete* rims, this is the
    local operation underneath it: pick one edge on each side and close only that gap. Two open
    rims become one, so a bridge is how a tube is joined to another tube at a chosen seam, how a
    partially torn boundary is tacked back together, and -- when both edges lie on the *same* rim --
    how a handle is added, since bridging one loop to itself splits it into two.

    The patch is the quadrilateral ``(a1, a0, b1, b0)`` split along the ``a0 - b0`` diagonal, so it
    adds **two triangles and no vertices**. When the two edges already share a vertex -- they are
    consecutive along one rim -- that quadrilateral is a triangle and only **one** is added.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions. Only read to validate the two edges; the patch
        itself is purely topological, and nothing moves.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.
    edge_a
        A boundary edge as ``(v0, v1)``, **directed the way its face winds it** -- a row of
        [`oriented_boundary_edges`][triwarp.boundary.oriented_boundary_edges].
    edge_b
        The second boundary edge, in the same direction convention.
    validate
        Check that both edges are boundary edges of ``faces`` and that the patch would not
        duplicate an existing edge. Costs one pass over the mesh edges and one readback; pass
        ``False`` when the edges came from
        [`oriented_boundary_edges`][triwarp.boundary.oriented_boundary_edges] and the pairing is
        known good.
    boundary_edges
        The rim table of ``faces``, when the caller already has one — as any caller that picked
        ``edge_a`` and ``edge_b`` out of
        [`oriented_boundary_edges`][triwarp.boundary.oriented_boundary_edges] does. Read only under
        ``validate``, and only for the on-rim half of it: the chord-duplicate half always runs.
        Trusted, not checked, so it must have been built from this same ``faces``; ``None``
        rebuilds it. This is the middle setting between paying for the whole check and turning it
        off — it keeps the half a rim-derived pair cannot satisfy by construction.

    Returns
    -------
    wp.array[wp.int32]
        The input faces with the patch appended, on ``faces.device``. The appended block is the
        tail -- ``3`` entries longer than the input for the shared-vertex case, ``6`` otherwise --
        so its size is what says which case was taken.

    Raises
    ------
    ValueError
        If the two edges are the same edge, if ``validate`` is set and either is not a boundary
        edge of ``faces``, or if ``validate`` is set and the patch would create a second edge
        between a pair of vertices that already share one (which would leave the mesh
        non-manifold).
    RuntimeError
        If ``vertices``, ``faces`` and ``boundary_edges`` are not all on one device.

    Examples
    --------
    ```python
    edges_np = tw.boundary.oriented_boundary_edges(open_v, open_f).numpy()
    bridged_f = tw.holes.bridge_edges(
        open_v, open_f, tuple(edges_np[0]), tuple(edges_np[len(edges_np) // 2])
    )
    ```

    See Also
    --------
    [`bridge_edges_smooth`][triwarp.holes.bridge_edges_smooth]
        The multi-segment form, which curves the patch and adds vertices.
    [`join_closest_components`][triwarp.holes.join_closest_components]
        The driver over this, when the pair to bridge is not the caller's to name.
    [`stitch_loops`][triwarp.holes.stitch_loops]
        Joins two rims completely, where this joins one edge of each.
    [`oriented_boundary_edges`][triwarp.boundary.oriented_boundary_edges]
        Produces the edges this takes, already in the right direction.
    """
    require_same_device(vertices=vertices, faces=faces, boundary_edges=boundary_edges)
    edge_a = (int(edge_a[0]), int(edge_a[1]))
    edge_b = (int(edge_b[0]), int(edge_b[1]))
    _check_bridge_edges(
        vertices,
        faces,
        edge_a,
        edge_b,
        _bridge_joined_pairs(edge_a, edge_b),
        validate,
        boundary_edges,
    )
    triangles = _bridge_triangles(edge_a, edge_b)
    patch = wp.array(
        np.asarray(triangles, dtype=np.int32).reshape(-1), dtype=wp.int32, device=faces.device
    )
    return tw.array.concatenate([faces, patch])

bridge_edges_smooth

bridge_edges_smooth(
    vertices: array[vec3],
    faces: array[int32],
    edge_a: tuple[int, int],
    edge_b: tuple[int, int],
    sampling_step: float,
    validate: bool = True,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Join two boundary edges with a curved strip that leaves both surfaces smoothly.

bridge_edges spans the gap with a flat patch, which creases against both surfaces as soon as the two edges are more than a triangle apart. This spans it with a strip of quadrilaterals instead, following the cubic through the two edge midpoints whose end tangents lie in the two incident triangles -- so the strip leaves each surface in the direction that surface was already going, and the crease is spread over the whole strip rather than concentrated at its two ends.

The strip is subdivided until its segments are no longer than sampling_step, and its width tapers linearly from the length of edge_a to that of edge_b. Its two boundary chains start at the two ends of edge_a and finish at the two ends of edge_b, so the existing four vertices are reused and only the interior ones are new. As in the flat form, two edges that already share a vertex give a fan from that vertex rather than a strip.

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
edge_a tuple[int, int]

A boundary edge as (v0, v1), directed the way its face winds it.

required
edge_b tuple[int, int]

The second boundary edge, in the same direction convention.

required
sampling_step float

Target segment length along the strip. Smaller means more segments and a smoother patch; a step at or above the span gives the single segment bridge_edges would have produced, on the curve rather than on the chord.

required
validate bool

Check that both edges are boundary edges of faces and that the patch would not duplicate an existing edge, as in bridge_edges.

True

Returns:

Name Type Description
vertices array[vec3]

The input positions, unchanged and in order, with the strip's interior vertices appended.

faces array[int32]

The input faces with the strip's triangles appended.

Raises:

Type Description
ValueError

If sampling_step is not positive, if either edge is not a directed edge of faces (checked whatever validate says, since the strip needs both incident faces), or for any of the reasons bridge_edges raises.

RuntimeError

If vertices and faces are not all on one device.

See Also

bridge_edges The single-quadrilateral form, which adds no vertices. fill_smooth The same idea for a whole rim: a patch refined and faired rather than merely spanned.

Notes

The curve is a cubic, not an optimum

The strip follows one cubic Hermite segment fitted to the two edge midpoints and the two incident-face tangents. Nothing minimizes its bending energy or checks it for self-intersection, so a step far smaller than the span across two nearly opposed edges can fold; run fix_self_intersections if the input pairing is not yours to choose.

Two opposed edges give a half-twisted strip, not an error

The strip's width direction interpolates from edge_a's to edge_b's. When the two run opposite ways the interpolation passes through a half turn whose axis is arbitrary, and the strip twists through it at one sample rather than curving smoothly. Reversing one of the two edges is what removes the twist.

Source code in triwarp/holes.py
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
def bridge_edges_smooth(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edge_a: tuple[int, int],
    edge_b: tuple[int, int],
    sampling_step: float,
    validate: bool = True,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Join two boundary edges with a curved strip that leaves both surfaces smoothly.

    [`bridge_edges`][triwarp.holes.bridge_edges] spans the gap with a flat patch, which creases
    against both surfaces as soon as the two edges are more than a triangle apart. This spans it
    with a strip of quadrilaterals instead, following the cubic through the two edge midpoints
    whose end tangents lie **in** the two incident triangles -- so the strip leaves each surface in
    the direction that surface was already going, and the crease is spread over the whole strip
    rather than concentrated at its two ends.

    The strip is subdivided until its segments are no longer than ``sampling_step``, and its width
    tapers linearly from the length of ``edge_a`` to that of ``edge_b``. Its two boundary chains
    start at the two ends of ``edge_a`` and finish at the two ends of ``edge_b``, so the existing
    four vertices are reused and only the interior ones are new. As in the flat form, two edges
    that already share a vertex give a fan from that vertex rather than a strip.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.
    edge_a
        A boundary edge as ``(v0, v1)``, directed the way its face winds it.
    edge_b
        The second boundary edge, in the same direction convention.
    sampling_step
        Target segment length along the strip. Smaller means more segments and a smoother patch;
        a step at or above the span gives the single segment
        [`bridge_edges`][triwarp.holes.bridge_edges] would have produced, on the curve rather than
        on the chord.
    validate
        Check that both edges are boundary edges of ``faces`` and that the patch would not
        duplicate an existing edge, as in [`bridge_edges`][triwarp.holes.bridge_edges].

    Returns
    -------
    vertices : wp.array[wp.vec3]
        The input positions, unchanged and in order, with the strip's interior vertices appended.
    faces : wp.array[wp.int32]
        The input faces with the strip's triangles appended.

    Raises
    ------
    ValueError
        If ``sampling_step`` is not positive, if either edge is not a directed edge of ``faces``
        (checked whatever ``validate`` says, since the strip needs both incident faces), or for any
        of the reasons [`bridge_edges`][triwarp.holes.bridge_edges] raises.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    See Also
    --------
    [`bridge_edges`][triwarp.holes.bridge_edges]
        The single-quadrilateral form, which adds no vertices.
    [`fill_smooth`][triwarp.holes.fill_smooth]
        The same idea for a whole rim: a patch refined and faired rather than merely spanned.

    Notes
    -----
    !!! note "The curve is a cubic, not an optimum"
        The strip follows one cubic Hermite segment fitted to the two edge midpoints and the two
        incident-face tangents. Nothing minimizes its bending energy or checks it for
        self-intersection, so a step far smaller than the span across two nearly opposed edges can
        fold; run [`fix_self_intersections`][triwarp.repair.fix_self_intersections] if the input
        pairing is not yours to choose.

    !!! note "Two opposed edges give a half-twisted strip, not an error"
        The strip's width direction interpolates from ``edge_a``'s to ``edge_b``'s. When the two
        run opposite ways the interpolation passes through a half turn whose axis is arbitrary, and
        the strip twists through it at one sample rather than curving smoothly. Reversing one of
        the two edges is what removes the twist.
    """
    require_same_device(vertices=vertices, faces=faces)
    if sampling_step <= 0.0:
        raise ValueError(f"sampling_step must be positive, got {sampling_step}")
    device = faces.device
    a0, a1 = int(edge_a[0]), int(edge_a[1])
    b0, b1 = int(edge_b[0]), int(edge_b[1])
    # A strip with interior samples joins nothing but its own new vertices, so it has no pair to
    # check; the one-segment case falls through to the flat patch below, which checks its own.
    #
    # The rim table is built here rather than inside each check so the one-segment path does not
    # rebuild it: nothing between the two calls touches ``faces``, and that rebuild measured
    # 22.5-23.7% of that path (0.38 ms of 1.60-1.71 ms, flat from 1 240 to 20 440 faces).
    rim = tw.boundary.oriented_boundary_edges(vertices, faces) if validate else None
    _check_bridge_edges(vertices, faces, (a0, a1), (b0, b1), (), validate, rim)

    # One launch to find each edge's opposite corner, then one gather of the six positions the
    # spline needs. Both are here so the host never reads back a buffer that scales with the mesh.
    query = wp.array(np.array([[a0, a1], [b0, b1]], dtype=np.int32), dtype=wp.int32, device=device)
    opposites = wp.full(2, -1, dtype=wp.int32, device=device)
    wp.launch(
        kernel_holes.directed_edge_opposites,
        dim=(int(faces.shape[0]) // 3, 2),
        inputs=[faces, query, opposites],
        device=device,
    )
    opposites_np = opposites.numpy()
    if int(opposites_np[0]) < 0 or int(opposites_np[1]) < 0:
        raise ValueError("both edges must be directed edges of faces, wound as their face winds")
    corners = np.array([a0, a1, b0, b1, int(opposites_np[0]), int(opposites_np[1])], dtype=np.int32)
    gathered = wp.empty(6, dtype=wp.vec3, device=device)
    wp.copy(gathered, vertices[wp.array(corners, dtype=wp.int32, device=device)])
    positions_np = gathered.numpy().astype(np.float64)

    n_vertices = int(vertices.shape[0])
    interior_np, strip_np = _bridge_strip(positions_np, corners, n_vertices, sampling_step)
    if interior_np.shape[0] == 0:
        # One segment: the strip *is* the flat patch, so hand it over with the caller's own
        # ``validate`` -- that path adds edges between existing vertices and has its own check.
        return wp.clone(vertices), bridge_edges(vertices, faces, (a0, a1), (b0, b1), validate, rim)

    bridged_vertices = wp.empty(n_vertices + interior_np.shape[0], dtype=wp.vec3, device=device)
    wp.copy(bridged_vertices[:n_vertices], vertices)
    wp.copy(
        bridged_vertices[n_vertices:],
        wp.array(interior_np.astype(np.float32), dtype=wp.vec3, device=device),
    )
    strip = wp.array(strip_np.reshape(-1), dtype=wp.int32, device=device)
    return bridged_vertices, tw.array.concatenate([faces, strip])

join_closest_components

join_closest_components(
    vertices: array[vec3],
    faces: array[int32],
    *,
    max_distance: float | None = None,
    max_joins: int | None = None,
) -> wp.array[wp.int32]

Bridge the closest pairs of open components until the mesh is connected.

The driver over bridge_edges, and the step that turns a repair pipeline's output into a single solid rather than several. Where bridge_edges joins two edges the caller names and stitch_loops consumes two complete rims, this answers "these are several open shells; make them one" -- which no other entry point here does, because the question it has to settle first is which pieces to join.

The rule is greedy nearest-link agglomeration, i.e. Kruskal over the components with the distance between their boundary vertices as the edge weight: take the globally closest pair of boundary vertices belonging to different components, bridge it, and repeat while a cross- component pair remains. Each join adds exactly two triangles and no vertices -- the two rims it touches become one open rim, left for a filler to close -- so the result grows by 2 * (k - 1) faces for k open components.

Ties are broken by the lower boundary-vertex index, so the answer does not depend on thread order.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions. Nothing moves and nothing is added, so this is only read.

required
faces array[int32]

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

required
max_distance float | None

Refuse a join whose two boundary vertices are further apart than this. None (default) joins unconditionally, which is what the reference implementations do -- and which on a mesh holding genuinely separate objects welds them together, so pass a bound when the input might not be one broken surface.

None
max_joins int | None

Stop after this many bridges. None (default) continues until one component remains or no admissible pair is left.

None

Returns:

Type Description
array[int32]

The input face buffer with the bridge triangles appended, on faces.device. A copy of faces when there is nothing to join -- fewer than two components, or no component with a boundary.

Raises:

Type Description
ValueError

If max_joins is negative, or if a chosen pair admits no valid bridge at either of its two incident boundary edges.

RuntimeError

If vertices and faces are not all on one device.

See Also

bridge_edges The primitive this drives, and where the two-triangle patch is defined. stitch_loops Close two rims completely rather than tacking them together at one seam. fill_min_weight What to run afterwards: the merged rim is left open on purpose. repair.remove_small_components The other answer to a multi-component mesh -- throw the extra pieces away instead.

Notes

The loop is host-sequential over the k - 1 joins and recomputes the component labelling and the boundary edges from the updated face buffer each round, so the cost is O(k * n_faces). That is deliberate: k is the number of open components, which does not grow with the mesh, and recomputing makes the merge and the rim update fall out rather than needing a union-find and an incremental rim edit whose correctness would be much harder to see. The pairing itself is on the device.

A component with no boundary -- a closed shell -- has nothing to bridge to and is left alone, so an input of closed shells comes back unchanged rather than raising.

Source code in triwarp/holes.py
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
def join_closest_components(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    *,
    max_distance: float | None = None,
    max_joins: int | None = None,
) -> wp.array[wp.int32]:
    """
    Bridge the closest pairs of *open* components until the mesh is connected.

    The driver over [`bridge_edges`][triwarp.holes.bridge_edges], and the step that turns a repair
    pipeline's output into a **single** solid rather than several. Where ``bridge_edges`` joins two
    edges the caller names and [`stitch_loops`][triwarp.holes.stitch_loops] consumes two complete
    rims, this answers *"these are several open shells; make them one"* -- which no other entry
    point here does, because the question it has to settle first is *which* pieces to join.

    The rule is greedy nearest-link agglomeration, i.e. Kruskal over the components with the
    distance between their boundary vertices as the edge weight: take the globally closest pair of
    boundary vertices belonging to different components, bridge it, and repeat while a cross-
    component pair remains. Each join adds exactly **two triangles and no vertices** -- the two rims
    it touches become one open rim, left for a filler to close -- so the result grows by
    ``2 * (k - 1)`` faces for ``k`` open components.

    Ties are broken by the lower boundary-vertex index, so the answer does not depend on thread
    order.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions. Nothing moves and nothing is added, so this is only
        read.
    faces
        Length-``3 * n_faces`` ``wp.int32`` flat triangle index buffer.
    max_distance
        Refuse a join whose two boundary vertices are further apart than this. ``None`` (default)
        joins unconditionally, which is what the reference implementations do -- and which on a mesh
        holding genuinely separate objects welds them together, so pass a bound when the input might
        not be one broken surface.
    max_joins
        Stop after this many bridges. ``None`` (default) continues until one component remains or no
        admissible pair is left.

    Returns
    -------
    wp.array[wp.int32]
        The input face buffer with the bridge triangles appended, on ``faces.device``. A copy of
        ``faces`` when there is nothing to join -- fewer than two components, or no component with a
        boundary.

    Raises
    ------
    ValueError
        If ``max_joins`` is negative, or if a chosen pair admits no valid bridge at either of its
        two incident boundary edges.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    See Also
    --------
    [`bridge_edges`][triwarp.holes.bridge_edges]
        The primitive this drives, and where the two-triangle patch is defined.
    [`stitch_loops`][triwarp.holes.stitch_loops]
        Close two rims *completely* rather than tacking them together at one seam.
    [`fill_min_weight`][triwarp.holes.fill_min_weight]
        What to run afterwards: the merged rim is left open on purpose.
    [`repair.remove_small_components`][triwarp.repair.remove_small_components]
        The other answer to a multi-component mesh -- throw the extra pieces away instead.

    Notes
    -----
    The loop is host-sequential over the ``k - 1`` joins and recomputes the component labelling and
    the boundary edges from the updated face buffer each round, so the cost is ``O(k * n_faces)``.
    That is deliberate: ``k`` is the number of *open* components, which does not grow with the mesh,
    and recomputing makes the merge and the rim update fall out rather than needing a union-find
    and an incremental rim edit whose correctness would be much harder to see. The pairing itself is
    on the device.

    A component with no boundary -- a closed shell -- has nothing to bridge to and is left alone, so
    an input of closed shells comes back unchanged rather than raising.
    """
    require_same_device(vertices=vertices, faces=faces)
    if max_joins is not None and max_joins < 0:
        raise ValueError(f"max_joins must be non-negative, got {max_joins}")
    max_distance_sq = wp.float32(float("inf") if max_distance is None else float(max_distance) ** 2)

    current = faces
    joins = 0
    while max_joins is None or joins < max_joins:
        pair = _closest_cross_component_edges(vertices, current, max_distance_sq)
        if pair is None:
            break
        # The rim table the pair was chosen from is the one ``bridge_edges`` would rebuild to check
        # it against, over the same unchanged ``current``. Handing it over keeps the docstring's
        # "recomputes ... the boundary edges ... each round" true at one rebuild per round rather
        # than two -- measured 13.5-15% of this call, and the share does not fall with mesh size.
        current = bridge_edges(vertices, current, pair[0], pair[1], boundary_edges=pair[2])
        joins += 1

    return wp.clone(faces) if joins == 0 else current