Skip to content

boundary

triwarp.boundary

Mesh boundary edges and vertices.

A mesh edge lies on the boundary when it appears exactly once among all triangle edges. Boundary detection reuses group_int_rows (the analog of trimesh.grouping.group_rows(require_count=1)), which hashes each sorted edge row and returns the original row indices of edges occurring exactly once.

Every loop-shaped entry point comes in two forms, and the pairing is the module's one convention worth stating up front: a list form returning or taking one wp.array per loop (boundary_loops, loop_perimeters, loop_directed_areas) and a _batched form over one packed buffer plus per-loop offsets and sizes (boundary_loops_batched, loop_perimeters_batched, loop_directed_areas_batched). They compute the same answer; the packed form is the one whose cost is independent of the loop count, and it is what triwarp.holes carries its rims in from end to end. The list forms pack and delegate, so there is one segmented launch behind each measure rather than one per form.

boundary_edges

boundary_edges(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
) -> twt.Array2dInt32

Undirected boundary edges (each row min-first), without preserving orientation.

An edge belongs to the boundary when it appears exactly once among the sorted edges of all triangles.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions; only the count is used (as the row-hash base).

required
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) sorted edges (each row min-first), as from faces_to_edges with sorted=True. Built from faces when None.

None

Returns:

Type Description
Array2dInt32

Shape (n_boundary, 2) sorted boundary edges on faces.device. Empty (0, 2) when the mesh has no boundary.

Raises:

Type Description
RuntimeError

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

Source code in triwarp/boundary.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def boundary_edges(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
) -> twt.Array2dInt32:
    """
    Undirected boundary edges (each row min-first), without preserving orientation.

    An edge belongs to the boundary when it appears exactly once among the sorted edges of
    all triangles.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions; only the count is used (as the row-hash base).
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed ``(n_faces * 3, 2)`` sorted edges (each row min-first), as from
        [`faces_to_edges`][triwarp.edges.faces_to_edges] with ``sorted=True``. Built from
        ``faces`` when ``None``.

    Returns
    -------
    twt.Array2dInt32
        Shape ``(n_boundary, 2)`` sorted boundary edges on ``faces.device``. Empty ``(0, 2)``
        when the mesh has no boundary.

    Raises
    ------
    RuntimeError
        If ``vertices``, ``faces`` and ``edges_sorted`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted)
    return _boundary_edges_impl(vertices, faces, edges_sorted, None, oriented=False)

oriented_boundary_edges

oriented_boundary_edges(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
    edges: Array2dInt32 | None = None,
) -> twt.Array2dInt32

Directed boundary edges, preserving the orientation from the face winding.

Boundary edges are detected on the sorted edges (appearing exactly once), but the directed (i, j) pairs are returned.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions; only the count is used (as the row-hash base).

required
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

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

None
edges Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) directed edges, as from faces_to_edges. Built from faces when None.

None

Returns:

Type Description
Array2dInt32

Shape (n_boundary, 2) directed boundary edges on faces.device. Empty (0, 2) when the mesh has no boundary.

Raises:

Type Description
RuntimeError

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

Source code in triwarp/boundary.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def oriented_boundary_edges(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    edges: twt.Array2dInt32 | None = None,
) -> twt.Array2dInt32:
    """
    Directed boundary edges, preserving the orientation from the face winding.

    Boundary edges are detected on the sorted edges (appearing exactly once), but the
    directed ``(i, j)`` pairs are returned.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions; only the count is used (as the row-hash base).
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed ``(n_faces * 3, 2)`` sorted edges (each row min-first). Built
        from ``faces`` when ``None``.
    edges
        Optional precomputed ``(n_faces * 3, 2)`` directed edges, as from
        [`faces_to_edges`][triwarp.edges.faces_to_edges]. Built from ``faces`` when ``None``.

    Returns
    -------
    twt.Array2dInt32
        Shape ``(n_boundary, 2)`` directed boundary edges on ``faces.device``. Empty
        ``(0, 2)`` when the mesh has no boundary.

    Raises
    ------
    RuntimeError
        If ``vertices``, ``faces``, ``edges_sorted`` and ``edges`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted, edges=edges)
    return _boundary_edges_impl(vertices, faces, edges_sorted, edges, oriented=True)

boundary_loops

boundary_loops(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
    edges: Array2dInt32 | None = None,
    *,
    copy: bool = False,
) -> list[wp.array[wp.int32]]

Ordered vertex-index loops along each mesh boundary.

Each boundary loop is a simple cycle in the directed-boundary-edge successor graph (on a manifold boundary every boundary vertex has exactly one outgoing boundary edge). Loops are grouped via connected-component labeling, then each vertex's ordinal position within its loop is ranked by following the successor chain from itself to its loop's canonical start (the smallest vertex index in the loop) — entirely GPU-parallel, mirroring igl::boundary_loop (its first overload).

All loops are found in one batched pass (boundary_loops_batched); this is split over its packed result.

The returned arrays are views

Each loop slices the single packed buffer boundary_loops_batched produced, which costs no device memory and no launches. Two consequences: holding on to a single loop keeps the whole buffer alive, and writing into one loop writes into the shared allocation. Pass copy=True for independent buffers.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions; only the count is used (as the row-hash base and the successor-array size).

required
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) sorted edges, forwarded to boundary_loops_batched.

None
edges Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) directed edges, forwarded to boundary_loops_batched.

None
copy bool

Return independent buffers instead of views into the packed result.

False

Returns:

Type Description
list[array[int32]]

One array per boundary loop, each holding the ordered vertex indices around that loop (not repeating the start vertex), on faces.device. Empty list when the mesh has no boundary.

Raises:

Type Description
RuntimeError

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

Notes

Assumes a manifold boundary: each boundary vertex lies on exactly two boundary edges. A vertex shared by more than one loop (a non-manifold pinch point) keeps only one outgoing successor edge, last write wins.

Orientability is not assumed. On a non-orientable surface the winding cannot orient the boundary globally -- at the seam two boundary edges leave the same vertex -- so the loops are recovered from the undirected edges instead, and their direction is then an arbitrary but reproducible choice rather than the face winding's. Only the direction is arbitrary; the loops themselves are exact. Costs one extra host readback to detect the case.

See Also

boundary_loops_batched longest_boundary_loop oriented_boundary_edges igl.boundary_loop_all

Source code in triwarp/boundary.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def boundary_loops(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    edges: twt.Array2dInt32 | None = None,
    *,
    copy: bool = False,
) -> list[wp.array[wp.int32]]:
    """
    Ordered vertex-index loops along each mesh boundary.

    Each boundary loop is a simple cycle in the directed-boundary-edge successor graph (on a
    manifold boundary every boundary vertex has exactly one outgoing boundary edge). Loops are
    grouped via connected-component labeling, then each vertex's ordinal position within its
    loop is ranked by following the successor chain from itself to its loop's canonical start
    (the smallest vertex index in the loop) — entirely GPU-parallel, mirroring
    ``igl::boundary_loop`` (its first overload).

    All loops are found in one batched pass
    ([`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched]); this is
    [`split`][triwarp.array.split] over its packed result.

    !!! note "The returned arrays are views"
        Each loop slices the single packed buffer ``boundary_loops_batched`` produced, which costs
        no device memory and no launches. Two consequences: holding on to a single loop keeps the
        *whole* buffer alive, and writing into one loop writes into the shared allocation. Pass
        ``copy=True`` for independent buffers.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions; only the count is used (as the row-hash base and
        the successor-array size).
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed ``(n_faces * 3, 2)`` sorted edges, forwarded to
        [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched].
    edges
        Optional precomputed ``(n_faces * 3, 2)`` directed edges, forwarded to
        [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched].
    copy
        Return independent buffers instead of views into the packed result.

    Returns
    -------
    list[wp.array[wp.int32]]
        One array per boundary loop, each holding the ordered vertex indices around that loop
        (not repeating the start vertex), on ``faces.device``. Empty list when the mesh has no
        boundary.

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

    Notes
    -----
    Assumes a **manifold** boundary: each boundary vertex lies on exactly two boundary edges. A
    vertex shared by more than one loop (a non-manifold pinch point) keeps only one outgoing
    successor edge, last write wins.

    Orientability is *not* assumed. On a non-orientable surface the winding cannot orient the
    boundary globally -- at the seam two boundary edges leave the same vertex -- so the loops are
    recovered from the undirected edges instead, and their direction is then an arbitrary but
    reproducible choice rather than the face winding's. Only the direction is arbitrary; the loops
    themselves are exact. Costs one extra host readback to detect the case.

    See Also
    --------
    [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched]
    [`longest_boundary_loop`][triwarp.boundary.longest_boundary_loop]
    [`oriented_boundary_edges`][triwarp.boundary.oriented_boundary_edges]
    ``igl.boundary_loop_all``
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted, edges=edges)
    flat_loops, offsets, _loop_sizes = boundary_loops_batched(vertices, faces, edges_sorted, edges)
    return tw.array.split(flat_loops, offsets, copy=copy)

boundary_loops_batched

boundary_loops_batched(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
    edges: Array2dInt32 | None = None,
) -> tuple[
    wp.array[wp.int32],
    wp.array[wp.int32],
    wp.array[wp.int32],
]

Every boundary loop at once, packed into one buffer plus per-loop offsets.

Same loops, same order, same contents as boundary_loops — but with no per-loop Python and no per-loop allocation, which is the only form whose cost is independent of the loop count. Prefer it when a mesh has many small holes or when the loops feed straight into another batched kernel, as triwarp.holes does. The loop ordering itself is the general successor-graph machinery of successor_cycles; this function contributes the boundary-edge detection.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions; only the count is used (as the row-hash base and the successor-array size).

required
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) sorted edges (each row min-first), as from faces_to_edges with sorted=True. Built from faces when None.

None
edges Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) directed edges, as from faces_to_edges. Built from faces when None.

None

Returns:

Type Description
flat_loops

Concatenated ordered vertex indices of every loop, on faces.device.

offsets

Length-n_loops exclusive prefix sum of the loop sizes: loop i occupies flat_loops[offsets[i] : offsets[i] + loop_sizes[i]]. Not a total-terminated CSR array — the last loop ends at flat_loops.shape[0].

loop_sizes

Length-n_loops vertex count per loop.

Raises:

Type Description
RuntimeError

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

See Also

boundary_loops longest_boundary_loop successor_cycles

Source code in triwarp/boundary.py
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
295
296
297
298
299
300
def boundary_loops_batched(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    edges: twt.Array2dInt32 | None = None,
) -> tuple[wp.array[wp.int32], wp.array[wp.int32], wp.array[wp.int32]]:
    """
    Every boundary loop at once, packed into one buffer plus per-loop offsets.

    Same loops, same order, same contents as
    [`boundary_loops`][triwarp.boundary.boundary_loops] — but with no per-loop Python and no
    per-loop allocation, which is the only form whose cost is independent of the loop *count*.
    Prefer it when a mesh has many small holes or when the loops feed straight into another
    batched kernel, as [`triwarp.holes`][triwarp.holes] does. The loop ordering itself is the
    general successor-graph machinery of [`successor_cycles`][triwarp.graph.successor_cycles];
    this function contributes the boundary-edge detection.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions; only the count is used (as the row-hash base and
        the successor-array size).
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed ``(n_faces * 3, 2)`` sorted edges (each row min-first), as from
        [`faces_to_edges`][triwarp.edges.faces_to_edges] with ``sorted=True``. Built from
        ``faces`` when ``None``.
    edges
        Optional precomputed ``(n_faces * 3, 2)`` directed edges, as from
        [`faces_to_edges`][triwarp.edges.faces_to_edges]. Built from ``faces`` when ``None``.

    Returns
    -------
    flat_loops
        Concatenated ordered vertex indices of every loop, on ``faces.device``.
    offsets
        Length-``n_loops`` exclusive prefix sum of the loop sizes: loop ``i`` occupies
        ``flat_loops[offsets[i] : offsets[i] + loop_sizes[i]]``. Not a total-terminated CSR
        array — the last loop ends at ``flat_loops.shape[0]``.
    loop_sizes
        Length-``n_loops`` vertex count per loop.

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

    See Also
    --------
    [`boundary_loops`][triwarp.boundary.boundary_loops]
    [`longest_boundary_loop`][triwarp.boundary.longest_boundary_loop]
    [`successor_cycles`][triwarp.graph.successor_cycles]
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted, edges=edges)
    n_faces = int(faces.shape[0]) // 3
    device = faces.device
    if n_faces == 0:
        # Three *distinct* empty allocations, so callers may write into them independently.
        return tuple(wp.empty(0, dtype=wp.int32, device=device) for _ in range(3))

    n_vertices = int(vertices.shape[0])
    if edges_sorted is None:
        edges_sorted = tw.edges.faces_to_edges(faces, sorted=True)
    # One boundary detection for all three edge views below; ``boundary_edges`` /
    # ``oriented_boundary_edges`` / ``boundary_vertex_indices`` would each redo the row grouping.
    rows = _boundary_rows(n_vertices, edges_sorted)
    n_boundary_edges = int(rows.shape[0])
    if n_boundary_edges == 0:
        return tuple(wp.empty(0, dtype=wp.int32, device=device) for _ in range(3))

    if edges is None:
        edges = tw.edges.faces_to_edges(faces)
    directed = twt.as_array2d(tw.array.gather(edges, rows), wp.int32)

    if _needs_unoriented_boundary_walk(directed, n_vertices):
        return _unoriented_boundary_cycles(
            twt.as_array2d(tw.array.gather(edges_sorted, rows), wp.int32), n_vertices
        )
    # ``validate=False``: ``directed`` holds vertex indices this function just gathered out of
    # ``faces``, so the range check would only re-derive a bound the caller already guarantees —
    # at the cost of a device synchronization.
    return tw.graph.successor_cycles(directed, n_vertices, validate=False)

loop_perimeters

loop_perimeters(
    vertices: array[vec3], loops: Sequence[array[int32]]
) -> wp.array[wp.float32]

Perimeter of every closed loop, in one launch.

Takes what boundary_loops returns -- a list of vertex-index cycles whose last entry joins back to the first -- and measures them all together, so the cost is one launch and one packing pass rather than one call per loop. Equivalent to polyline_length with closed=True on each loop's gathered positions, and the segmented form is why the fill's preserve_largest_hole can rank every rim for the price of one readback.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
loops Sequence[array[int32]]

Closed vertex-index cycles. An empty sequence gives an empty result; a loop of fewer than two entries measures 0.

required

Returns:

Type Description
array[float32]

One perimeter per loop, in the order given, on vertices.device.

Raises:

Type Description
TypeError

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

RuntimeError

If vertices and loops are not all on one device.

See Also

loop_directed_areas The vector measure of the same loops: norm is the spanned area, direction is its normal. boundary_loops Produces the loops. polyline_length The single-loop form, over positions rather than indices.

Source code in triwarp/boundary.py
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
def loop_perimeters(
    vertices: wp.array[wp.vec3], loops: Sequence[wp.array[wp.int32]]
) -> wp.array[wp.float32]:
    """
    Perimeter of every closed loop, in one launch.

    Takes what [`boundary_loops`][triwarp.boundary.boundary_loops] returns -- a list of vertex-index
    cycles whose last entry joins back to the first -- and measures them all together, so the cost
    is one launch and one packing pass rather than one call per loop. Equivalent to
    [`polyline_length`][triwarp.polyline.polyline_length] with ``closed=True`` on each loop's
    gathered positions, and the segmented form is why the fill's ``preserve_largest_hole`` can rank
    every rim for the price of one readback.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    loops
        Closed vertex-index cycles. An empty sequence gives an empty result; a loop of fewer than
        two entries measures ``0``.

    Returns
    -------
    wp.array[wp.float32]
        One perimeter per loop, in the order given, on ``vertices.device``.

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

    See Also
    --------
    [`loop_directed_areas`][triwarp.boundary.loop_directed_areas]
        The vector measure of the same loops: norm is the spanned area, direction is its normal.
    [`boundary_loops`][triwarp.boundary.boundary_loops]
        Produces the loops.
    [`polyline_length`][triwarp.polyline.polyline_length]
        The single-loop form, over positions rather than indices.
    """
    require_same_device(vertices=vertices, loops=loops)
    packed = _pack_loop_segments(vertices, loops)
    if packed is None:
        return wp.empty(0, dtype=wp.float32, device=vertices.device)
    flat_loops, loop_id, starts, sizes, n_loops = packed
    return _launch_loop_measure(
        kernel_boundary.loop_perimeters,
        wp.float32,
        vertices,
        flat_loops,
        loop_id,
        starts,
        sizes,
        n_loops,
    )

loop_perimeters_batched

loop_perimeters_batched(
    vertices: array[vec3],
    flat_loops: array[int32],
    offsets: array[int32],
    loop_sizes: array[int32],
    *,
    loop_id: array[int32] | None = None,
    validate: bool = True,
) -> wp.array[wp.float32]

Perimeter of every loop, taking the loops in the packed form rather than as a list.

Same measure as loop_perimeters and the same launch; the three arguments after vertices are exactly what boundary_loops_batched returns, in that order. Reach for this whenever the loops are already packed, which is the form triwarp.holes carries them in throughout: the list form would have to be split back out and repacked to be measured, and the split is a per-loop Python object.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
flat_loops array[int32]

Concatenated ordered vertex indices of every loop.

required
offsets array[int32]

Length-n_loops start of each loop in flat_loops. Not total-terminated, matching boundary_loops_batched.

required
loop_sizes array[int32]

Length-n_loops vertex count per loop.

required
loop_id array[int32] | None

Optional precomputed length-flat_loops label saying which loop each packed position belongs to. Built here when None, which costs an allocation, a copy and a launch -- roughly doubling the call, since the measure itself is one launch. Pass it when the caller already holds it, as triwarp.holes does.

None
validate bool

When True (default), check that offsets and loop_sizes agree in length and that every (offset, size) pair stays within flat_loops before launching -- a real cost (a host readback of both arrays), paid because a hand-built or stale packed triple otherwise drives the kernel's per-loop index past flat_loops's end with no exception raised. Pass False only when the triple is known correct by construction, as triwarp.holes's internal packing already is.

True

Returns:

Type Description
array[float32]

One perimeter per loop, in the packed order, on vertices.device.

Raises:

Type Description
RuntimeError

If vertices, flat_loops, offsets, loop_sizes and loop_id are not all on one device.

ValueError

If validate and the packed triple is malformed -- see validate above.

See Also

loop_perimeters The list form, which packs and then calls this. loop_directed_areas_batched The vector measure of the same packed loops.

Source code in triwarp/boundary.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
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
def loop_perimeters_batched(
    vertices: wp.array[wp.vec3],
    flat_loops: wp.array[wp.int32],
    offsets: wp.array[wp.int32],
    loop_sizes: wp.array[wp.int32],
    *,
    loop_id: wp.array[wp.int32] | None = None,
    validate: bool = True,
) -> wp.array[wp.float32]:
    """
    Perimeter of every loop, taking the loops in the packed form rather than as a list.

    Same measure as [`loop_perimeters`][triwarp.boundary.loop_perimeters] and the same launch; the
    three arguments after ``vertices`` are exactly what
    [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched] returns, in that order.
    Reach for this whenever the loops are already packed, which is the form
    [`triwarp.holes`][triwarp.holes] carries them in throughout: the list form would have to be
    split back out and repacked to be measured, and the split is a per-loop Python object.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    flat_loops
        Concatenated ordered vertex indices of every loop.
    offsets
        Length-``n_loops`` start of each loop in ``flat_loops``. Not total-terminated, matching
        ``boundary_loops_batched``.
    loop_sizes
        Length-``n_loops`` vertex count per loop.
    loop_id
        Optional precomputed length-``flat_loops`` label saying which loop each packed position
        belongs to. Built here when ``None``, which costs an allocation, a copy and a launch --
        **roughly doubling the call**, since the measure itself is one launch. Pass it when the
        caller already holds it, as [`triwarp.holes`][triwarp.holes] does.
    validate
        When ``True`` (default), check that ``offsets`` and ``loop_sizes`` agree in length and that
        every ``(offset, size)`` pair stays within ``flat_loops`` before launching -- a real cost (a
        host readback of both arrays), paid because a hand-built or stale packed triple otherwise
        drives the kernel's per-loop index past ``flat_loops``'s end with no exception raised. Pass
        ``False`` only when the triple is known correct by construction, as
        [`triwarp.holes`][triwarp.holes]'s internal packing already is.

    Returns
    -------
    wp.array[wp.float32]
        One perimeter per loop, in the packed order, on ``vertices.device``.

    Raises
    ------
    RuntimeError
        If ``vertices``, ``flat_loops``, ``offsets``, ``loop_sizes`` and ``loop_id`` are not all on
        one device.
    ValueError
        If ``validate`` and the packed triple is malformed -- see ``validate`` above.

    See Also
    --------
    [`loop_perimeters`][triwarp.boundary.loop_perimeters]
        The list form, which packs and then calls this.
    [`loop_directed_areas_batched`][triwarp.boundary.loop_directed_areas_batched]
        The vector measure of the same packed loops.
    """
    require_same_device(
        vertices=vertices,
        flat_loops=flat_loops,
        offsets=offsets,
        loop_sizes=loop_sizes,
        loop_id=loop_id,
    )
    if validate:
        _validate_packed_loops(flat_loops, offsets, loop_sizes, "loop_perimeters_batched")
    return _launch_loop_measure(
        kernel_boundary.loop_perimeters,
        wp.float32,
        vertices,
        flat_loops,
        _loop_owner_labels(flat_loops, offsets, loop_sizes) if loop_id is None else loop_id,
        offsets,
        loop_sizes,
        int(loop_sizes.shape[0]),
    )

loop_directed_areas

loop_directed_areas(
    vertices: array[vec3], loops: Sequence[array[int32]]
) -> wp.array[wp.vec3]

Directed area vector of every loop, in one launch.

Half the sum of p_i x p_{i+1} around each cycle. Its norm is the area of the planar polygon the loop spans and its direction is that polygon's normal, oriented by the loop's own winding -- so it answers "how big is this hole" and "which way does it face" at once, and the sign flips if the loop is reversed. A vector rather than a scalar for that reason: the area alone loses the orientation, which is what tells an outer boundary from an inner one.

Origin-independent, because the cross products of a closed ring cancel any shift of the origin, so no centroid pass is needed. Exact for a planar loop; for a non-planar one it is the area of the loop's projection onto the plane normal to the result, which is the standard convention.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
loops Sequence[array[int32]]

Closed vertex-index cycles, as boundary_loops returns.

required

Returns:

Type Description
array[vec3]

One directed area per loop, in the order given, on vertices.device.

Raises:

Type Description
TypeError

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

RuntimeError

If vertices and loops are not all on one device.

See Also

loop_perimeters The scalar measure of the same loops. polyline_normal The single-loop direction, normalized and over positions.

Source code in triwarp/boundary.py
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
def loop_directed_areas(
    vertices: wp.array[wp.vec3], loops: Sequence[wp.array[wp.int32]]
) -> wp.array[wp.vec3]:
    """
    Directed area vector of every loop, in one launch.

    Half the sum of ``p_i x p_{i+1}`` around each cycle. Its **norm** is the area of the planar
    polygon the loop spans and its **direction** is that polygon's normal, oriented by the loop's
    own winding -- so it answers "how big is this hole" and "which way does it face" at once, and
    the sign flips if the loop is reversed. A vector rather than a scalar for that reason: the area
    alone loses the orientation, which is what tells an outer boundary from an inner one.

    Origin-independent, because the cross products of a closed ring cancel any shift of the origin,
    so no centroid pass is needed. Exact for a planar loop; for a non-planar one it is the area of
    the loop's projection onto the plane normal to the result, which is the standard convention.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    loops
        Closed vertex-index cycles, as [`boundary_loops`][triwarp.boundary.boundary_loops] returns.

    Returns
    -------
    wp.array[wp.vec3]
        One directed area per loop, in the order given, on ``vertices.device``.

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

    See Also
    --------
    [`loop_perimeters`][triwarp.boundary.loop_perimeters]
        The scalar measure of the same loops.
    [`polyline_normal`][triwarp.polyline.polyline_normal]
        The single-loop direction, normalized and over positions.
    """
    require_same_device(vertices=vertices, loops=loops)
    packed = _pack_loop_segments(vertices, loops)
    if packed is None:
        return wp.empty(0, dtype=wp.vec3, device=vertices.device)
    flat_loops, loop_id, starts, sizes, n_loops = packed
    return _launch_loop_measure(
        kernel_boundary.loop_directed_areas,
        wp.vec3,
        vertices,
        flat_loops,
        loop_id,
        starts,
        sizes,
        n_loops,
    )

loop_directed_areas_batched

loop_directed_areas_batched(
    vertices: array[vec3],
    flat_loops: array[int32],
    offsets: array[int32],
    loop_sizes: array[int32],
    *,
    loop_id: array[int32] | None = None,
    validate: bool = True,
) -> wp.array[wp.vec3]

Directed area vector of every loop, taking the loops in the packed form rather than as a list.

The packed counterpart of loop_directed_areas, exactly as loop_perimeters_batched is of loop_perimeters; the arguments after vertices are what boundary_loops_batched returns.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions.

required
flat_loops array[int32]

Concatenated ordered vertex indices of every loop.

required
offsets array[int32]

Length-n_loops start of each loop in flat_loops, not total-terminated.

required
loop_sizes array[int32]

Length-n_loops vertex count per loop.

required
loop_id array[int32] | None

Optional precomputed length-flat_loops label saying which loop each packed position belongs to. Built here when None, which costs an allocation, a copy and a launch -- roughly doubling the call, since the measure itself is one launch. Pass it when the caller already holds it, as triwarp.holes does.

None
validate bool

When True (default), check that offsets and loop_sizes agree in length and that every (offset, size) pair stays within flat_loops before launching -- see loop_perimeters_batched's docstring for the cost and the reasoning.

True

Returns:

Type Description
array[vec3]

One directed area per loop, in the packed order, on vertices.device.

Raises:

Type Description
RuntimeError

If vertices, flat_loops, offsets, loop_sizes and loop_id are not all on one device.

ValueError

If validate and the packed triple is malformed -- see validate above.

See Also

loop_directed_areas The list form, which packs and then calls this. loop_perimeters_batched The scalar measure of the same packed loops.

Source code in triwarp/boundary.py
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
def loop_directed_areas_batched(
    vertices: wp.array[wp.vec3],
    flat_loops: wp.array[wp.int32],
    offsets: wp.array[wp.int32],
    loop_sizes: wp.array[wp.int32],
    *,
    loop_id: wp.array[wp.int32] | None = None,
    validate: bool = True,
) -> wp.array[wp.vec3]:
    """
    Directed area vector of every loop, taking the loops in the packed form rather than as a list.

    The packed counterpart of
    [`loop_directed_areas`][triwarp.boundary.loop_directed_areas], exactly as
    [`loop_perimeters_batched`][triwarp.boundary.loop_perimeters_batched] is of
    [`loop_perimeters`][triwarp.boundary.loop_perimeters]; the arguments after ``vertices`` are what
    [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched] returns.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions.
    flat_loops
        Concatenated ordered vertex indices of every loop.
    offsets
        Length-``n_loops`` start of each loop in ``flat_loops``, not total-terminated.
    loop_sizes
        Length-``n_loops`` vertex count per loop.
    loop_id
        Optional precomputed length-``flat_loops`` label saying which loop each packed position
        belongs to. Built here when ``None``, which costs an allocation, a copy and a launch --
        **roughly doubling the call**, since the measure itself is one launch. Pass it when the
        caller already holds it, as [`triwarp.holes`][triwarp.holes] does.
    validate
        When ``True`` (default), check that ``offsets`` and ``loop_sizes`` agree in length and that
        every ``(offset, size)`` pair stays within ``flat_loops`` before launching -- see
        [`loop_perimeters_batched`][triwarp.boundary.loop_perimeters_batched]'s docstring for the
        cost and the reasoning.

    Returns
    -------
    wp.array[wp.vec3]
        One directed area per loop, in the packed order, on ``vertices.device``.

    Raises
    ------
    RuntimeError
        If ``vertices``, ``flat_loops``, ``offsets``, ``loop_sizes`` and ``loop_id`` are not all on
        one device.
    ValueError
        If ``validate`` and the packed triple is malformed -- see ``validate`` above.

    See Also
    --------
    [`loop_directed_areas`][triwarp.boundary.loop_directed_areas]
        The list form, which packs and then calls this.
    [`loop_perimeters_batched`][triwarp.boundary.loop_perimeters_batched]
        The scalar measure of the same packed loops.
    """
    require_same_device(
        vertices=vertices,
        flat_loops=flat_loops,
        offsets=offsets,
        loop_sizes=loop_sizes,
        loop_id=loop_id,
    )
    if validate:
        _validate_packed_loops(flat_loops, offsets, loop_sizes, "loop_directed_areas_batched")
    return _launch_loop_measure(
        kernel_boundary.loop_directed_areas,
        wp.vec3,
        vertices,
        flat_loops,
        _loop_owner_labels(flat_loops, offsets, loop_sizes) if loop_id is None else loop_id,
        offsets,
        loop_sizes,
        int(loop_sizes.shape[0]),
    )

longest_boundary_loop

longest_boundary_loop(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
    edges: Array2dInt32 | None = None,
) -> wp.array[wp.int32]

Ordered vertex-index loop along the longest mesh boundary.

The name carries the longest, because the plural boundary_loops returns every one and the two used to differ by a single character.

Parameters:

Name Type Description Default
vertices array[vec3]

Forwarded to boundary_loops_batched.

required
faces array[vec3]

Forwarded to boundary_loops_batched.

required
edges_sorted array[vec3]

Forwarded to boundary_loops_batched.

required
edges array[vec3]

Forwarded to boundary_loops_batched.

required

Returns:

Type Description
array[int32]

Ordered vertex indices around the longest boundary loop, on faces.device. An independent buffer, not a view into the packed result. Empty when the mesh has no boundary.

Raises:

Type Description
RuntimeError

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

See Also

boundary_loops Every loop, not only the longest. boundary_loops_batched igl.boundary_loop

Source code in triwarp/boundary.py
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
def longest_boundary_loop(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    edges: twt.Array2dInt32 | None = None,
) -> wp.array[wp.int32]:
    """
    Ordered vertex-index loop along the longest mesh boundary.

    The name carries the *longest*, because the plural
    [`boundary_loops`][triwarp.boundary.boundary_loops] returns every one and the two used to differ
    by a single character.

    Parameters
    ----------
    vertices, faces, edges_sorted, edges
        Forwarded to [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched].

    Returns
    -------
    wp.array[wp.int32]
        Ordered vertex indices around the longest boundary loop, on ``faces.device``. An
        independent buffer, not a view into the packed result. Empty when the mesh has no
        boundary.

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

    See Also
    --------
    [`boundary_loops`][triwarp.boundary.boundary_loops]
        Every loop, not only the longest.
    [`boundary_loops_batched`][triwarp.boundary.boundary_loops_batched]
    ``igl.boundary_loop``
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted, edges=edges)
    flat_loops, offsets, _loop_sizes = boundary_loops_batched(vertices, faces, edges_sorted, edges)
    loops = tw.array.split(flat_loops, offsets)
    if not loops:
        return wp.empty(0, dtype=wp.int32, device=faces.device)
    # Only the winner is materialized: the other loops stay views into the packed buffer.
    return wp.clone(max(loops, key=lambda loop: int(loop.shape[0])))

boundary_vertex_indices

boundary_vertex_indices(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
) -> wp.array[wp.int32]

Sorted unique vertex indices lying on the mesh boundary.

A vertex belongs to the boundary when it belongs to at least one boundary edge.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions; only the count is used (as the row-hash base).

required
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed sorted edges, forwarded to boundary_edges.

None

Returns:

Type Description
array[int32]

Sorted unique boundary vertex indices on faces.device. Empty when the mesh has no boundary.

Raises:

Type Description
RuntimeError

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

Source code in triwarp/boundary.py
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
def boundary_vertex_indices(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
) -> wp.array[wp.int32]:
    """
    Sorted unique vertex indices lying on the mesh boundary.

    A vertex belongs to the boundary when it belongs to at least one boundary edge.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions; only the count is used (as the row-hash base).
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed sorted edges, forwarded to
        [`boundary_edges`][triwarp.boundary.boundary_edges].

    Returns
    -------
    wp.array[wp.int32]
        Sorted unique boundary vertex indices on ``faces.device``. Empty when the mesh has
        no boundary.

    Raises
    ------
    RuntimeError
        If ``vertices``, ``faces`` and ``edges_sorted`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted)
    edges = boundary_edges(vertices, faces, edges_sorted)
    return tw.grouping.unique_1d(edges.flatten())

boundary_vertices

boundary_vertices(
    vertices: array[vec3],
    faces: array[int32],
    edges_sorted: Array2dInt32 | None = None,
) -> wp.array[wp.vec3]

Coordinates of the vertices lying on the mesh boundary.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) vertex positions.

required
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed sorted edges, forwarded to boundary_vertex_indices.

None

Returns:

Type Description
array[vec3]

Shape (n_boundary_vertices,) boundary vertex positions on vertices.device, ordered by ascending vertex index. Empty when the mesh has no boundary.

Raises:

Type Description
RuntimeError

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

Source code in triwarp/boundary.py
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
def boundary_vertices(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
) -> wp.array[wp.vec3]:
    """
    Coordinates of the vertices lying on the mesh boundary.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` vertex positions.
    faces
        Length-``3 * n_faces`` ``wp.int32`` face index buffer.
    edges_sorted
        Optional precomputed sorted edges, forwarded to
        [`boundary_vertex_indices`][triwarp.boundary.boundary_vertex_indices].

    Returns
    -------
    wp.array[wp.vec3]
        Shape ``(n_boundary_vertices,)`` boundary vertex positions on ``vertices.device``,
        ordered by ascending vertex index. Empty when the mesh has no boundary.

    Raises
    ------
    RuntimeError
        If ``vertices``, ``faces`` and ``edges_sorted`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, edges_sorted=edges_sorted)
    indices = boundary_vertex_indices(vertices, faces, edges_sorted)
    return tw.array.gather(vertices, indices)

ears

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

Find ear faces (triangles with exactly two boundary edges).

For each ear face, ear_opp is the local index of the non-boundary edge, where local edge i is (faces[f, i], faces[f, (i + 1) % 3]).

igl::ears numbers the edge differently

The same quantity in igl.ears is indexed opposite-vertex style -- edge i is the one facing vertex i, (faces[f, (i + 1) % 3], faces[f, (i + 2) % 3]) -- because it reads its mask from igl::on_boundary, whose columns are documented that way. The two agree on which faces are ears and differ on the index by a cyclic shift: triwarp_opp == (igl_opp + 1) % 3.

Parameters:

Name Type Description Default
faces array[int32]

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

required
edges_sorted Array2dInt32 | None

Optional precomputed (n_faces * 3, 2) sorted edges (each row min-first), as from faces_to_edges with sorted=True. Built from faces when None.

None
n_vertices int | None

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

None

Returns:

Name Type Description
ear array[int32]

Face indices of ear triangles on faces.device. Empty when no ears exist.

ear_opp array[int32]

Local edge index of the interior edge for each ear face, same length as ear.

Raises:

Type Description
RuntimeError

If faces and edges_sorted are not all on one device.

See Also

igl.ears

Source code in triwarp/boundary.py
 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
def ears(
    faces: wp.array[wp.int32],
    edges_sorted: twt.Array2dInt32 | None = None,
    n_vertices: int | None = None,
) -> tuple[wp.array[wp.int32], wp.array[wp.int32]]:
    """
    Find ear faces (triangles with exactly two boundary edges).

    For each ear face, ``ear_opp`` is the local index of the non-boundary edge, where local edge
    ``i`` is ``(faces[f, i], faces[f, (i + 1) % 3])``.

    !!! note "``igl::ears`` numbers the edge differently"

        The same quantity in ``igl.ears`` is indexed *opposite-vertex* style -- edge ``i`` is the
        one facing vertex ``i``, ``(faces[f, (i + 1) % 3], faces[f, (i + 2) % 3])`` -- because it
        reads its mask from ``igl::on_boundary``, whose columns are documented that way. The two
        agree on *which* faces are ears and differ on the index by a cyclic shift:
        ``triwarp_opp == (igl_opp + 1) % 3``.

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

    Returns
    -------
    ear : wp.array[wp.int32]
        Face indices of ear triangles on ``faces.device``. Empty when no ears exist.
    ear_opp : wp.array[wp.int32]
        Local edge index of the interior edge for each ear face, same length as ``ear``.

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

    See Also
    --------
    ``igl.ears``
    """
    require_same_device(faces=faces, edges_sorted=edges_sorted)
    n_faces = int(faces.shape[0]) // 3
    device = faces.device
    empty = wp.empty(0, dtype=wp.int32, device=device)
    if n_faces == 0:
        return empty, empty

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

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

    boundary_rows = _boundary_rows(n_vertices, edges_sorted)
    edge_boundary = tw.array.indices_to_mask(boundary_rows, n_faces * 3, device=device)

    out_ear = wp.empty(n_faces, dtype=wp.int32, device=device)
    out_ear_opp = wp.empty(n_faces, dtype=wp.int32, device=device)
    counter = wp.zeros(1, dtype=wp.int32, device=device)
    wp.launch(
        kernel_boundary.find_ears,
        dim=n_faces,
        inputs=[edge_boundary, out_ear, out_ear_opp, counter],
        device=device,
    )

    n_ears, (ear, ear_opp) = tw.array.trim_to_count(counter, out_ear, out_ear_opp)
    if n_ears == 0:
        return empty, empty
    return ear, ear_opp