Skip to content

Lipschitz twist extrude#88

Open
snowbldr wants to merge 2 commits into
deadsy:masterfrom
snowbldr:lipschitz_twist_extrude
Open

Lipschitz twist extrude#88
snowbldr wants to merge 2 commits into
deadsy:masterfrom
snowbldr:lipschitz_twist_extrude

Conversation

@snowbldr
Copy link
Copy Markdown
Contributor

Fix TwistExtrude3D holes during octree rendering

Fixes one of the bugs in #85.

The bug

TwistExtrude3D evaluates by un-twisting the 3D query point back into the
2D thread frame and asking the 2D SDF for a distance. The un-twist mapping
rotates (x, y) by θ = k·z where k = twist/height; its Jacobian's
maximum singular value is

σ_max = √(1 + k²·r²)     with r² = x² + y²

so a unit step in 3D corresponds to up to σ_max in the un-twisted 2D space.
The 2D SDF therefore returns a value larger than the true 3D distance by
that factor. The octree marching-cubes renderer prunes cubes via
|sdf(center)| ≥ half-diagonal, and an over-stated distance causes it to
skip cubes that actually contain surface — holes.

The uniform renderer is unaffected because it evaluates every cube
unconditionally; the bug only shows up under octree.

The fix

ExtrudeSDF3 grows an invStretch float64 field set at construction.
For TwistExtrude3D:

k     := twist / height
rMax² := max(|bb.Min.X|, |bb.Max.X|+ max(|bb.Min.Y|, |bb.Max.Y|)²
s.invStretch = 1 / √(1 + k²·rMax²)

All surfaces of the extruded shape sit inside the 2D bounding box, so
r ≤ rMax everywhere on the surface and a single global constant
invStretch is a sound conservative bound. Evaluate multiplies its
result by invStretch so the SDF stays a valid Lipschitz-1 estimator
the renderer can prune against.

Extrude3D, ScaleExtrude3D, and ScaleTwistExtrude3D initialize
invStretch = 1 (no correction). Plain Extrude3D is already
1-Lipschitz; ScaleExtrude3D/ScaleTwistExtrude3D have their own
distinct Jacobian shapes and need their own correction (separate PR).

Tests

render/twist_extrude_test.go sweeps twist from 30° through 2 full turns
over both Box2D(4×4) and Box2D(8×1) 2D profiles, asserting zero
boundary edges from the octree renderer at 80 cells. All pass on macOS.

Architecture-specific note

This is the same family of bug as the high-taper screw rendering issue
fixed in #84 — a non-1-Lipschitz SDF that the octree's isEmpty
pruning rule wrongly trusts. Borderline configurations may render
holes on x86_64 (FMA / FTZ rounding differences) but not on Apple
Silicon. The conservative invStretch constant has plenty of margin
either way.

snowbldr added 2 commits May 8, 2026 19:52
The twist mapping rotates (x, y) by θ = k·z where k = twist/height. Its
Jacobian has maximum singular value σ_max = √(1 + k²r²) with
r² = x² + y². The 2D thread SDF returned by Evaluate is in the
un-twisted frame, so it overestimates true 3D distance by up to σ_max.
Without correction, the octree marching-cubes renderer's |sdf(center)|
≥ half-diagonal pruning skips cubes that contain surface, producing
holes.

ExtrudeSDF3 grows an invStretch field set at construction:

    k     = twist/height
    rMax² = max(|bb.Min.X|, |bb.Max.X|)² + max(|bb.Min.Y|, |bb.Max.Y|)²
    invStretch = 1 / √(1 + k²·rMax²)

All surfaces of the extruded shape lie within the 2D bounding box, so
r ≤ rMax and a single global constant is conservative everywhere.
Evaluate multiplies its result by invStretch to scale the over-stated
distance back to a valid Lipschitz-1 estimator. Plain Extrude3D and the
scale variants set invStretch = 1 (no correction); their separate
Lipschitz issues are out of scope for this PR (Scale/ScaleTwist need a
2D-Jacobian correction tracked elsewhere).

render/twist_extrude_test.go: watertight test sweeping twist from 30°
to 2 full turns over both square and thin-rect 2D profiles, asserting
zero boundary edges from the octree renderer at 80 cells.
…figs

Cover the full failure space for the σ_max bound: 7 shape profiles
(square, thin rect, circle, triangle, rounded square — and
factories for negative-quadrant / off-axis cases left commented out
pending the sibling bbox-fix PR) × 12 twist configurations
(zero, fractions of a turn, full turn, multiple turns, negative,
varied heights). Plus a high-resolution stress pass at cells=200 on
the worst-case configurations that's gated by -short.

Each combination asserts zero boundary edges from the octree. Pure
twist=0 is also pinned to verify k=0 is a no-op.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant