Skip to content

sample

triwarp.sample

Generative sampling: new points on a mesh surface, inside its volume, or over a sphere.

Everything here creates points -- uniformly over the faces (sample_surface), spaced at least radius apart (sample_surface_poisson_disk, sample_surface_blue_noise), inside a watertight solid (sample_volume), or over a sphere, hemisphere or cone as a low-discrepancy Fibonacci lattice (the sample_fibonacci_* family, which take a count and no mesh at all).

Subsampling an existing cloud is the other question and lives with the data structures that answer it: points.farthest_point_sample for an exact count, and voxels.voxel_down_sample for one representative per occupied cell. Neither belongs here -- each is a reduction of points a caller already has.

The one function here that reads like a subsampler and is not is sample_surface_blue_noise: it draws its own dense pool from the surface and thins that, so its input is a mesh and its output is new points, never a subset of anything the caller passed.

See Also

points.farthest_point_sample Subsample an existing cloud to an exact count. voxels.voxel_down_sample Subsample an existing cloud to one point per occupied voxel.

sample_fibonacci_sphere

sample_fibonacci_sphere(
    count: int, device: DeviceLike = None
) -> wp.array[wp.vec3]

Generate near-uniform unit vectors on the sphere via the Fibonacci spiral.

Successive points are placed at multiples of the golden angle while their height z descends uniformly through (-1, 1), producing the Fibonacci lattice — a deterministic, low-discrepancy covering of the sphere that is far more even than independent random sampling for the same count.

Parameters:

Name Type Description Default
count int

Number of directions to generate.

required
device DeviceLike

Warp device for the result. Defaults to the current device.

None

Returns:

Type Description
array[vec3]

(count,) unit vectors on the sphere. Empty when count is 0.

See Also

sample_fibonacci_hemisphere

Source code in triwarp/sample.py
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
def sample_fibonacci_sphere(count: int, device: wp.DeviceLike = None) -> wp.array[wp.vec3]:
    """
    Generate near-uniform unit vectors on the sphere via the Fibonacci spiral.

    Successive points are placed at multiples of the golden angle while their
    height ``z`` descends uniformly through ``(-1, 1)``, producing the Fibonacci
    lattice — a deterministic, low-discrepancy covering of the sphere that is far
    more even than independent random sampling for the same ``count``.

    Parameters
    ----------
    count
        Number of directions to generate.
    device
        Warp device for the result. Defaults to the current device.

    Returns
    -------
    wp.array[wp.vec3]
        ``(count,)`` unit vectors on the sphere. Empty when ``count`` is 0.

    See Also
    --------
    [`sample_fibonacci_hemisphere`][triwarp.sample.sample_fibonacci_hemisphere]
    """
    return _fibonacci_lattice(count, 2.0, device)

sample_fibonacci_hemisphere

sample_fibonacci_hemisphere(
    count: int, device: DeviceLike = None
) -> wp.array[wp.vec3]

Generate near-uniform unit vectors on the positive-z hemisphere.

Same Fibonacci-spiral construction as sample_fibonacci_sphere but with z descending uniformly through (0, 1), so every direction has a positive z component. Because the hemisphere and its reflection tile the full sphere, pairing each direction n with its antipode -n (for example via a min/max reduction) covers all orientations with half the directions.

Parameters:

Name Type Description Default
count int

Number of directions to generate.

required
device DeviceLike

Warp device for the result. Defaults to the current device.

None

Returns:

Type Description
array[vec3]

(count,) unit vectors on the positive-z hemisphere. Empty when count is 0.

See Also

sample_fibonacci_sphere

Source code in triwarp/sample.py
 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
def sample_fibonacci_hemisphere(count: int, device: wp.DeviceLike = None) -> wp.array[wp.vec3]:
    """
    Generate near-uniform unit vectors on the positive-``z`` hemisphere.

    Same Fibonacci-spiral construction as
    [`sample_fibonacci_sphere`][triwarp.sample.sample_fibonacci_sphere] but with
    ``z`` descending uniformly through ``(0, 1)``, so every direction has a
    positive ``z`` component. Because the hemisphere and its reflection tile the
    full sphere, pairing each direction ``n`` with its antipode ``-n`` (for
    example via a min/max reduction) covers all orientations with half the
    directions.

    Parameters
    ----------
    count
        Number of directions to generate.
    device
        Warp device for the result. Defaults to the current device.

    Returns
    -------
    wp.array[wp.vec3]
        ``(count,)`` unit vectors on the positive-``z`` hemisphere. Empty when
        ``count`` is 0.

    See Also
    --------
    [`sample_fibonacci_sphere`][triwarp.sample.sample_fibonacci_sphere]
    """
    return _fibonacci_lattice(count, 1.0, device)

sample_fibonacci_cone

sample_fibonacci_cone(
    count: int, half_angle: float, device: DeviceLike = None
) -> wp.array[wp.vec3]

Generate near-uniform unit vectors inside a cone around +z.

The same Fibonacci-spiral construction as sample_fibonacci_sphere, with z descending uniformly through (cos(half_angle), 1) instead of the whole range — which is the correct restriction, because a uniform z is a uniform solid angle (Archimedes) whether the band is the full sphere or a cap. Rejection-sampling a sphere lattice down to the cone would not stay low-discrepancy; this does.

Parameters:

Name Type Description Default
count int

Number of directions to generate.

required
half_angle float

Half-angle of the cone in radians, measured from +z. pi / 2 reproduces sample_fibonacci_hemisphere and pi reproduces sample_fibonacci_sphere. Must be in (0, pi].

required
device DeviceLike

Warp device for the result. Defaults to the current device.

None

Returns:

Type Description
array[vec3]

(count,) unit vectors inside the cone. Empty when count is 0.

Raises:

Type Description
ValueError

If half_angle is outside (0, pi].

See Also

sample_fibonacci_hemisphere triwarp.visibility.shape_diameter

Source code in triwarp/sample.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def sample_fibonacci_cone(
    count: int, half_angle: float, device: wp.DeviceLike = None
) -> wp.array[wp.vec3]:
    """
    Generate near-uniform unit vectors inside a cone around ``+z``.

    The same Fibonacci-spiral construction as
    [`sample_fibonacci_sphere`][triwarp.sample.sample_fibonacci_sphere], with ``z`` descending
    uniformly through ``(cos(half_angle), 1)`` instead of the whole range — which is the *correct*
    restriction, because a uniform ``z`` is a uniform solid angle (Archimedes) whether the band is
    the full sphere or a cap. Rejection-sampling a sphere lattice down to the cone would not stay
    low-discrepancy; this does.

    Parameters
    ----------
    count
        Number of directions to generate.
    half_angle
        Half-angle of the cone in **radians**, measured from ``+z``. ``pi / 2`` reproduces
        [`sample_fibonacci_hemisphere`][triwarp.sample.sample_fibonacci_hemisphere] and ``pi``
        reproduces [`sample_fibonacci_sphere`][triwarp.sample.sample_fibonacci_sphere]. Must be in
        ``(0, pi]``.
    device
        Warp device for the result. Defaults to the current device.

    Returns
    -------
    wp.array[wp.vec3]
        ``(count,)`` unit vectors inside the cone. Empty when ``count`` is 0.

    Raises
    ------
    ValueError
        If ``half_angle`` is outside ``(0, pi]``.

    See Also
    --------
    [`sample_fibonacci_hemisphere`][triwarp.sample.sample_fibonacci_hemisphere]
    [`triwarp.visibility.shape_diameter`][triwarp.visibility.shape_diameter]
    """
    if not 0.0 < half_angle <= math.pi:
        raise ValueError(f"half_angle must be in (0, pi] radians, got {half_angle}")
    return _fibonacci_lattice(count, 1.0 - math.cos(half_angle), device)

sample_surface

sample_surface(
    vertices: array[vec3],
    faces: array[int32],
    count: int,
    face_weight: array[float32] | None = None,
    seed: int | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Sample points uniformly on a triangle mesh surface (area-weighted faces).

Uses face_normals_and_areas for default triangle weights. Builds a CDF and draws triangle indices with wp.sample_cdf, then uniform points with wp.sample_triangle (same scheme as trimesh.sample.sample_surface).

Parameters:

Name Type Description Default
vertices array[vec3]

Vertex positions.

required
faces array[int32]

Flat triangle indices (i0, i1, i2) per face.

required
count int

Number of samples.

required
face_weight array[float32] | None

Optional per-face weights (length = number of triangles). If None, triangle areas from face_normals_and_areas are used.

None
seed int | None

RNG seed for wp.rand_init. If None, a random seed is chosen.

None

Returns:

Type Description
samples

(count,) sampled positions on the mesh surface.

face_index

(count,) triangle index for each sample.

Raises:

Type Description
ValueError

If face_weight is given and its length is not the triangle count, if the mesh has no faces and count > 0, or if the total face weight is not positive.

RuntimeError

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

Source code in triwarp/sample.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def sample_surface(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    count: int,
    face_weight: wp.array[wp.float32] | None = None,
    seed: int | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Sample points uniformly on a triangle mesh surface (area-weighted faces).

    Uses ``face_normals_and_areas`` for default triangle weights. Builds a CDF and
    draws triangle indices with ``wp.sample_cdf``, then uniform points with
    ``wp.sample_triangle`` (same scheme as [`trimesh.sample.sample_surface`][]).

    Parameters
    ----------
    vertices
        Vertex positions.
    faces
        Flat triangle indices ``(i0, i1, i2)`` per face.
    count
        Number of samples.
    face_weight
        Optional per-face weights (length = number of triangles). If ``None``,
        triangle areas from ``face_normals_and_areas`` are used.
    seed
        RNG seed for ``wp.rand_init``. If ``None``, a random seed is chosen.

    Returns
    -------
    samples
        ``(count,)`` sampled positions on the mesh surface.
    face_index
        ``(count,)`` triangle index for each sample.

    Raises
    ------
    ValueError
        If ``face_weight`` is given and its length is not the triangle count, if the mesh has no
        faces and ``count > 0``, or if the total face weight is not positive.
    RuntimeError
        If ``vertices``, ``faces`` and ``face_weight`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, face_weight=face_weight)
    n_faces = faces.shape[0] // 3
    # Validated before the ``count == 0`` short-circuit below, so a bad ``face_weight`` still
    # raises even when nothing would otherwise be sampled.
    if face_weight is not None and face_weight.shape[0] != n_faces:
        raise ValueError(
            f"face_weight length must match number of triangles (expected {n_faces}, "
            f"got {face_weight.shape[0]})"
        )
    if count == 0:
        return (
            wp.empty(0, dtype=wp.vec3, device=vertices.device),
            wp.empty(0, dtype=wp.int32, device=vertices.device),
        )
    if n_faces == 0:
        # Without this, an empty ``weights``/``cdf`` reaches ``read_scalar``'s tail read below and
        # raises an unrelated ``IndexError`` (CPU) or a Warp slicing error (CUDA) instead.
        raise ValueError("mesh has no faces; cannot sample its surface")

    if face_weight is None:
        _, weights = face_normals_and_areas(vertices, faces)
    else:
        weights = face_weight

    # ``array_scan`` is inclusive by default, so the total is the scan's last element -- a 4-byte
    # tail read instead of a whole second reduction over the weights. Same trick as
    # ``array.flatnonzero`` and ``array.counts_to_offsets``.
    cdf = wp.empty(n_faces, dtype=wp.float32, device=vertices.device)
    wp.utils.array_scan(weights, out_array=cdf)
    total = float(read_scalar(cdf))
    if total <= 0.0:
        raise ValueError("total face weight must be positive")
    wp.map(wp.div, cdf, wp.float32(total), out=cdf)

    out_points = wp.empty(count, dtype=wp.vec3, device=vertices.device)
    out_face_indices = wp.empty(count, dtype=wp.int32, device=vertices.device)
    wp.launch(
        kernel_sample.sample_surface,
        dim=count,
        inputs=[vertices, faces, cdf, resolve_seed(seed), out_points, out_face_indices],
        device=vertices.device,
    )
    return out_points, out_face_indices

sample_surface_poisson_disk

sample_surface_poisson_disk(
    vertices: array[vec3],
    faces: array[int32],
    count: int,
    init_factor: float = 5.0,
    face_weight: array[float32] | None = None,
    seed: int | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Sample points on a triangle mesh surface with Poisson disk distribution.

Uses Weighted Sample Elimination (Öztireli & Gross 2012): generates init_factor * count uniform surface samples, then iteratively removes the most "crowded" points in parallel rounds until count remain. Each round deletes all alive local weight-maxima simultaneously. Maximality is decided on (weight, -index), so no two of them are ever within r_max of each other and deleting the whole set at once cannot remove a point that a sequential elimination would have kept.

Parameters:

Name Type Description Default
vertices array[vec3]

Vertex positions.

required
faces array[int32]

Flat triangle indices (i0, i1, i2) per face.

required
count int

Number of output samples.

required
init_factor float

Over-sampling factor; initial pool has init_factor * count points. Must satisfy init_factor >= 1.

5.0
face_weight array[float32] | None

Optional per-face weights passed to the initial uniform sampling.

None
seed int | None

RNG seed for the initial uniform sampling. If None, a random seed is chosen.

None

Returns:

Type Description
samples

(count,) sampled positions on the mesh surface.

face_index

(count,) triangle index for each sample.

Raises:

Type Description
ValueError

If init_factor < 1 or if the mesh has no faces.

RuntimeError

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

Source code in triwarp/sample.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
def sample_surface_poisson_disk(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    count: int,
    init_factor: float = 5.0,
    face_weight: wp.array[wp.float32] | None = None,
    seed: int | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Sample points on a triangle mesh surface with Poisson disk distribution.

    Uses Weighted Sample Elimination (Öztireli & Gross 2012): generates
    ``init_factor * count`` uniform surface samples, then iteratively removes
    the most "crowded" points in parallel rounds until ``count`` remain.
    Each round deletes all alive local weight-maxima simultaneously. Maximality is decided on
    ``(weight, -index)``, so no two of them are ever within ``r_max`` of each other and deleting
    the whole set at once cannot remove a point that a sequential elimination would have kept.

    Parameters
    ----------
    vertices
        Vertex positions.
    faces
        Flat triangle indices ``(i0, i1, i2)`` per face.
    count
        Number of output samples.
    init_factor
        Over-sampling factor; initial pool has ``init_factor * count`` points.
        Must satisfy ``init_factor >= 1``.
    face_weight
        Optional per-face weights passed to the initial uniform sampling.
    seed
        RNG seed for the initial uniform sampling. If ``None``, a random seed
        is chosen.

    Returns
    -------
    samples
        ``(count,)`` sampled positions on the mesh surface.
    face_index
        ``(count,)`` triangle index for each sample.

    Raises
    ------
    ValueError
        If ``init_factor < 1`` or if the mesh has no faces.
    RuntimeError
        If ``vertices``, ``faces`` and ``face_weight`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces, face_weight=face_weight)
    device = vertices.device

    # Validated before the ``count == 0`` short-circuit below, matching ``sample_surface``.
    if init_factor < 1.0:
        raise ValueError(f"init_factor must be >= 1, got {init_factor}")

    if count == 0:
        return (
            wp.empty(0, dtype=wp.vec3, device=device),
            wp.empty(0, dtype=wp.int32, device=device),
        )

    init_count = max(math.ceil(init_factor * count), count)

    # 1. Initial uniform surface samples
    init_points, init_face_indices = sample_surface(
        vertices, faces, init_count, face_weight=face_weight, seed=seed
    )

    # 2. Surface area (for radius computation)
    _, areas = face_normals_and_areas(vertices, faces)
    surface_area = float(wp.utils.array_sum(areas))

    # 3. Poisson disk radii (Öztireli & Gross 2012 constants)
    alpha = wp.float32(8.0)
    beta = wp.float32(0.65)
    gamma = wp.float32(1.5)
    ratio = float(count) / float(init_count)
    r_max = wp.float32(2.0 * math.sqrt((surface_area / count) / (2.0 * math.sqrt(3.0))))
    r_min = wp.float32(r_max * beta * (1.0 - ratio**gamma))

    # 4. Neighbor lists (GPU, computed once for the full initial pool)
    nbr_idx, nbr_dists, offsets = query_ball_with_offsets(
        init_points, init_points, r_max, include_total=True
    )

    # 5. Initial per-point weights (parallel)
    alive = wp.ones(init_count, dtype=wp.int32, device=device)
    weights = wp.zeros(init_count, dtype=wp.float32, device=device)
    wp.launch(
        kernel_sample.compute_poisson_weights,
        dim=init_count,
        inputs=[nbr_idx, nbr_dists, offsets, alive, r_max, r_min, alpha, weights],
        device=device,
    )

    # 6. Parallel round-based elimination
    alive_count = init_count
    is_max = wp.zeros(init_count, dtype=wp.int32, device=device)

    while alive_count > count:
        wp.launch(
            kernel_sample.find_local_maxima,
            dim=init_count,
            inputs=[weights, alive, nbr_idx, offsets, is_max],
            device=device,
        )

        n_max = tw.reduce.sum(is_max)
        excess = alive_count - count
        if n_max == 0:
            # Nothing is flagged only when no alive point has an alive neighbour inside ``r_max``
            # -- otherwise the heaviest alive point with a neighbour is flagged, the test being a
            # strict ``>``. Every remaining point then carries weight 0, so they are equally good
            # and the round may delete any ``excess`` of them; ranking all of the alive ones keeps
            # the loop making progress, which the maxima alone no longer guarantee.
            deleted_mask = _top_maxima_by_weight(alive, weights, excess)
            n_max = excess
        elif n_max <= excess:
            deleted_mask = is_max
        else:
            deleted_mask = _top_maxima_by_weight(is_max, weights, excess)
            n_max = excess

        wp.map(kernel_sample.apply_deletions, deleted_mask, alive, out=alive)
        wp.launch(
            kernel_sample.subtract_deleted_contributions,
            dim=init_count,
            inputs=[deleted_mask, nbr_idx, nbr_dists, offsets, alive, r_max, r_min, alpha, weights],
            device=device,
        )
        alive_count -= n_max

    # 7. GPU gather: convert alive mask to bool, get indices, copy selected rows
    alive_bool = tw.array.astype(alive, wp.bool)
    indices = flatnonzero(alive_bool)

    return gather(init_points, indices), gather(init_face_indices, indices)

sample_surface_blue_noise

sample_surface_blue_noise(
    vertices: array[vec3],
    faces: array[int32],
    radius: float,
    seed: int | None = None,
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]

Sample points on a triangle mesh surface with a blue-noise (Poisson-disk) distribution.

Draws a dense uniform surface pool — 30x the expected output, the oversampling factor igl::blue_noise uses — and reduces it to a maximal subset in which no two points are within radius of each other, by randomized-priority parallel dart throwing. The result has the distribution of sequential dart throwing over a uniformly random order of the pool; see kernels/algorithms/blue_noise.py for why the parallelism costs nothing in distribution, and for the measured spacing and coverage against MeshLab and Open3D.

Parameters:

Name Type Description Default
vertices array[vec3]

Vertex positions.

required
faces array[int32]

Flat triangle indices (i0, i1, i2) per face.

required
radius float

Minimum Poisson disk radius (Euclidean distance in 3D). Enforced exactly: the closest pair in the output is never below it.

required
seed int | None

RNG seed for the initial uniform sampling and the sampling order. If None, a random seed is chosen. With a seed the output is reproducible — every round of the loop is a deterministic function of its input state.

None

Returns:

Type Description
samples

(m,) sampled positions on the mesh surface. Count m is determined implicitly by radius and mesh area.

face_index

(m,) triangle index for each sample.

Raises:

Type Description
ValueError

If radius <= 0.

RuntimeError

If vertices and faces are not all on one device.

Notes

The count is derived, never requested: it is what a maximal radius-packing of the surface comes to, so it lands near — not at — the hexagonal-packing estimate the radius is usually chosen from. Ask for a count with sample_surface_poisson_disk instead.

A mesh with no faces returns two empty arrays rather than raising.

Source code in triwarp/sample.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
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
def sample_surface_blue_noise(
    vertices: wp.array[wp.vec3], faces: wp.array[wp.int32], radius: float, seed: int | None = None
) -> tuple[wp.array[wp.vec3], wp.array[wp.int32]]:
    """
    Sample points on a triangle mesh surface with a blue-noise (Poisson-disk) distribution.

    Draws a dense uniform surface pool — ``30x`` the expected output, the oversampling factor
    ``igl::blue_noise`` uses — and reduces it to a **maximal** subset in which no two points are
    within ``radius`` of each other, by randomized-priority parallel dart throwing. The result has
    the distribution of sequential dart throwing over a uniformly random order of the pool; see
    ``kernels/algorithms/blue_noise.py`` for why the parallelism costs nothing in distribution, and
    for the measured spacing and coverage against MeshLab and Open3D.

    Parameters
    ----------
    vertices
        Vertex positions.
    faces
        Flat triangle indices ``(i0, i1, i2)`` per face.
    radius
        Minimum Poisson disk radius (Euclidean distance in 3D). Enforced exactly: the closest pair
        in the output is never below it.
    seed
        RNG seed for the initial uniform sampling and the sampling order. If ``None``, a random seed
        is chosen. With a seed the output is reproducible — every round of the loop is a
        deterministic function of its input state.

    Returns
    -------
    samples
        ``(m,)`` sampled positions on the mesh surface. Count ``m`` is determined
        implicitly by ``radius`` and mesh area.
    face_index
        ``(m,)`` triangle index for each sample.

    Raises
    ------
    ValueError
        If ``radius <= 0``.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.

    Notes
    -----
    The count is **derived**, never requested: it is what a maximal ``radius``-packing of the
    surface comes to, so it lands near — not at — the hexagonal-packing estimate the radius is
    usually chosen from. Ask for a *count* with
    [`sample_surface_poisson_disk`][triwarp.sample.sample_surface_poisson_disk] instead.

    A mesh with no faces returns two empty arrays rather than raising.
    """
    require_same_device(vertices=vertices, faces=faces)
    device = vertices.device
    n_faces = faces.shape[0] // 3

    if radius <= 0.0:
        raise ValueError(f"radius must be > 0, got {radius}")

    if n_faces == 0:
        return (
            wp.empty(0, dtype=wp.vec3, device=device),
            wp.empty(0, dtype=wp.int32, device=device),
        )

    _, areas = face_normals_and_areas(vertices, faces)
    surface_area = float(wp.utils.array_sum(areas))
    expected = surface_area * (math.pi * math.sqrt(3.0) / 6.0) / (math.pi * radius * radius / 4.0)
    nx = max(1, int(30.0 * expected))

    # The pool draw and the dart-throw priority draw both key off the same point index (``tid``
    # here, ``i`` in ``random_priorities``) at the same pool size, so a shared seed would give
    # ``wp.rand_init(seed, i)`` the identical initial state in both kernels -- ``sample_cdf``'s
    # first draw and ``random_priorities``'s only draw are then the exact same ``rand_pcg`` step,
    # making each point's dart-throw priority an exact, monotonic function of the very draw that
    # picked its face. A distinct, seed-derived salt for the priority draw removes that
    # correlation while staying a deterministic function of the caller's own seed.
    pool_seed = resolve_seed(seed)
    priority_seed = (pool_seed ^ 0x2545F491) & 0x7FFFFFFF
    init_points, init_face_indices = sample_surface(vertices, faces, nx, seed=pool_seed)
    return _dart_throw_blue_noise(init_points, init_face_indices, radius, priority_seed)

sample_volume

sample_volume(
    vertices: array[vec3],
    faces: array[int32],
    count: int,
    seed: int | None = None,
) -> wp.array[wp.vec3]

Sample points uniformly inside a watertight triangle mesh volume.

Fans tetrahedra from the mesh's area-weighted surface centroid, builds a CDF from signed tetrahedron volumes, and draws uniform points inside each selected one via the order-statistics barycentric method (zero rejection for meshes that are star-shaped with respect to their centroid).

Parameters:

Name Type Description Default
vertices array[vec3]

Vertex positions.

required
faces array[int32]

Flat triangle indices (i0, i1, i2) per face.

required
count int

Number of samples.

required
seed int | None

RNG seed for wp.rand_init. If None, a random seed is chosen.

None

Returns:

Type Description
samples

(count,) positions inside the mesh volume.

Raises:

Type Description
ValueError

If the mesh is not watertight (open boundary edges detected).

ValueError

If the mesh has zero total volume, some signed tetrahedron volumes are negative after fanning from the centroid (the mesh is not star-shaped with respect to its own centroid, e.g. a torus), or the mesh has no faces and count > 0.

RuntimeError

If vertices and faces are not all on one device.

Source code in triwarp/sample.py
720
721
722
723
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
def sample_volume(
    vertices: wp.array[wp.vec3], faces: wp.array[wp.int32], count: int, seed: int | None = None
) -> wp.array[wp.vec3]:
    """
    Sample points uniformly inside a watertight triangle mesh volume.

    Fans tetrahedra from the mesh's area-weighted surface centroid, builds a CDF
    from signed tetrahedron volumes, and draws uniform points inside each selected one via
    the order-statistics barycentric method (zero rejection for meshes that are
    star-shaped with respect to their centroid).

    Parameters
    ----------
    vertices
        Vertex positions.
    faces
        Flat triangle indices ``(i0, i1, i2)`` per face.
    count
        Number of samples.
    seed
        RNG seed for ``wp.rand_init``. If ``None``, a random seed is chosen.

    Returns
    -------
    samples
        ``(count,)`` positions inside the mesh volume.

    Raises
    ------
    ValueError
        If the mesh is not watertight (open boundary edges detected).
    ValueError
        If the mesh has zero total volume, some signed tetrahedron volumes are negative after
        fanning from the centroid (the mesh is not star-shaped with respect to its own centroid,
        e.g. a torus), or the mesh has no faces and ``count > 0``.
    RuntimeError
        If ``vertices`` and ``faces`` are not all on one device.
    """
    require_same_device(vertices=vertices, faces=faces)
    n_faces = faces.shape[0] // 3

    # These two validations run regardless of ``count`` -- unlike the "no faces" guard below,
    # which only matters once something is actually being sampled -- so a caller cannot skip a
    # documented raise on a malformed mesh by asking for zero points.
    if not tw.validation.is_edge_manifold(
        faces, allow_boundary_edges=False, n_vertices=int(vertices.shape[0])
    ):
        raise ValueError(
            "mesh is not watertight; tetrahedral decomposition requires a closed surface"
        )

    center = tw.measures.surface_centroid(vertices, faces)

    signed_vols = tw.triangles.face_signed_volumes(vertices, faces, center)

    # One device reduction, not two: the star-shaped test genuinely needs a ``min``, but the total
    # is the inclusive scan's last element and comes for free with the CDF this builds anyway.
    # ``n_faces > 0`` guards the empty mesh, where "no negative volumes" holds vacuously and
    # ``reduce.min`` would otherwise raise its own unrelated "requires a non-empty array" error.
    if n_faces > 0 and tw.reduce.min(signed_vols) < 0.0:
        raise ValueError(
            "mesh is not star-shaped with respect to its centroid (e.g. a torus); "
            "tetrahedral decomposition cannot sample it without rejection"
        )

    if count == 0:
        return wp.empty(0, dtype=wp.vec3, device=vertices.device)

    if n_faces == 0:
        # Without this, an empty ``signed_vols``/``cdf`` reaches ``read_scalar``'s tail read below
        # and raises an unrelated ``IndexError`` (CPU) or a Warp slicing error (CUDA) instead.
        raise ValueError("mesh has no faces; cannot sample its volume")

    cdf = wp.empty(n_faces, dtype=wp.float32, device=vertices.device)
    wp.utils.array_scan(signed_vols, out_array=cdf)
    total_vol = float(read_scalar(cdf))
    if total_vol == 0.0:
        raise ValueError("mesh has zero volume")
    wp.map(wp.div, cdf, wp.float32(total_vol), out=cdf)

    out_points = wp.empty(count, dtype=wp.vec3, device=vertices.device)
    wp.launch(
        kernel_sample.sample_volume_tetrahedra,
        dim=count,
        inputs=[vertices, faces, center, cdf, resolve_seed(seed), out_points],
        device=vertices.device,
    )
    return out_points

resolve_seed

resolve_seed(seed: int | None) -> int

Concrete non-negative int32-range RNG seed, drawn at random when none was given.

Every generator in the package takes seed: int | None and means the same thing by it, so the draw lives here rather than at each entry point. Public because random_soup needs the identical convention from another module.

Parameters:

Name Type Description Default
seed int | None

User-provided seed, or None to draw a cryptographically random one.

required

Returns:

Type Description
int

seed unchanged when provided, otherwise a random value in [0, 2**31).

See Also

sample_surface random_soup voxels.resolve_voxel_grid

Notes

The resolve_* pattern

Both of these -- this and sample.resolve_seed / voxels.resolve_voxel_grid -- turn an optional argument into the concrete value the wrapper would have derived, so a caller who wants two functions to share the derived thing can resolve it once and pass it to both. Each default is domain knowledge, so they cannot share a module. The optional face-adjacency pair deliberately has no such resolver: deriving it is one face_adjacency call with return_edges=True, so only the pairing rule is worth sharing and adjacency.require_paired_adjacency is that rule on its own.

Source code in triwarp/sample.py
810
811
812
813
814
815
816
817
818
819
820
821
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
def resolve_seed(seed: int | None) -> int:
    """
    Concrete non-negative ``int32``-range RNG seed, drawn at random when none was given.

    Every generator in the package takes ``seed: int | None`` and means the same thing by it, so
    the draw lives here rather than at each entry point. Public because
    [`random_soup`][triwarp.creation.random_soup] needs the identical convention from another
    module.

    Parameters
    ----------
    seed
        User-provided seed, or ``None`` to draw a cryptographically random one.

    Returns
    -------
    int
        ``seed`` unchanged when provided, otherwise a random value in ``[0, 2**31)``.

    See Also
    --------
    [`sample_surface`][triwarp.sample.sample_surface]
    [`random_soup`][triwarp.creation.random_soup]
    [`voxels.resolve_voxel_grid`][triwarp.voxels.resolve_voxel_grid]

    Notes
    -----
    !!! note "The ``resolve_*`` pattern"
        Both of these -- this and [`sample.resolve_seed`][triwarp.sample.resolve_seed] /
        [`voxels.resolve_voxel_grid`][triwarp.voxels.resolve_voxel_grid] -- turn an optional
        argument into the concrete value the wrapper would have derived, so a caller who wants two
        functions to share the derived thing can resolve it once and pass it to both. Each default
        is domain knowledge, so they cannot share a module. The optional face-adjacency pair
        deliberately has **no** such resolver: deriving it is one
        [`face_adjacency`][triwarp.adjacency.face_adjacency] call with ``return_edges=True``, so
        only the *pairing rule* is worth sharing and
        [`adjacency.require_paired_adjacency`][triwarp.adjacency.require_paired_adjacency] is that
        rule on its own.
    """
    if seed is None:
        return secrets.randbelow(2**31)
    return int(seed)