A twisted limb, weighed and traced
Twist a skinned limb about its own bone and linear blend skinning (each vertex follows a weighted average of its bones' transforms) squeezes the joint into a pinched waist that animators call a candy wrapper. How much volume does the limb lose? Let's pose a rigged glTF, turn its mesh into a Manifold solid, measure it, and send the same solid to the path tracer.
Field can also measure the narrowest ring with DualQuaternionSkinning.candyWrapperRadius.
Here, afield.graphics.scene skins the file on the CPU, afield.geometry.solid.manifold makes and
measures the solid, and afield.graphics.scene.fabricated traces it.
The render below shows the same limb skinned both ways.

Both tubes are the same file at the same pose: the upper bone turned 180° about its own axis. The nearer, cyan tube was skinned by linear blending and the tan one behind it by dual quaternions. The cyan tube's middle ring has shrunk to a single point.
Environment: Venice Sunset by Greg Zaal / Poly Haven (CC0), shared with the Fabricate demo assets.
A rig that encloses something
RiggedFixture, the rig the skinning tests use, is an open tube with no caps, so it encloses no
volume and Manifold will not take it as a solid. CappedTwistFixture is the same two-bone rig with
both ends closed. Like RiggedFixture, it is written out by Kotlin source
(CappedTwistFixture.kt) rather
than downloaded:
- 12 sides, radius 0.5, height 4, with rings every 0.25 along the bone and a centre vertex on each cap. That makes 206 vertices and 408 triangles, and every edge is shared by exactly two of them.
boneLowerat the origin,boneUpperaty = 2. The weight toboneUpperisclamp((y − 1) / 2, 0, 1), so the joint zone runs fromy = 1toy = 3and the ring aty = 2is an even 50/50 blend.- One clip,
twist, turnsboneUpperfrom 0° to 180° about+y. The sampler slerps, so the angle is linear in time andsecondsFor(degrees)converts.
At rest the tube is a straight prism over a regular 12-gon, and the volume has a closed form: area \( \tfrac{1}{2} \cdot 12 \cdot 0.5^2 \cdot \sin 30^\circ = 0.75 \), times height 4, is 3.0.
Volume against twist
This box poses the limb every fifteen degrees from rest to a half turn, under both skinning methods, and plots the enclosed volume Manifold reports for each pose.
:::static
import afield.geometry.solid.manifold.toSolid
import afield.graphics.scene.GltfSkinning.SkinningMethod
import afield.graphics.scene.GltfSkinning.SkinningOptions
val rig = CappedTwistFixture.locate()
val twists = (0..12).map { it * 15f }
// Pose the file, hand the triangles to Manifold, read back the enclosed volume.
fun volumeAt(degrees: Float, method: SkinningMethod): Double =
skinnedGltfToTriMesh(
rig,
clip = "twist",
seconds = CappedTwistFixture.secondsFor(degrees),
options = SkinningOptions(method = method),
)
.toSolid()
.use { it.volume }
:::fold sweep both methods and plot them against the rest volume
val lbs = twists.map { volumeAt(it, SkinningMethod.LINEAR_BLEND) }
val dqs = twists.map { volumeAt(it, SkinningMethod.DUAL_QUATERNION) }
showSignals(
"linear blend" to lbs,
"dual quaternion" to dqs,
"rest, closed form" to twists.map { CappedTwistFixture.REST_VOLUME },
x = twists,
channel = "main",
title = "Enclosed volume at 180°: linear blend %.4f, dual quaternion %.4f".format(lbs.last(), dqs.last()),
xTitle = "twist of boneUpper about its own axis (degrees)",
yTitle = "Manifold volume",
)
:::endfold

Under linear blending the volume falls at every step. Under dual quaternions it goes slightly up. These are the two familiar artefacts of the methods, a pinch and a bulge, seen as volume.
Four poses, weighed twice
This box measures four poses with two independent volume calculations, Manifold's and Field's own
signedVolume, alongside the waist radius candyWrapperRadius reports.
:::static
import afield.geometry.solid.manifold.toSolid
import afield.graphics.scene.GltfSkinning.SkinningMethod
import afield.graphics.scene.GltfSkinning.SkinningOptions
val rig = CappedTwistFixture.locate()
:::fold measure rest and a half turn, both methods, both volume implementations
fun row(label: String, degrees: Float, method: SkinningMethod): String {
val mesh = skinnedGltfToTriMesh(
rig,
clip = "twist",
seconds = CappedTwistFixture.secondsFor(degrees),
options = SkinningOptions(method = method),
)
val waist = DualQuaternionSkinning.candyWrapperRadius(
mesh.positions,
CappedTwistFixture.UPPER_JOINT_Y,
tolerance = 0.01f,
)
return mesh.toSolid().use { solid ->
val field = mesh.signedVolume()
"| $label | ${mesh.isWatertight()} | ${solid.genus} | %.6f | %.6f | %.1e | %.4f | %.4f |"
.format(
solid.volume,
field,
kotlin.math.abs(solid.volume - field),
solid.volume / CappedTwistFixture.REST_VOLUME,
waist,
)
}
}
markdown(
"chain",
"""
| pose | watertight | genus | Manifold volume | `signedVolume` | gap | of rest | waist radius |
|---|---|---|---|---|---|---|---|
${row("rest", 0f, SkinningMethod.LINEAR_BLEND)}
${row("linear blend, 179.9°", 179.9f, SkinningMethod.LINEAR_BLEND)}
${row("linear blend, 180°", 180f, SkinningMethod.LINEAR_BLEND)}
${row("dual quaternion, 180°", 180f, SkinningMethod.DUAL_QUATERNION)}
Closed forms: rest ${CappedTwistFixture.REST_VOLUME}; linear blend at 180°, 0.75 × (2 + 2/3) = ${(0.75 * (2.0 + 2.0 / 3.0)).f6}.
""",
width = 1000,
)
Unit
:::endfold

Manifold's volume and Field's signedVolume share no code. One integrates Manifold's own
half-edge solid in double precision, the other sums signed tetrahedra over the float triangles.
Where they agree, the skinned mesh reached Manifold intact.
The two thirds that linear blending keeps at 180° can be worked out by hand. At the 50/50 ring the blend
averages the identity with its own half turn, \( \tfrac{1}{2}\operatorname{diag}(1,1,1) + \tfrac{1}{2}\operatorname{diag}(-1,1,-1) = \operatorname{diag}(0,1,0) \), so
every vertex of that ring lands on the axis. In general, a ring whose weight is w gets
\( \operatorname{diag}(1 - 2w, 1, 1 - 2w) \): a pure scale with no rotation left in it. Its scale \( 1 - 2w = 2 - y \)
is linear in y, so each band of the joint zone is a frustum of two similar 12-gons. The zone then
integrates exactly to \( 0.75 \cdot \int_1^3 (2 - y)^2\,dy = 0.75 \cdot \tfrac{2}{3} \), and the two unblended units keep their
0.75 each. The total is 2.0, so a third of the limb is gone, and none of that loss comes from how
the tube happens to be triangulated.
Why the 180° row has a gap the others do not
At 180° exactly, the 50/50 ring does not quite shrink to a point. Float rounding in the
half-turn quaternion leaves it about 2·10⁻⁷ across, which is under the tolerance Manifold
gives a mesh of this size (about 5·10⁻⁷). Manifold keeps every vertex and every triangle it was
handed, but it swaps edges in that collar, so the volume it reports is the volume of its
triangulation. At 179.9° the ring is well above tolerance and the gap disappears. If a measured
volume matters at a degenerate pose, compare it with signedVolume on the mesh you handed over.
What crosses each hop
| hop | the call | what crosses | what stops here |
|---|---|---|---|
| file to posed triangles | skinnedGltfToTriMesh with SkinningOptions(method = …) |
positions and normals, scene space, one vertex per file vertex | the skeleton, the weights and the clip. What comes out is a plain TriMesh |
| triangles to a solid | toSolid |
positions and indices | the normals. Manifold gets three floats per vertex and nothing else |
| a solid back to triangles | toTriMesh(normals = true) |
positions, indices, normals split at a crease angle | shared vertices along the creases. Measure the solid, not this mesh |
| triangles to a scene | importTriMesh |
positions, normals, one material, a transform | zero-area triangles, which are counted and skipped |
Linear blending and dual quaternions are the same call with a different SkinningOptions, so the
comparison above runs both on one file. The only difference between the two
tubes in the render is that method argument.
Where each piece is documented
- Skinned Characters is the transcript that introduced skinning, the bending cylinder, and the candy wrapper measured as a radius.
- Solids & Booleans (Manifold) covers
toSolid, what it refuses and why, and the status model. - Progressive Path Tracing covers the tracer, its materials and its two traversal backends.
- A polytope, cut open, and traced does the same last two hops with a Conway polyhedron at the front.
Why the fixture has so many rings
RiggedFixture's tube has five rings, and only the middle one is blended. At a half turn, dual
quaternions rotate that ring 90° against each neighbour, and a straight triangulated band
between two 12-gons 90° apart passes within \( r/\sqrt{2} \) of the axis, so the triangulation alone
would lose about a third of those bands' volume. Seventeen rings spread the joint's rotation
over eight bands, 22.5° each at the worst pose, so the volume measures the skinning and not the
mesh.