Skip to content

grouping

triwarp.grouping

Index grouping by equal value/row, plus value and row deduplication for Warp arrays.

Scalar module-attribute

Scalar = TypeVar('Scalar', bound=wp.Scalar)

group

group(values: array[Int], length: int) -> twt.Array2dInt32

Return index groups of exactly length entries that share the same value.

values are radix-sorted with their original indices; each output row lists length indices whose corresponding entries are equal and form a run of precisely that size (runs shorter or longer than length are omitted). This matches trimesh.grouping.group with min_len == max_len == length and trimesh.grouping.group_rows with require_count=length.

Parameters:

Name Type Description Default
values array[Int]

(n,) device array of integer keys (any wp.Int dtype).

required
length int

Required run length; each returned group contains exactly this many indices.

required

Returns:

Type Description
Array2dInt32

(g, length) array on values.device where g is the number of groups found. Empty when no run has exactly length equal neighbors.

See Also

group_int_rows

Source code in triwarp/grouping.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def group(values: wp.array[wp.Int], length: int) -> twt.Array2dInt32:
    """
    Return index groups of exactly ``length`` entries that share the same value.

    ``values`` are radix-sorted with their original indices; each output row lists
    ``length`` indices whose corresponding entries are equal and form a run of
    precisely that size (runs shorter or longer than ``length`` are omitted). This
    matches [`trimesh.grouping.group`][] with ``min_len == max_len == length`` and
    [`trimesh.grouping.group_rows`][] with ``require_count=length``.

    Parameters
    ----------
    values
        ``(n,)`` device array of integer keys (any ``wp.Int`` dtype).
    length
        Required run length; each returned group contains exactly this many indices.

    Returns
    -------
    twt.Array2dInt32
        ``(g, length)`` array on ``values.device`` where ``g`` is the number of
        groups found. Empty when no run has exactly ``length`` equal neighbors.

    See Also
    --------
    [`group_int_rows`][triwarp.grouping.group_int_rows]
    """
    n = int(values.shape[0])
    device = values.device
    if n < length or length <= 0:
        return twt.as_array2d(twt.empty_2d((0, max(length, 0)), wp.int32, device=device), wp.int32)

    sort_dtype = twt.sortable_dtype(values.dtype)
    values_buffer = wp.empty(2 * n, dtype=sort_dtype, device=device)
    if sort_dtype == values.dtype:
        wp.copy(values_buffer, values, count=n)
    else:
        wp.utils.array_cast(values, values_buffer, count=n)
    indices_buffer = sort_pair_indices(n, -1, device)
    wp.utils.radix_sort_pairs(values_buffer, indices_buffer, count=n)

    # Scan + scatter compaction: mark run starts, compact them with flatnonzero, then emit one
    # right-sized row per group (deterministic ascending-value order, no atomic counter and no
    # (n, length) over-allocation).
    is_start = wp.empty(n, dtype=wp.bool, device=device)
    wp.launch(
        kernel_grouping.MARK_GROUP_STARTS[values_buffer.dtype],
        dim=n,
        inputs=[values_buffer, wp.int32(n), wp.int32(length), is_start],
        device=device,
    )
    starts = tw.array.flatnonzero(is_start)
    n_groups = int(starts.shape[0])
    if n_groups == 0:
        return twt.as_array2d(twt.empty_2d((0, length), wp.int32, device=device), wp.int32)
    groups = twt.empty_2d((n_groups, length), wp.int32, device=device)
    wp.launch(
        kernel_grouping.emit_groups,
        dim=n_groups,
        inputs=[starts, indices_buffer, groups],
        device=device,
    )
    return twt.as_array2d(groups, wp.int32)

group_int_rows

group_int_rows(
    data: Array2dInt,
    length: int,
    max_value: int | None = None,
    *,
    validate: bool = True,
) -> twt.Array2dInt32

Return index groups of exactly length rows that are identical.

Each row is hashed with hash_indices_rows, then group finds runs of length equal keys in sorted order. For example, [[1, 2], [3, 4], [1, 2]] with length=2 yields one group [[0, 2]] (same as trimesh.grouping.group_rows with require_count=2).

Parameters:

Name Type Description Default
data Array2dInt

(n, w) device array of non-negative int32 row values.

required
length int

Required number of duplicate rows per group.

required
max_value int | None

Optional exclusive upper bound on entries and radix for row hashing; passed through to hash_indices_rows as max_index. If None, inferred from max(data) + 1.

None
validate bool

Forwarded to hash_indices_rows. False skips the range-check reduction (and its host readback) and requires max_value; see the warning there before using it.

True

Returns:

Type Description
Array2dInt32

(g, length) array on data.device with original row indices per group.

See Also

group

Source code in triwarp/grouping.py
 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
113
114
115
116
117
118
119
120
121
122
123
124
def group_int_rows(
    data: twt.Array2dInt, length: int, max_value: int | None = None, *, validate: bool = True
) -> twt.Array2dInt32:
    """
    Return index groups of exactly ``length`` rows that are identical.

    Each row is hashed with [`hash_indices_rows`][triwarp.grouping.hash_indices_rows], then
    [`group`][triwarp.grouping.group] finds runs of ``length`` equal keys in sorted order. For
    example, ``[[1, 2], [3, 4], [1, 2]]`` with ``length=2`` yields one group ``[[0, 2]]`` (same as
    [`trimesh.grouping.group_rows`][] with ``require_count=2``).

    Parameters
    ----------
    data
        ``(n, w)`` device array of non-negative ``int32`` row values.
    length
        Required number of duplicate rows per group.
    max_value
        Optional exclusive upper bound on entries and radix for row hashing; passed
        through to [`hash_indices_rows`][triwarp.grouping.hash_indices_rows] as ``max_index``. If
        ``None``, inferred
        from ``max(data) + 1``.
    validate
        Forwarded to [`hash_indices_rows`][triwarp.grouping.hash_indices_rows]. ``False`` skips the
        range-check reduction (and its host readback) and requires ``max_value``; see the warning
        there before using it.

    Returns
    -------
    twt.Array2dInt32
        ``(g, length)`` array on ``data.device`` with original row indices per group.

    See Also
    --------
    [`group`][triwarp.grouping.group]
    """
    twt.ensure_ndim(data, 2)
    hashed_rows = hash_indices_rows(data, max_value, validate=validate)
    return group(hashed_rows, length)

unique_1d

unique_1d(
    data: array[Scalar],
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[False] = False,
) -> wp.array[Scalar]
unique_1d(
    data: array[Scalar],
    *,
    return_inverse: Literal[True],
    return_counts: Literal[False] = False,
) -> tuple[wp.array[Scalar], wp.array[wp.int32]]
unique_1d(
    data: array[Scalar],
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[True],
) -> tuple[wp.array[Scalar], wp.array[wp.int32]]
unique_1d(
    data: array[Scalar],
    *,
    return_inverse: Literal[True],
    return_counts: Literal[True],
) -> tuple[
    wp.array[Scalar], wp.array[wp.int32], wp.array[wp.int32]
]
unique_1d(
    data: array[Scalar],
    *,
    return_inverse: bool = False,
    return_counts: bool = False,
) -> (
    wp.array[Scalar]
    | tuple[wp.array[Scalar], wp.array[wp.int32]]
    | tuple[
        wp.array[Scalar],
        wp.array[wp.int32],
        wp.array[wp.int32],
    ]
)

Find sorted unique elements of a 1D Warp array (numpy.unique subset).

Uses an open-addressing hash table: O(n) inserts plus an O(n_unique log n_unique) sort of only the unique values.

return_inverse maps each input position to the index of its value in the sorted unique output. return_index is not supported.

Parameters:

Name Type Description Default
data array[Scalar]

Rank-1 wp.array with any scalar dtype.

required
return_inverse bool

If True, include the inverse mapping in the return tuple.

False
return_counts bool

If True, include per-unique occurrence counts in the return tuple.

False

Returns:

Name Type Description
unique array

Sorted unique values (same dtype and device as data).

inverse (array[int32], optional)

Present if return_inverse=True. inverse[i] indexes unique such that conceptually unique[inverse[i]] == data[i] (float NaN matches NumPy: all NaN values share one slot).

counts (array[int32], optional)

Present if return_counts=True. Number of occurrences per unique row.

Raises:

Type Description
ValueError

If data is not rank-1 or length is > 2**30.

Source code in triwarp/grouping.py
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
def unique_1d(
    data: wp.array[Scalar], *, return_inverse: bool = False, return_counts: bool = False
) -> (
    wp.array[Scalar]
    | tuple[wp.array[Scalar], wp.array[wp.int32]]
    | tuple[wp.array[Scalar], wp.array[wp.int32], wp.array[wp.int32]]
):
    """
    Find sorted unique elements of a 1D Warp array (``numpy.unique`` subset).

    Uses an open-addressing hash table: O(n) inserts plus an O(n_unique log n_unique) sort of only
    the unique values.

    ``return_inverse`` maps each input position to the index of its value in the sorted unique
    output.
    ``return_index`` is not supported.

    Parameters
    ----------
    data
        Rank-1 ``wp.array`` with any scalar ``dtype``.
    return_inverse
        If ``True``, include the inverse mapping in the return tuple.
    return_counts
        If ``True``, include per-unique occurrence counts in the return tuple.

    Returns
    -------
    unique : wp.array
        Sorted unique values (same ``dtype`` and ``device`` as ``data``).
    inverse : wp.array[wp.int32], optional
        Present if ``return_inverse=True``. ``inverse[i]`` indexes ``unique`` such
        that conceptually ``unique[inverse[i]] == data[i]`` (float ``NaN`` matches
        NumPy: all ``NaN`` values share one slot).
    counts : wp.array[wp.int32], optional
        Present if ``return_counts=True``. Number of occurrences per ``unique`` row.

    Raises
    ------
    ValueError
        If ``data`` is not rank-1 or length is ``> 2**30``.
    """
    if int(data.ndim) != 1:
        raise ValueError(f"unique_1d expects a rank-1 array, got ndim={data.ndim}")

    device = data.device
    n = int(data.shape[0])

    if n == 0:
        empty_unique = wp.empty(0, dtype=data.dtype, device=device)
        empty_i32 = wp.empty(0, dtype=wp.int32, device=device)
        return _pack_unique_result(
            empty_unique,
            inverse=empty_i32 if return_inverse else None,
            counts=empty_i32 if return_counts else None,
        )
    if n > (1 << 30):
        raise ValueError(f"unique_1d requires length <= 2**30, got length {n}")

    log2_capacity = max(3, math.ceil(math.log2(n) + 1))
    mask = wp.int32((1 << log2_capacity) - 1)

    # ``_unique_hash`` only ever *reads* the integer key array, so when the input already is one of
    # the two key dtypes the bit reinterpretation is the identity and the buffer can be shared --
    # skipping a full-length copy and its allocation. Every other dtype still needs the real
    # conversion.
    data_int = data if data.dtype in (wp.int32, wp.int64) else bitcast_to_int(data, n)
    return _unique_hash(data, data_int, data.dtype, n, mask, return_inverse, return_counts)

unique_rows

unique_rows(
    data: array[vec3],
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[False] = False,
) -> wp.array[wp.vec3]
unique_rows(
    data: array[vec3],
    *,
    return_inverse: Literal[True],
    return_counts: Literal[False] = False,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]
unique_rows(
    data: array[vec3],
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[True],
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]
unique_rows(
    data: array[vec3],
    *,
    return_inverse: Literal[True],
    return_counts: Literal[True],
) -> tuple[
    wp.array[wp.vec3],
    wp.array[wp.int32],
    wp.array[wp.int32],
]
unique_rows(
    data: Array2dInt32,
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[False] = False,
) -> twt.Array2dInt32
unique_rows(
    data: Array2dInt32,
    *,
    return_inverse: Literal[True],
    return_counts: Literal[False] = False,
) -> tuple[twt.Array2dInt32, wp.array[wp.int32]]
unique_rows(
    data: Array2dInt32,
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[True],
) -> tuple[twt.Array2dInt32, wp.array[wp.int32]]
unique_rows(
    data: Array2dInt32,
    *,
    return_inverse: Literal[True],
    return_counts: Literal[True],
) -> tuple[
    twt.Array2dInt32, wp.array[wp.int32], wp.array[wp.int32]
]
unique_rows(
    data: Array2dFloat32,
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[False] = False,
) -> twt.Array2dFloat32
unique_rows(
    data: Array2dFloat32,
    *,
    return_inverse: Literal[True],
    return_counts: Literal[False] = False,
) -> tuple[twt.Array2dFloat32, wp.array[wp.int32]]
unique_rows(
    data: Array2dFloat32,
    *,
    return_inverse: Literal[False] = False,
    return_counts: Literal[True],
) -> tuple[twt.Array2dFloat32, wp.array[wp.int32]]
unique_rows(
    data: Array2dFloat32,
    *,
    return_inverse: Literal[True],
    return_counts: Literal[True],
) -> tuple[
    twt.Array2dFloat32,
    wp.array[wp.int32],
    wp.array[wp.int32],
]
unique_rows(
    data: array,
    *,
    return_inverse: bool = False,
    return_counts: bool = False,
) -> (
    wp.array
    | tuple[wp.array, wp.array[wp.int32]]
    | tuple[
        wp.array, wp.array[wp.int32], wp.array[wp.int32]
    ]
)

Find unique rows of a 2D Warp array (numpy.unique along axis 0).

Each row is hashed with hash_rows, then deduplicated via the same open-addressing hash table as unique_1d. Unique rows are returned in sorted hash-key order; the representative row for each key is the first input row in that equivalence class.

return_inverse maps each input row index to its slot in the sorted unique output. return_index is not supported.

Parameters:

Name Type Description Default
data array

(n, w) int32 or float32 array, or length-n wp.vec3 array.

required
return_inverse bool

If True, include the inverse mapping in the return tuple.

False
return_counts bool

If True, include per-unique occurrence counts in the return tuple.

False

Returns:

Name Type Description
unique

(n_unique, w) or length-n_unique wp.vec3 array on data.device.

inverse (array[int32], optional)

Present if return_inverse=True. inverse[i] indexes unique.

counts (array[int32], optional)

Present if return_counts=True. Occurrences per unique row.

Raises:

Type Description
ValueError

If data is not rank-2 int32/float32 or rank-1 wp.vec3.

Source code in triwarp/grouping.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
def unique_rows(
    data: wp.array, *, return_inverse: bool = False, return_counts: bool = False
) -> (
    wp.array
    | tuple[wp.array, wp.array[wp.int32]]
    | tuple[wp.array, wp.array[wp.int32], wp.array[wp.int32]]
):
    """
    Find unique rows of a 2D Warp array (``numpy.unique`` along axis 0).

    Each row is hashed with [`hash_rows`][triwarp.grouping.hash_rows], then deduplicated via the
    same open-addressing hash table as [`unique_1d`][triwarp.grouping.unique_1d]. Unique rows are
    returned in
    sorted hash-key order; the representative row for each key is the first input
    row in that equivalence class.

    ``return_inverse`` maps each input row index to its slot in the sorted unique
    output. ``return_index`` is not supported.

    Parameters
    ----------
    data
        ``(n, w)`` ``int32`` or ``float32`` array, or length-``n`` ``wp.vec3`` array.
    return_inverse
        If ``True``, include the inverse mapping in the return tuple.
    return_counts
        If ``True``, include per-unique occurrence counts in the return tuple.

    Returns
    -------
    unique
        ``(n_unique, w)`` or length-``n_unique`` ``wp.vec3`` array on ``data.device``.
    inverse : wp.array[wp.int32], optional
        Present if ``return_inverse=True``. ``inverse[i]`` indexes ``unique``.
    counts : wp.array[wp.int32], optional
        Present if ``return_counts=True``. Occurrences per ``unique`` row.

    Raises
    ------
    ValueError
        If ``data`` is not rank-2 ``int32``/``float32`` or rank-1 ``wp.vec3``.
    """
    device = data.device
    n = int(data.shape[0])
    is_vec3 = data.dtype == wp.vec3

    if n == 0:
        if is_vec3:
            empty_unique = wp.empty(0, dtype=wp.vec3, device=device)
        else:
            # Same dtype check ``hash_rows`` performs on the non-empty path below, so a caller who
            # only ever exercises this function on empty input still gets the documented raise
            # rather than a silently wrong output dtype.
            twt.ensure_ndim(data, 2)
            if data.dtype == wp.int32:
                empty_unique = twt.empty_2d((0, int(data.shape[1])), wp.int32, device=device)
            elif data.dtype == wp.float32:
                empty_unique = twt.empty_2d((0, int(data.shape[1])), wp.float32, device=device)
            else:
                raise ValueError(f"unique_rows unsupported dtype {data.dtype}")
        empty_i32 = wp.empty(0, dtype=wp.int32, device=device)
        return _pack_unique_result(
            empty_unique,
            inverse=empty_i32 if return_inverse else None,
            counts=empty_i32 if return_counts else None,
        )

    _unique_keys, inverse, first_idx, counts = _unique_rows_core(data, return_counts=return_counts)

    if not is_vec3:
        twt.ensure_ndim(data, 2)
    # ``gather`` performs both rank-1 (vec3) and rank-2 (int32/float32 row) gather.
    unique_rows_out = gather(data, first_idx)

    if not return_inverse:
        inverse = None
    return _pack_unique_result(unique_rows_out, inverse=inverse, counts=counts)

unique_faces

unique_faces(
    faces: array[int32],
    *,
    return_inverse: Literal[False] = False,
) -> wp.array[wp.int32]
unique_faces(
    faces: array[int32], *, return_inverse: Literal[True]
) -> tuple[wp.array[wp.int32], wp.array[wp.int32]]
unique_faces(
    faces: array[int32], *, return_inverse: bool = False
) -> (
    wp.array[wp.int32]
    | tuple[wp.array[wp.int32], wp.array[wp.int32]]
)

Find unique triangular faces up to vertex permutation (orientation-agnostic).

Two faces are equal when they share the same three vertices regardless of order. Each face's indices are sorted before deduplication; the representative returned for a class is the first occurring face, with its original vertex order preserved.

Parameters:

Name Type Description Default
faces array[int32]

Flat wp.int32 triangle index buffer of length 3 * n_faces (common 1D format).

required
return_inverse bool

If True, also return the inverse mapping from each input face to its slot in the unique output.

False

Returns:

Name Type Description
unique_faces array[int32]

Flat buffer of the unique faces (length 3 * n_unique).

inverse (array[int32], optional)

Present if return_inverse=True. Length n_faces; inverse[i] is the unique slot of input face i.

Source code in triwarp/grouping.py
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
def unique_faces(
    faces: wp.array[wp.int32], *, return_inverse: bool = False
) -> wp.array[wp.int32] | tuple[wp.array[wp.int32], wp.array[wp.int32]]:
    """
    Find unique triangular faces up to vertex permutation (orientation-agnostic).

    Two faces are equal when they share the same three vertices regardless of order. Each
    face's indices are sorted before deduplication; the representative returned for a class is
    the first occurring face, with its original vertex order preserved.

    Parameters
    ----------
    faces
        Flat ``wp.int32`` triangle index buffer of length ``3 * n_faces`` (common 1D format).
    return_inverse
        If ``True``, also return the inverse mapping from each input face to its slot in the
        unique output.

    Returns
    -------
    unique_faces : wp.array[wp.int32]
        Flat buffer of the unique faces (length ``3 * n_unique``).
    inverse : wp.array[wp.int32], optional
        Present if ``return_inverse=True``. Length ``n_faces``; ``inverse[i]`` is the unique
        slot of input face ``i``.
    """
    device = faces.device
    n_faces = int(faces.shape[0]) // 3
    if n_faces == 0:
        empty_faces = wp.empty(0, dtype=wp.int32, device=device)
        if return_inverse:
            return empty_faces, wp.empty(0, dtype=wp.int32, device=device)
        return empty_faces

    faces2d = faces.reshape((-1, 3))
    sorted_faces = twt.empty_2d((n_faces, 3), wp.int32, device=device)
    wp.launch(
        kernel_triangles.sort_face_indices,
        dim=n_faces,
        inputs=[faces2d, sorted_faces],
        device=device,
    )
    # Not ``unique_rows(sorted_faces, return_inverse=True)``: that gathers ``sorted_faces`` by the
    # first-occurrence indices to build its own return, an answer this function has no use for --
    # it gathers ``faces2d`` (the unsorted rows) by the same indices instead, to keep each
    # representative's original winding. Sharing the core skips that discarded gather and the
    # ``first_occurrence_indices`` launch it would otherwise take a second time on the identical
    # ``inverse``.
    _unique_keys, inverse, first, _counts = _unique_rows_core(sorted_faces, return_counts=False)
    unique_faces_out = gather(faces2d, first).reshape((-1,))
    if return_inverse:
        return unique_faces_out, inverse
    return unique_faces_out

first_occurrence_indices

first_occurrence_indices(
    inverse: array[int32], n_unique: int | None = None
) -> wp.array[wp.int32]

Index of the first element of each equivalence class in an inverse map.

The representative-picking half of every deduplication in this package: given the inverse that unique_1d / unique_rows return, produce for each class the smallest input index belonging to it. Gathering any per-element payload by the result yields one representative per class, taken from its first occurrence -- which is what makes unique_faces keep the original winding and edges_unique keep the first-seen edge.

Parameters:

Name Type Description Default
inverse array[int32]

Length-n wp.int32 map from each element to its class slot, as returned by unique_1d or unique_rows.

required
n_unique int | None

Number of classes (the output length). Defaults to int(reduce.max(inverse)) + 1, which costs a device reduction and a host synchronization; pass it whenever the caller already knows it. It is almost always a .shape[0] the caller is holding -- the length of the unique array that came back beside inverse.

None

Returns:

Type Description
array[int32]

Length-n_unique array whose entry c is min{i : inverse[i] == c}. A class with no member (only reachable when n_unique is passed too large) holds the sentinel n.

See Also

unique_rows unique_1d gather The companion step: gather the payload by these indices to get the representatives.

Source code in triwarp/grouping.py
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
580
581
582
583
584
585
def first_occurrence_indices(
    inverse: wp.array[wp.int32], n_unique: int | None = None
) -> wp.array[wp.int32]:
    """
    Index of the first element of each equivalence class in an ``inverse`` map.

    The representative-picking half of every deduplication in this package: given the
    ``inverse`` that ``unique_1d`` / ``unique_rows`` return, produce for each class the smallest
    input index belonging to it. Gathering any per-element payload by the result yields one
    representative per class, taken from its **first** occurrence -- which is what makes
    ``unique_faces`` keep the original winding and ``edges_unique`` keep the first-seen edge.

    Parameters
    ----------
    inverse
        Length-``n`` ``wp.int32`` map from each element to its class slot, as returned by
        [`unique_1d`][triwarp.grouping.unique_1d] or
        [`unique_rows`][triwarp.grouping.unique_rows].
    n_unique
        Number of classes (the output length). Defaults to ``int(reduce.max(inverse)) + 1``,
        which costs a device reduction **and** a host synchronization; pass it whenever the
        caller already knows it. It is almost always a ``.shape[0]`` the caller is holding --
        the length of the unique array that came back beside ``inverse``.

    Returns
    -------
    wp.array[wp.int32]
        Length-``n_unique`` array whose entry ``c`` is ``min{i : inverse[i] == c}``. A class with
        no member (only reachable when ``n_unique`` is passed too large) holds the sentinel ``n``.

    See Also
    --------
    [`unique_rows`][triwarp.grouping.unique_rows]
    [`unique_1d`][triwarp.grouping.unique_1d]
    [`gather`][triwarp.array.gather]
        The companion step: gather the payload by these indices to get the representatives.
    """
    device = inverse.device
    n = int(inverse.shape[0])
    if n_unique is None:
        n_unique = int(tw.reduce.max(inverse)) + 1 if n > 0 else 0
    first = wp.full(n_unique, n, dtype=wp.int32, device=device)
    if n > 0 and n_unique > 0:
        wp.launch(
            kernel_grouping.scatter_first_occurrence, dim=n, inputs=[inverse, first], device=device
        )
    return first

hash_rows

hash_rows(data: array[vec3]) -> wp.array[wp.uint64]
hash_rows(data: Array2dInt32) -> wp.array[wp.uint64]
hash_rows(data: Array2dFloat32) -> wp.array[wp.uint64]
hash_rows(
    data: array[vec3] | Array2dInt32 | Array2dFloat32,
) -> wp.array[wp.uint64]

Pack each row of a 2D or wp.vec3 array into a single uint64 key.

Dispatches to hash_vector_rows for wp.vec3 (including float32 arrays with width 3) or hash_indices_rows for int32 rows.

Parameters:

Name Type Description Default
data array[vec3] | Array2dInt32 | Array2dFloat32

(n, w) int32 or float32 array, or length-n wp.vec3 array.

required

Returns:

Type Description
array[uint64]

Length-n array on data.device with one packed key per row.

Raises:

Type Description
ValueError

If data has an unsupported dtype or shape.

Source code in triwarp/grouping.py
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
def hash_rows(
    data: wp.array[wp.vec3] | twt.Array2dInt32 | twt.Array2dFloat32,
) -> wp.array[wp.uint64]:
    """
    Pack each row of a 2D or ``wp.vec3`` array into a single ``uint64`` key.

    Dispatches to [`hash_vector_rows`][triwarp.grouping.hash_vector_rows] for ``wp.vec3``
    (including ``float32`` arrays with width 3) or
    [`hash_indices_rows`][triwarp.grouping.hash_indices_rows] for ``int32`` rows.

    Parameters
    ----------
    data
        ``(n, w)`` ``int32`` or ``float32`` array, or length-``n`` ``wp.vec3`` array.

    Returns
    -------
    wp.array[wp.uint64]
        Length-``n`` array on ``data.device`` with one packed key per row.

    Raises
    ------
    ValueError
        If ``data`` has an unsupported dtype or shape.
    """
    if data.dtype == wp.vec3:
        return hash_vector_rows(data)
    twt.ensure_ndim(data, 2)
    if data.dtype == wp.int32:
        return hash_indices_rows(data)
    if data.dtype == wp.float32:
        n = int(data.shape[0])
        if int(data.shape[1]) != 3:
            raise ValueError("float32 hash_rows currently requires width 3")
        vec = wp.empty(n, dtype=wp.vec3, device=data.device)
        wp.utils.array_cast(data, vec)
        return hash_vector_rows(vec)
    raise ValueError(f"hash_rows unsupported dtype {data.dtype}")

hash_vector_rows

hash_vector_rows(
    data: array[vec3], epsilon: float = 0.0
) -> wp.array[wp.uint64]

Pack each wp.vec3 row into a single uint64 key.

Two schemes, selected by epsilon:

  • epsilon > 0.0 — an absolute tolerance. Each coordinate is snapped to a multiple of epsilon measured from the data's own minimum corner, and the resulting integer row is packed via hash_indices_rows. Equal keys mean the rows landed in the same grid cell. Snapping relative to the minimum corner rather than to the coordinate origin is what lets negative coordinates work at all, and it keeps the scaled values at the size of the data's extent, where float32 still resolves epsilon.
  • epsilon == 0.0 — a relative bucket. Each coordinate's float32 bits are right-shifted by 11, so a key names an interval roughly 2 ** -12 (about 2.4e-4) wide relative to the coordinate's own magnitude. This is not an equality test in either direction, and callers that need one should pass an explicit epsilon: see Notes.

Parameters:

Name Type Description Default
data array[vec3]

(n,) device array of wp.vec3 values.

required
epsilon float

Uniqueness tolerance. 0 uses the relative bit-truncation bucket; positive values snap coordinates to round(v / epsilon) before packing.

0.0

Returns:

Type Description
array[uint64]

Length-n array on data.device with one packed key per row.

Raises:

Type Description
ValueError

If data is not a wp.array[wp.vec3].

Notes

Both schemes quantize, so both split a pair that straddles a cell boundary no matter how close the two values are. For epsilon > 0 the packing is additionally injective only while radix ** 3 fits a uint64, where radix is the widest per-axis extent in cells; beyond that the row keys wrap and behave as a hash with a small collision probability rather than an exact cell identity. The relative scheme additionally:

  • collides distinct coordinates that share a bucket — 1.0 and 1.000244 produce the same key, so a mesh whose vertex spacing is below 2.4e-4 relative is merged too aggressively;
  • has unbounded resolution approaching zero, so +1e-6 and -1e-6 are thousands of buckets apart (correct — they are distinct points), but so are +1e-40 and -1e-40, which for most purposes are the same point. Only +0.0 and -0.0 are folded together, because IEEE-754 defines them as equal.

Neither is a defect of the packing so much as the nature of a fixed-width key; pass an explicit epsilon when the tolerance has to be one you chose.

See Also

hash_indices_rows hash_rows

Source code in triwarp/grouping.py
634
635
636
637
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
716
717
718
719
720
721
def hash_vector_rows(data: wp.array[wp.vec3], epsilon: float = 0.0) -> wp.array[wp.uint64]:
    """
    Pack each ``wp.vec3`` row into a single ``uint64`` key.

    Two schemes, selected by ``epsilon``:

    - ``epsilon > 0.0`` — an **absolute** tolerance. Each coordinate is snapped to a multiple of
      ``epsilon`` measured from the data's own minimum corner, and the resulting integer row is
      packed via [`hash_indices_rows`][triwarp.grouping.hash_indices_rows]. Equal keys mean the rows
      landed in the same grid cell. Snapping relative to the minimum corner rather than to the
      coordinate origin is what lets negative coordinates work at all, and it keeps the scaled
      values at the size of the data's extent, where ``float32`` still resolves ``epsilon``.
    - ``epsilon == 0.0`` — a **relative** bucket. Each coordinate's ``float32`` bits are
      right-shifted by 11, so a key names an interval roughly ``2 ** -12`` (about ``2.4e-4``) wide
      relative to the coordinate's own magnitude. This is *not* an equality test in either
      direction, and callers that need one should pass an explicit ``epsilon``: see Notes.

    Parameters
    ----------
    data
        ``(n,)`` device array of ``wp.vec3`` values.
    epsilon
        Uniqueness tolerance. ``0`` uses the relative bit-truncation bucket; positive values snap
        coordinates to ``round(v / epsilon)`` before packing.

    Returns
    -------
    wp.array[wp.uint64]
        Length-``n`` array on ``data.device`` with one packed key per row.

    Raises
    ------
    ValueError
        If ``data`` is not a ``wp.array[wp.vec3]``.

    Notes
    -----
    Both schemes quantize, so both split a pair that straddles a cell boundary no matter how close
    the two values are. For ``epsilon > 0`` the packing is additionally injective only while
    ``radix ** 3`` fits a ``uint64``, where ``radix`` is the widest per-axis extent in cells; beyond
    that the row keys wrap and behave as a hash with a small collision probability rather than an
    exact cell identity. The relative scheme additionally:

    - **collides distinct coordinates** that share a bucket — ``1.0`` and ``1.000244`` produce the
      same key, so a mesh whose vertex spacing is below ``2.4e-4`` relative is merged too
      aggressively;
    - has *unbounded* resolution approaching zero, so ``+1e-6`` and ``-1e-6`` are thousands of
      buckets apart (correct — they are distinct points), but so are ``+1e-40`` and ``-1e-40``,
      which for most purposes are the same point. Only ``+0.0`` and ``-0.0`` are folded together,
      because IEEE-754 defines them as equal.

    Neither is a defect of the packing so much as the nature of a fixed-width key; pass an explicit
    ``epsilon`` when the tolerance has to be one you chose.

    See Also
    --------
    [`hash_indices_rows`][triwarp.grouping.hash_indices_rows]
    [`hash_rows`][triwarp.grouping.hash_rows]
    """
    if data.dtype != wp.vec3:
        raise ValueError(f"data must be a wp.array[wp.vec3], got wp.array[{data.dtype}]")
    n = int(data.shape[0])
    if epsilon > 0.0:
        if n == 0:
            return wp.empty(0, dtype=wp.uint64, device=data.device)
        # `hash_indices_rows` packs each row as digits in a positive radix, so negative cell indices
        # -- which every mesh spanning the origin produces -- cannot be packed. Snapping relative to
        # the data's own minimum corner makes them non-negative *by construction*: subtracting the
        # true minimum cannot give a negative result, so no validation pass or shift is needed. The
        # bounds also supply the radix, and doing it per component keeps that radix as small as the
        # widest single extent rather than the whole diagonal, which matters because the row packing
        # is only injective while ``radix ** 3`` fits a ``uint64``.
        min_bound, max_bound = tw.bounds.aabb(data)
        rounded = twt.empty_2d((n, 3), wp.int32, device=data.device)
        wp.launch(
            kernel_grouping.round_vec3_scaled,
            dim=n,
            inputs=[data, min_bound, wp.float32(1.0 / epsilon), rounded],
            device=data.device,
        )
        extent = max(float(max_bound[c]) - float(min_bound[c]) for c in range(3)) / epsilon
        # The device rounds a float32 product where this divides in float64; the relative slack plus
        # the half-cell of rounding covers the difference, and an over-wide radix is harmless.
        radix = int(extent * (1.0 + 1e-6)) + 3
        return hash_indices_rows(rounded, max_index=radix, validate=False)
    hashes = wp.empty(data.shape[0], dtype=wp.uint64, device=data.device)
    wp.map(kernel_grouping.pack_vec3, data, out=hashes)
    return hashes

hash_indices_rows

hash_indices_rows(
    data: Array2dInt32,
    max_index: int | None = None,
    *,
    validate: bool = True,
) -> wp.array[wp.uint64]

Pack each row of non-negative int32 values into a single uint64 key.

Each row is treated as digits in base max_index (or max(data)+1 when max_index is omitted) and combined into one integer key per row. A global min/max over data (via triwarp.reduce.minmax) validates the range and supplies the radix when it is inferred.

Parameters:

Name Type Description Default
data Array2dInt32

(n, w) device array of int32; every entry must be non-negative. If max_index is given, every entry must satisfy entry < max_index.

required
max_index int | None

Optional exclusive upper bound on entries and radix for packing; must be positive when provided. If None, set to max(data) + 1 after validation.

None
validate bool

When True (default), a triwarp.reduce.minmax over data checks that entries are non-negative and below max_index. That reduction ends in a host readback, which serialises the device pipeline — measurably so when this is called once per pass inside a remeshing loop. Pass False, together with an explicit max_index, to skip it when the bound is already guaranteed by construction (mesh edge rows are built from face indices, so they are non-negative and below the vertex count by definition).

A caller that derived its max_index from index_bound has not thereby made this redundant, and four of them deliberately keep it on. index_bound is a max reduction; the validation is a minmax, and it is the min half -- the negative-index guard -- that has no counterpart above it, so skipping it would turn a malformed face buffer from a raise into a silently wrong grouping. So edges.edges_unique, validation.is_edge_manifold, validation.edge_manifold_mask and holes._EdgeTable all validate, and the ten callers that pass False are the ones whose bound and non-negativity are structural.

True

Returns:

Type Description
array[uint64]

Length-n array on data.device with one packed key per row.

Raises:

Type Description
ValueError

If max_index is not positive, if validate=False is passed without a max_index, or -- when validating -- if data is negative or reaches max_index.

Warnings

validate=False with a max_index smaller than the true maximum silently produces colliding keys, and therefore wrong groupings, rather than raising. Only use it where the bound is structurally guaranteed.

The mixed-radix key is injective only while max_index ** w fits a uint64; past that the positional sum wraps and equal keys no longer imply equal rows. Two 32-bit columns always fit, but three columns need max_index <= 2 ** (64 / 3), about 2.6e6. Beyond that the keys degrade from an exact row identity into a hash whose collision probability grows with the square of the row count -- around 2e-5 for 28M rows at a radix of 1.4e7. This is deliberately not validated: raising would reject meshes that group correctly in practice.

See Also

hash_vector_rows hash_rows

Source code in triwarp/grouping.py
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
def hash_indices_rows(
    data: twt.Array2dInt32, max_index: int | None = None, *, validate: bool = True
) -> wp.array[wp.uint64]:
    """
    Pack each row of non-negative ``int32`` values into a single ``uint64`` key.

    Each row is treated as digits in base ``max_index`` (or ``max(data)+1`` when
    ``max_index`` is omitted) and combined into one integer key per row. A global
    min/max over ``data`` (via ``triwarp.reduce.minmax``) validates the range and
    supplies the radix when it is inferred.

    Parameters
    ----------
    data
        ``(n, w)`` device array of ``int32``; every entry must be non-negative. If
        ``max_index`` is given, every entry must satisfy ``entry < max_index``.
    max_index
        Optional exclusive upper bound on entries and radix for packing; must be
        positive when provided. If ``None``, set to ``max(data) + 1`` after validation.
    validate
        When ``True`` (default), a ``triwarp.reduce.minmax`` over ``data`` checks that entries are
        non-negative and below ``max_index``. That reduction ends in a host readback, which
        serialises the device pipeline — measurably so when this is called once per pass inside a
        remeshing loop. Pass ``False``, together with an explicit ``max_index``, to skip it when the
        bound is already guaranteed by construction (mesh edge rows are built from face indices, so
        they are non-negative and below the vertex count by definition).

        **A caller that derived its ``max_index`` from
        [`index_bound`][triwarp.array.index_bound] has *not* thereby made this
        redundant, and four of them deliberately keep it on.** ``index_bound`` is a ``max``
        reduction; the validation is a ``minmax``, and it is the ``min`` half -- the negative-index
        guard -- that has no counterpart above it, so skipping it would turn a malformed face
        buffer from a raise into a silently wrong grouping. So ``edges.edges_unique``,
        ``validation.is_edge_manifold``, ``validation.edge_manifold_mask`` and ``holes._EdgeTable``
        all validate, and the ten callers that pass ``False`` are the ones whose bound *and*
        non-negativity are structural.

    Returns
    -------
    wp.array[wp.uint64]
        Length-``n`` array on ``data.device`` with one packed key per row.

    Raises
    ------
    ValueError
        If ``max_index`` is not positive, if ``validate=False`` is passed without a ``max_index``,
        or -- when validating -- if ``data`` is negative or reaches ``max_index``.

    Warnings
    --------
    ``validate=False`` with a ``max_index`` smaller than the true maximum silently produces
    colliding keys, and therefore wrong groupings, rather than raising. Only use it where the bound
    is structurally guaranteed.

    The mixed-radix key is injective only while ``max_index ** w`` fits a ``uint64``; past that the
    positional sum wraps and equal keys no longer imply equal rows. Two 32-bit columns always fit,
    but three columns need ``max_index <= 2 ** (64 / 3)``, about ``2.6e6``. Beyond that the keys
    degrade from an exact row identity into a hash whose collision probability grows with the square
    of the row count -- around ``2e-5`` for 28M rows at a radix of ``1.4e7``. This is deliberately
    not validated: raising would reject meshes that group correctly in practice.

    See Also
    --------
    [`hash_vector_rows`][triwarp.grouping.hash_vector_rows]
    [`hash_rows`][triwarp.grouping.hash_rows]
    """
    twt.ensure_ndim(data, 2, dtype=wp.int32)
    if max_index is not None and max_index <= 0:
        raise ValueError(f"max_index must be positive, got {max_index}")
    n = int(data.shape[0])
    if not validate:
        if max_index is None:
            raise ValueError("validate=False requires an explicit max_index (the radix to use).")
    elif n > 0:
        # The min/max is a device reduction with a host readback, so it serialises the pipeline.
        # It is unavoidable when the radix has to be inferred, and skippable via validate=False
        # when the caller already knows the bound. Skipped for an empty ``data`` -- there is
        # nothing to validate, and ``reduce.minmax`` itself raises on an empty array.
        min_data, max_data = tw.reduce.minmax(data)
        if min_data < 0:
            raise ValueError(f"data must be non-negative, got a minimum of {min_data}")
        if max_index is not None and max_data >= max_index:
            raise ValueError(
                f"data must be less than max_index {max_index}, got a maximum of {max_data}"
            )
        if max_index is None:
            max_index = max_data + 1
    hashes = wp.empty(n, dtype=wp.uint64, device=data.device)
    if n > 0:
        wp.launch(
            kernel_grouping.pack_indices,
            dim=n,
            inputs=[data, wp.uint64(max_index), hashes],
            device=data.device,
        )
    return hashes

sorted_undirected_edge_keys

sorted_undirected_edge_keys(
    edges: Array2dInt32, n_vertices: int
) -> wp.array[wp.uint64]

Sorted packed keys of an undirected edge set, for binary-search membership testing.

Each row packs to the same key regardless of which endpoint comes first, so a halfedge's own (origin, destination) key -- built the same way from either order -- can be tested for membership against the result with a binary search, without caring which order a caller's row was given in.

Parameters:

Name Type Description Default
edges Array2dInt32

(k, 2) vertex-index pairs, in either order per row.

required
n_vertices int

Total vertex count, used as the packing radix.

required

Returns:

Type Description
array[uint64]

Length-k sorted keys on edges.device. Empty when edges is empty.

Source code in triwarp/grouping.py
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
def sorted_undirected_edge_keys(edges: twt.Array2dInt32, n_vertices: int) -> wp.array[wp.uint64]:
    """
    Sorted packed keys of an undirected edge set, for binary-search membership testing.

    Each row packs to the same key regardless of which endpoint comes first, so a halfedge's own
    ``(origin, destination)`` key -- built the same way from either order -- can be tested for
    membership against the result with a binary search, without caring which order a caller's row
    was given in.

    Parameters
    ----------
    edges
        ``(k, 2)`` vertex-index pairs, in either order per row.
    n_vertices
        Total vertex count, used as the packing radix.

    Returns
    -------
    wp.array[wp.uint64]
        Length-``k`` sorted keys on ``edges.device``. Empty when ``edges`` is empty.
    """
    device = edges.device
    n_edges = int(edges.shape[0])
    if n_edges == 0:
        return wp.empty(0, dtype=wp.uint64, device=device)
    keys = wp.empty(n_edges, dtype=wp.uint64, device=device)
    wp.launch(
        kernel_grouping.pack_undirected_edge_keys,
        dim=n_edges,
        inputs=[edges, wp.uint64(n_vertices), keys],
        device=device,
    )
    return tw.array.sort_and_argsort(keys)[0]