Skip to content

Lipschitz scale extrude#92

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

Lipschitz scale extrude#92
snowbldr wants to merge 2 commits into
deadsy:masterfrom
snowbldr:lipschitz_scale_extrude

Conversation

@snowbldr
Copy link
Copy Markdown
Contributor

Fix ScaleExtrude3D / ScaleTwistExtrude3D Lipschitz under-correction

Fixes one of the bugs in #85.

The bug

Both Evaluate paths un-scale (and un-twist) the 3D query before
forwarding to a 2D SDF. The inverse map's Jacobian is non-orthonormal —
the linearly-varying scale factor contributes off-diagonal entries from
∂/∂z — so the returned 2D distance overstates true 3D distance by
σ_max(J) > 1. The octree marching-cubes renderer's |sdf(center)| ≥ half-diagonal pruning then skips cubes that contain surface, producing
holes.

For ScaleExtrude3D alone the σ_max comes from a 2×2 JJᵀ with diagonal
scale entries plus a z-column. For ScaleTwistExtrude3D the rotation
column adds shear that couples (x, y) into the z-derivative.

The fix

ExtrudeSDF3 grows an invStretch float64 field. Both ScaleExtrude3D
and ScaleTwistExtrude3D compute σ_max² at construction and store
invStretch = 1/√σ²; Evaluate multiplies by it.

ScaleExtrude3D. The 2×2 JJᵀ is

| s_x² + m_x²·x²        m_x·m_y·x·y       |
| m_x·m_y·x·y           s_y² + m_y²·y²    |

with λ_max = (A+C)/2 + √((A-C)²/4 + B²). λ_max is monotone in x², y²
and in s_x², s_y², so its maximum over the volume is attained at one
of the two z endpoints with |x|, |y| at their bbox extremes — checked
explicitly.

ScaleTwistExtrude3D. The rotation matrix is orthogonal so σ_max is
invariant to it; the rotation-free Jacobian's z-column becomes

∂z = (m_x·x − k·y·s_y, k·x·s_x + m_y·y),    k = twist/height

λ_max(x,y) at fixed z is a convex quadratic form in (x,y), so its
max over the 2D bbox is also attained at a corner. We scan the
2 z-endpoints × 4 (x,y) corners — 8 evaluations per construction.

Plain Extrude3D and TwistExtrude3D set invStretch = 1 here; their
separate Lipschitz issues are out of scope for this PR (TwistExtrude3D
is fixed in a sibling PR — same invStretch field).

Tests

render/scale_extrude_test.go:

  • Test_ScaleExtrude3D_Watertight — uniform shrink (2x, 3x), anisotropic
    shrink (0.4, 0.7), uniform grow (2x). Octree at 80 cells, zero
    boundary edges.
  • Test_ScaleTwistExtrude3D_Watertight — twist 30°/90°/180°/360° crossed
    with shrink + an anisotropic case.

All pass on macOS.

Architecture-specific note

Same family of bug as the high-taper screw rendering issue from #84
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 closed-form
λ_max bound has plenty of slack either way.

Dependency note

The invStretch plumbing on ExtrudeSDF3 overlaps with the sibling
TwistExtrude3D Lipschitz fix PR. Whichever lands first sets up the
field; the second needs a trivial rebase to drop the duplicate
declaration. The Scale-specific math here is otherwise independent.

snowbldr added 2 commits May 8, 2026 20:09
Both Evaluate paths un-scale (and un-twist) the query before forwarding
to a 2D SDF. The Jacobian of the inverse map is non-orthonormal — its
2x2 JJᵀ has off-diagonal contributions from ∂/∂z of the linearly-
varying scale factor — so the returned 2D distance overstates true 3D
distance by σ_max(J) > 1. The octree marching-cubes renderer's
|sdf(center)| ≥ half-diagonal pruning then drops cubes that contain
surface, producing holes.

ExtrudeSDF3 grows an invStretch field. For ScaleExtrude3D:

    A = s_x² + m_x²·x², C = s_y² + m_y²·y², B² = m_x²·m_y²·x²·y²
    λ_max = (A+C)/2 + √((A-C)²/4 + B²)

λ_max is monotone in x², y² and in s_x², s_y², so its max over the
volume is attained at one of the two z endpoints with |x|, |y| at
their bbox extreme.

For ScaleTwistExtrude3D the rotation column adds j13 = m_x·x − k·y·s_y
and j23 = k·x·s_x + m_y·y to the Jacobian. λ_max(x,y) at fixed z is a
convex quadratic form in (x,y), so its max over the 2D bbox is also
attained at a corner. We scan the 2 (z) × 4 (xy corners) extreme cells.

Plain Extrude3D and TwistExtrude3D set invStretch = 1; their separate
Lipschitz issues are out of scope for this PR (TwistExtrude3D is
fixed in a sibling PR).

render/scale_extrude_test.go: watertight tests for both functions
across uniform shrink, anisotropic shrink, and grow scales (plus a
sweep of twist angles for the twisted variant). All assert zero
boundary edges from the octree at 80 cells.
…one configs

5 2D profiles (square, thin rect, rounded box, circle, triangle) ×

  ScaleExtrude3D: 9 configs covering identity, modest shrink/grow,
    anisotropic, extreme 4× ratios, tall (small m_x) and short (large
    m_x) heights.

  ScaleTwistExtrude3D: 11 configs covering twist=0 reduction to scale,
    modest twist (30/90/180), full and 2-turn rotations, anisotropic
    scale + twist, negative twist, short-extrusion stress.

Per-axis Lipschitz bound is exercised across the full range. All
assert zero boundary edges from the octree at cells=80.
@snowbldr snowbldr force-pushed the lipschitz_scale_extrude branch from b7486b1 to bd3223b Compare May 10, 2026 22:16
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