Skip to content

vertices

triwarp.vertices

Per-vertex quantities: normals under three weighting rules, and the angle defect.

A vertex has no normal of its own -- only the faces around it do -- so a vertex normal is always a weighted average of incident face normals, and the weight is a modelling choice rather than a detail. vertex_normals is the entry point, and its weighting= keyword is that choice: by face area, by incident angle (the one invariant to how a neighbouring polygon happened to be triangulated), or by Nelson Max's sine-and-edge-length reciprocals.

Two accumulators sit underneath it and are public in their own right: weighted_vertex_normals takes a caller-supplied per-corner weight table and mean_vertex_normals is the same thing with unit weights. They know no geometry -- they take no vertices at all, which is why they keep an explicit n_vertices argument where vertex_normals derives it. That asymmetry is the visible marker of which of the two layers you are in.

vertex_defects is the odd one out and not a normal at all: 2 * pi minus the incident angle sum, which is the intrinsic curvature concentrated at that vertex and the quantity discrete_gaussian_curvature reports.

mean_vertex_normals

mean_vertex_normals(
    n_vertices: int,
    faces: array[int32],
    face_normals: array[vec3],
) -> wp.array[wp.vec3]

Vertex normals as the (unnormalized) sum of incident face normals, then unit-length.

For each vertex, face normals sharing that corner are accumulated in float32 on faces.device, cast to wp.vec3, and L2-normalized. Vertices not referenced by any face remain zero.

Parameters:

Name Type Description Default
n_vertices int

Number of vertices indexed by faces (output length).

required
faces array[int32]

Triangle indices as wp.int32; interpreted as (f, 3) via reshape((-1, 3)) (row-major [i0, i1, i2, …] flat layout is fine).

required
face_normals array[vec3]

One unit (or unnormalized) normal per triangle, length f as wp.vec3, aligned with the rows of faces.

required

Returns:

Type Description
array[vec3]

Length-n_vertices device array of unit normals where the accumulated vector was non-zero; otherwise the corresponding entry is zero.

Raises:

Type Description
ValueError

If face_normals does not have one entry per triangle in faces.

RuntimeError

If faces and face_normals are not all on one device.

See Also

weighted_vertex_normals The same accumulator with a per-corner weight table; this is that with unit weights. vertex_normals The geometry-aware entry point, which derives a weight table and calls one of these two.

Source code in triwarp/vertices.py
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
84
85
86
87
88
89
90
91
def mean_vertex_normals(
    n_vertices: int, faces: wp.array[wp.int32], face_normals: wp.array[wp.vec3]
) -> wp.array[wp.vec3]:
    """
    Vertex normals as the (unnormalized) sum of incident face normals, then unit-length.

    For each vertex, face normals sharing that corner are accumulated in ``float32`` on
    ``faces.device``, cast to ``wp.vec3``, and L2-normalized. Vertices not referenced by
    any face remain zero.

    Parameters
    ----------
    n_vertices
        Number of vertices indexed by ``faces`` (output length).
    faces
        Triangle indices as ``wp.int32``; interpreted as ``(f, 3)`` via ``reshape((-1, 3))``
        (row-major ``[i0, i1, i2, …]`` flat layout is fine).
    face_normals
        One unit (or unnormalized) normal per triangle, length ``f`` as ``wp.vec3``, aligned
        with the rows of ``faces``.

    Returns
    -------
    wp.array[wp.vec3]
        Length-``n_vertices`` device array of unit normals where the accumulated vector was
        non-zero; otherwise the corresponding entry is zero.

    Raises
    ------
    ValueError
        If ``face_normals`` does not have one entry per triangle in ``faces``.
    RuntimeError
        If ``faces`` and ``face_normals`` are not all on one device.

    See Also
    --------
    [`weighted_vertex_normals`][triwarp.vertices.weighted_vertex_normals]
        The same accumulator with a per-corner weight table; this is that with unit weights.
    [`vertex_normals`][triwarp.vertices.vertex_normals]
        The geometry-aware entry point, which derives a weight table and calls one of these two.
    """
    require_same_device(faces=faces, face_normals=face_normals)
    _require_face_rows(int(faces.shape[0]) // 3, face_normals=face_normals)
    return _accumulate_and_normalize(
        n_vertices, faces, kernel_scatter.SCATTER_SUM_VEC, face_normals
    )

weighted_vertex_normals

weighted_vertex_normals(
    n_vertices: int,
    faces: array[int32],
    face_normals: array[vec3],
    face_weights: Array2dFloat32,
) -> wp.array[wp.vec3]

Vertex normals from a weighted sum of incident face normals, then unit-length.

Each face contributes its normal scaled by the per-corner weight in face_weights; contributions are summed per vertex in float32 on faces.device, cast to wp.vec3, and L2-normalized.

Parameters:

Name Type Description Default
n_vertices int

Number of vertices indexed by faces (output length).

required
faces array[int32]

Triangle indices as wp.int32; interpreted as (f, 3) via reshape((-1, 3)).

required
face_normals array[vec3]

One normal per triangle, length f as wp.vec3, aligned with faces.

required
face_weights Array2dFloat32

Per-corner weights, shape (f, 3) as twt.Array2dFloat32 with rows matching faces / face_normals.

required

Returns:

Type Description
array[vec3]

Length-n_vertices device array of unit normals where the accumulated vector was non-zero; otherwise the corresponding entry is zero.

Raises:

Type Description
ValueError

If face_normals or face_weights does not have one row per triangle in faces.

RuntimeError

If faces, face_normals and face_weights are not all on one device.

See Also

mean_vertex_normals This with unit weights. vertex_normals The geometry-aware entry point, which derives a weight table and calls this.

Source code in triwarp/vertices.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def weighted_vertex_normals(
    n_vertices: int,
    faces: wp.array[wp.int32],
    face_normals: wp.array[wp.vec3],
    face_weights: twt.Array2dFloat32,
) -> wp.array[wp.vec3]:
    """
    Vertex normals from a weighted sum of incident face normals, then unit-length.

    Each face contributes its normal scaled by the per-corner weight in ``face_weights``;
    contributions are summed per vertex in ``float32`` on ``faces.device``, cast to
    ``wp.vec3``, and L2-normalized.

    Parameters
    ----------
    n_vertices
        Number of vertices indexed by ``faces`` (output length).
    faces
        Triangle indices as ``wp.int32``; interpreted as ``(f, 3)`` via ``reshape((-1, 3))``.
    face_normals
        One normal per triangle, length ``f`` as ``wp.vec3``, aligned with ``faces``.
    face_weights
        Per-corner weights, shape ``(f, 3)`` as ``twt.Array2dFloat32`` with rows matching
        ``faces`` / ``face_normals``.

    Returns
    -------
    wp.array[wp.vec3]
        Length-``n_vertices`` device array of unit normals where the accumulated vector was
        non-zero; otherwise the corresponding entry is zero.

    Raises
    ------
    ValueError
        If ``face_normals`` or ``face_weights`` does not have one row per triangle in ``faces``.
    RuntimeError
        If ``faces``, ``face_normals`` and ``face_weights`` are not all on one device.

    See Also
    --------
    [`mean_vertex_normals`][triwarp.vertices.mean_vertex_normals]
        This with unit weights.
    [`vertex_normals`][triwarp.vertices.vertex_normals]
        The geometry-aware entry point, which derives a weight table and calls this.
    """
    require_same_device(faces=faces, face_normals=face_normals, face_weights=face_weights)
    _require_face_rows(
        int(faces.shape[0]) // 3, face_normals=face_normals, face_weights=face_weights
    )
    return _accumulate_and_normalize(
        n_vertices, faces, kernel_scatter.SCATTER_WEIGHTED_SUM_VEC, face_normals, face_weights
    )

vertex_normals

vertex_normals(
    vertices: array[vec3],
    faces: array[int32],
    *,
    weighting: Literal["area", "angle", "mwselr"] = "area",
    face_normals: array[vec3] | None = None,
    face_weights: array[float32]
    | Array2dFloat32
    | None = None,
) -> wp.array[wp.vec3]

Per-vertex unit normals, as a weighted average of the incident face normals.

A vertex has no normal of its own -- only the faces around it do -- so the weight is the whole content of the choice, and weighting selects it. Contributions are summed per vertex and L2-normalized, so any constant factor in the weights cancels.

Which weighting to want is a measurable question, not a preference: one reference library ships both conventions as separate entry points, and its plain per-vertex normals reproduce "area" to 1.19e-07 while its pseudo-normals reproduce "angle" to 1.19e-07 -- each sitting 6.8e-03 from the other's partner, so the two are distinguishable well above float32 noise. Reach for "angle" when the answer must not depend on how a neighbouring polygon happened to be triangulated -- it is the only one of the three that is invariant to that -- and for "area" otherwise, since it is what libigl and most of the field default to.

Parameters:

Name Type Description Default
vertices array[vec3]

(n_vertices,) mesh vertex positions. The output length is vertices.shape[0].

required
faces array[int32]

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

required
weighting Literal['area', 'angle', 'mwselr']

Which average to take.

  • "area" (default) weights each face by its area, matching libigl's default per_vertex_normals (up to the constant 2 from doublearea, which normalization cancels).
  • "angle" weights each face by its interior angle at the corner vertex -- the Thuerrner/Wuethrich recipe (Computing Vertex Normals from Polygonal Facets, Journal of Graphics Tools 3:1, 43-46, 1998) and igl::PER_VERTEX_NORMALS_WEIGHTING_TYPE_ANGLE. The triangulation-invariant one.
  • "mwselr" weights each corner by sin(angle) / (||E_i|| ||E_{i+1}||), which reduces to (e1 x e2) / (||e1||^2 ||e2||^2) for the two outgoing edges -- Nelson Max's Mean Weighted by Sine and Edge Length Reciprocals, optimized for smooth surface reconstruction (Jin et al., 2005).
'area'
face_normals array[vec3] | None

Precomputed (n_faces,) face normals, to skip deriving them. Under "mwselr", passing them asserts they are unit length; omitting them lets the unnormalized cross product be used instead, which is cheaper and gives the same answer after normalization.

None
face_weights array[float32] | Array2dFloat32 | None

Precomputed weights, to skip deriving them. The expected shape follows weighting: (n_faces,) per-face areas for "area", and (n_faces, 3) per-corner angles for "angle". Not accepted under "mwselr", whose weights are a joint function of the corner positions rather than a table a caller would hold.

None

Returns:

Type Description
array[vec3]

Length-n_vertices unit normals on vertices.device, zero wherever the accumulated vector was zero (an unreferenced vertex, or a cancelling fan).

Raises:

Type Description
ValueError

If weighting is not one of the three names, if face_weights is passed with weighting="mwselr", or if a supplied face_normals / face_weights does not have one row per triangle in faces.

RuntimeError

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

See Also

weighted_vertex_normals The accumulator underneath: a caller-supplied per-corner weight table, no geometry. mean_vertex_normals The same accumulator with unit weights. triwarp.triangles.face_normals_and_areas Produces the face_normals / face_weights the "area" path derives. triwarp.triangles.face_angles Produces the "angle" path's weight table.

Source code in triwarp/vertices.py
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
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
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
def vertex_normals(
    vertices: wp.array[wp.vec3],
    faces: wp.array[wp.int32],
    *,
    weighting: Literal["area", "angle", "mwselr"] = "area",
    face_normals: wp.array[wp.vec3] | None = None,
    face_weights: wp.array[wp.float32] | twt.Array2dFloat32 | None = None,
) -> wp.array[wp.vec3]:
    """
    Per-vertex unit normals, as a weighted average of the incident face normals.

    A vertex has no normal of its own -- only the faces around it do -- so the weight is the whole
    content of the choice, and ``weighting`` selects it. Contributions are summed per vertex and
    L2-normalized, so any constant factor in the weights cancels.

    Which weighting to want is a measurable question, not a preference: one reference library
    ships both conventions as separate entry points, and its plain per-vertex normals reproduce
    ``"area"`` to **1.19e-07** while its pseudo-normals reproduce ``"angle"`` to **1.19e-07** --
    each sitting **6.8e-03** from the other's partner, so the two are distinguishable well above
    float32 noise. Reach for ``"angle"`` when the answer must not depend on
    how a neighbouring polygon happened to be triangulated -- it is the only one of the three that
    is invariant to that -- and for ``"area"`` otherwise, since it is what libigl and most of the
    field default to.

    Parameters
    ----------
    vertices
        ``(n_vertices,)`` mesh vertex positions. The output length is ``vertices.shape[0]``.
    faces
        Length-``3 * n_faces`` ``wp.int32`` triangle index buffer.
    weighting
        Which average to take.

        - ``"area"`` (default) weights each face by its area, matching libigl's default
          ``per_vertex_normals`` (up to the constant ``2`` from ``doublearea``, which normalization
          cancels).
        - ``"angle"`` weights each face by its interior angle at the corner vertex -- the
          Thuerrner/Wuethrich recipe (*Computing Vertex Normals from Polygonal Facets*, Journal of
          Graphics Tools 3:1, 43-46, 1998) and
          ``igl::PER_VERTEX_NORMALS_WEIGHTING_TYPE_ANGLE``. The triangulation-invariant one.
        - ``"mwselr"`` weights each corner by ``sin(angle) / (||E_i|| ||E_{i+1}||)``, which reduces
          to ``(e1 x e2) / (||e1||^2 ||e2||^2)`` for the two outgoing edges -- Nelson Max's Mean
          Weighted by Sine and Edge Length Reciprocals, optimized for smooth surface reconstruction
          (Jin et al., 2005).
    face_normals
        Precomputed ``(n_faces,)`` face normals, to skip deriving them. Under ``"mwselr"``, passing
        them asserts they are *unit* length; omitting them lets the unnormalized cross product be
        used instead, which is cheaper and gives the same answer after normalization.
    face_weights
        Precomputed weights, to skip deriving them. The expected shape follows ``weighting``:
        ``(n_faces,)`` per-face areas for ``"area"``, and ``(n_faces, 3)`` per-corner angles for
        ``"angle"``. Not accepted under ``"mwselr"``, whose weights are a joint function of the
        corner positions rather than a table a caller would hold.

    Returns
    -------
    wp.array[wp.vec3]
        Length-``n_vertices`` unit normals on ``vertices.device``, zero wherever the accumulated
        vector was zero (an unreferenced vertex, or a cancelling fan).

    Raises
    ------
    ValueError
        If ``weighting`` is not one of the three names, if ``face_weights`` is passed with
        ``weighting="mwselr"``, or if a supplied ``face_normals`` / ``face_weights`` does not have
        one row per triangle in ``faces``.
    RuntimeError
        If ``vertices``, ``faces``, ``face_normals`` and ``face_weights`` are not all on one
        device.

    See Also
    --------
    [`weighted_vertex_normals`][triwarp.vertices.weighted_vertex_normals]
        The accumulator underneath: a caller-supplied per-corner weight table, no geometry.
    [`mean_vertex_normals`][triwarp.vertices.mean_vertex_normals]
        The same accumulator with unit weights.
    [`triwarp.triangles.face_normals_and_areas`][triwarp.triangles.face_normals_and_areas]
        Produces the ``face_normals`` / ``face_weights`` the ``"area"`` path derives.
    [`triwarp.triangles.face_angles`][triwarp.triangles.face_angles]
        Produces the ``"angle"`` path's weight table.
    """
    require_same_device(
        vertices=vertices, faces=faces, face_normals=face_normals, face_weights=face_weights
    )
    if weighting not in ("area", "angle", "mwselr"):
        raise ValueError(f'weighting must be "area", "angle" or "mwselr", got {weighting!r}')
    if weighting == "mwselr" and face_weights is not None:
        raise ValueError('face_weights is not accepted with weighting="mwselr"')

    device = vertices.device
    n_vertices = int(vertices.shape[0])
    n_faces = int(faces.shape[0]) // 3

    if weighting == "mwselr":
        if n_faces == 0:
            return wp.zeros(n_vertices, dtype=wp.vec3, device=device)
        # A supplied ``face_normals`` is unit length; a derived one is the raw cross product, and
        # the weight depends on which, because the sine it divides out is already in the cross
        # product's magnitude. Deriving them takes one launch that produces both answers from a
        # single cross product; a supplied set takes the weight pass alone.
        corner_weights = twt.empty_2d((n_faces, 3), wp.float32, device=device)
        if face_normals is None:
            face_normals = wp.empty(n_faces, dtype=wp.vec3, device=device)
            wp.launch(
                kernel_vertices.face_crosses_and_weights,
                dim=n_faces,
                inputs=[vertices, faces, face_normals, corner_weights],
                device=device,
            )
        else:
            wp.launch(
                kernel_vertices.max_vertex_normal_weights,
                dim=n_faces,
                inputs=[vertices, faces, corner_weights],
                device=device,
            )
        return weighted_vertex_normals(n_vertices, faces, face_normals, corner_weights)

    if weighting == "angle":
        if face_normals is None:
            face_normals, _areas = tw.triangles.face_normals_and_areas(vertices, faces)
        angles = tw.triangles.face_angles(vertices, faces) if face_weights is None else face_weights
        return weighted_vertex_normals(
            n_vertices, faces, face_normals, twt.as_array2d(angles, wp.float32)
        )

    # "area": scale each face normal by its area once, then accumulate with unit weights -- the
    # weight is per *face* here rather than per corner, so this is the cheaper of the two paths.
    if face_normals is None or face_weights is None:
        computed_normals, computed_areas = tw.triangles.face_normals_and_areas(vertices, faces)
        if face_normals is None:
            face_normals = computed_normals
        if face_weights is None:
            face_weights = computed_areas
    _require_face_rows(n_faces, face_normals=face_normals, face_weights=face_weights)
    scaled_normals = wp.empty(n_faces, dtype=wp.vec3, device=device)
    wp.map(wp.mul, face_normals, face_weights, out=scaled_normals)
    return mean_vertex_normals(n_vertices, faces, scaled_normals)

vertex_defects

vertex_defects(
    n_vertices: int,
    faces: array[int32],
    face_angles: Array2dFloat32,
) -> wp.array[wp.float32]

Discrete angle defect per vertex: 2π minus the sum of incident corner angles.

For each vertex, interior angles from every triangle corner that references that vertex are accumulated in float32 on faces.device, then subtracted from a full turn. This is the standard piecewise-linear angle defect (related to discrete Gaussian curvature via the Gauss—Bonnet viewpoint on triangle meshes).

Parameters:

Name Type Description Default
n_vertices int

Number of vertices indexed by faces (output length).

required
faces array[int32]

Triangle indices as wp.int32; interpreted as (f, 3) via reshape((-1, 3)) (row-major flat layout is fine).

required
face_angles Array2dFloat32

Interior angles at the three corners of each triangle, shape (f, 3) as twt.Array2dFloat32, with rows aligned with faces.

required

Returns:

Type Description
array[float32]

Length-n_vertices device array 2π - Σ angles at each vertex. Vertices not referenced by any face have defect 2π (empty angle sum).

Raises:

Type Description
ValueError

If face_angles does not have one row per triangle in faces.

RuntimeError

If faces and face_angles are not all on one device.

See Also

discrete_gaussian_curvature The same quantity at a scale: the Cohen-Steiner/Morvan ball measure sums these defects over a ball of given radius, where this is the pointwise value at one vertex. The ball measure is what converges under refinement; the pointwise defect does not. face_angles The angles this sums.

Source code in triwarp/vertices.py
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 vertex_defects(
    n_vertices: int, faces: wp.array[wp.int32], face_angles: twt.Array2dFloat32
) -> wp.array[wp.float32]:
    """
    Discrete angle defect per vertex: ``2π`` minus the sum of incident corner angles.

    For each vertex, interior angles from every triangle corner that references that vertex
    are accumulated in ``float32`` on ``faces.device``, then subtracted from a full turn.
    This is the standard piecewise-linear angle defect (related to discrete Gaussian
    curvature via the Gauss—Bonnet viewpoint on triangle meshes).

    Parameters
    ----------
    n_vertices
        Number of vertices indexed by ``faces`` (output length).
    faces
        Triangle indices as ``wp.int32``; interpreted as ``(f, 3)`` via ``reshape((-1, 3))``
        (row-major flat layout is fine).
    face_angles
        Interior angles at the three corners of each triangle, shape ``(f, 3)`` as
        ``twt.Array2dFloat32``, with rows aligned with ``faces``.

    Returns
    -------
    wp.array[wp.float32]
        Length-``n_vertices`` device array ``2π - Σ angles`` at each vertex. Vertices not
        referenced by any face have defect ``2π`` (empty angle sum).

    Raises
    ------
    ValueError
        If ``face_angles`` does not have one row per triangle in ``faces``.
    RuntimeError
        If ``faces`` and ``face_angles`` are not all on one device.

    See Also
    --------
    [`discrete_gaussian_curvature`][triwarp.curvature.discrete_gaussian_curvature]
        The same quantity at a *scale*: the Cohen-Steiner/Morvan ball measure sums these defects
        over a ball of given radius, where this is the pointwise value at one vertex. The ball
        measure is what converges under refinement; the pointwise defect does not.
    [`face_angles`][triwarp.triangles.face_angles]
        The angles this sums.
    """
    require_same_device(faces=faces, face_angles=face_angles)
    n_faces = int(faces.shape[0]) // 3
    _require_face_rows(n_faces, face_angles=face_angles)
    angle_sum = wp.zeros(n_vertices, dtype=wp.float32, device=faces.device)
    faces2d = faces.reshape((-1, 3))
    wp.launch(
        kernel_scatter.SCATTER_SUM_SCALAR[face_angles.dtype],
        dim=n_faces,
        inputs=[face_angles, faces2d, angle_sum],
        device=faces.device,
    )
    # In place over the accumulator: ``angle_sum`` is scratch, and the operator spelling
    # ``TWO_PI - angle_sum`` would run the same wp.map into a second allocation.
    wp.map(kernel_predicates.angle_defect, angle_sum, out=angle_sum)
    return angle_sum