Skip to content

fix Screw3D Lipschitz under-correction at high taper#87

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

fix Screw3D Lipschitz under-correction at high taper#87
snowbldr wants to merge 2 commits into
deadsy:masterfrom
snowbldr:fix_watertight_failures

Conversation

@snowbldr
Copy link
Copy Markdown
Contributor

@snowbldr snowbldr commented May 9, 2026

Fix Screw3D Lipschitz under-correction at high taper

Fixes the watertight-test failures reported on
examples/screw_assortment at cells=300 for high-taper configurations:

config boundary edges (before)
taper_45 32
taper_30_coarse 142
taper_30_buttress 68
taper_30_left_buttress 68

Heads-up: I couldn't reproduce locally

Every config above renders with 0 boundary edges on my machine
(macOS / Apple Silicon) at cells=300, cells=600, and the existing
unit tests at cells=100. The failures are platform-specific
floating-point artifacts.

The most likely cause is FMA (fused multiply-add) emission differences
between architectures. The Go compiler emits fmadd on arm64 for
expressions of the shape a*b + c*d (one rounding step) but typically
emits separate mulsd; addsd on amd64 (two rounding steps). The screw
σ_max formula has exactly this shape:

disc := (a-c)*(a-c) + 4*s.tan2Taper
stretch2 := (a + c + math.Sqrt(disc)) * 0.5
d /= math.Sqrt(stretch2)

A 1-ulp difference in disc propagates through Sqrt, through the
divide, and into the final reported distance. The octree's pruning rule
is |sdf(center)| ≥ half-diagonal — exactly the kind of comparison
that flips on 1-ulp shifts when the SDF is borderline. At high taper
the existing correction was just tight enough; either rounding
direction kept it on the right side on macOS/arm64 but pushed it off
on Linux/amd64.

Secondary suspects (less likely but worth knowing about):

  • SSE FTZ/DAZ defaults on x86. If subnormals appear anywhere in
    the helical math they get flushed to zero on x86 by default but
    preserved on ARM. Could shift values by far more than 1 ulp.
  • Go's pure-Go math (Sqrt, Atan2, Sin, Cos) is bit-for-bit
    deterministic across platforms
    given identical inputs, so it
    isn't the source — the non-determinism comes from how the compiler
    generates the surrounding multiplies and adds.

The actual bug

ScrewSDF3.Evaluate computes
rEff = max(r_query - threadDepth, r_query/2) and feeds that into
σ_max. For a cylindrical screw the closest surface to any query is
near r_query, so this is a tight bound. For a tapered screw the cone
narrows along z, and the closest surface to a query can sit at much
smaller r — a query at the wide end can have its closest surface at
the cone's narrow end if the cube spans z. The existing correction
under-estimated σ_max for those queries, the SDF over-reported
distance, the octree's isEmpty check skipped cubes that contain
surface, and the rendered mesh had holes.

Fix

At construction, compute the smallest possible surface r anywhere on
the screw:

s.rSurfaceMin = bb.Max.Y - s.length*s.tanTaper - s.threadDepth
if floor := bb.Max.Y * 0.05; s.rSurfaceMin < floor {
    s.rSurfaceMin = floor
}

The 5%-of-crest floor keeps σ_max bounded when the cone pinches to
or past the axis (e.g. taper=45° length=20 R=5 has the cone tip at
z=5, well inside the screw's z range).

In Evaluate, clamp rEff from above by rSurfaceMin:

if rEff > s.rSurfaceMin {
    rEff = s.rSurfaceMin
}

For a cylindrical screw rSurfaceMin = root radius, so the clamp is
a no-op for queries inside the screw radius and a small extra
correction outside. For tapered screws it pulls the bound down to
whatever the narrowest part of the cone produces — conservative but
correct, and tight enough to absorb the 1-ulp wobble that was the
proximate cause of the platform-specific holes.

Tests

render/screw_test.go gains Test_Screw_Watertight_HighTaperHighRes
with the four reported configurations at cells=300 (matching the
assortment example). Passes locally; should also pass on the
Linux/amd64 box that originally reported the failures.

ScrewSDF3.Evaluate computes rEff = max(r - threadDepth, r/2) where r is
the query point's radial distance, then uses rEff in the Lipschitz σ_max
formula. For a cylindrical screw the closest surface to any query is near
r_query so this is a tight bound, but for a tapered screw the cone
narrows along z and the closest surface to a query can sit at much
smaller r — e.g. a query at the wide end can have its closest surface at
the cone's narrow end if the cube spans z.

Without clamping rEff at the global minimum surface r, σ_max gets
under-estimated for those queries, the SDF over-reports distance, and
the octree's isEmpty check skips cubes that contain surface. The result
is borderline floating-point: holes appear on Linux and Windows but not
on macOS for the same configurations (jasonh@eccles reported 4 of 41
configs failing in examples/screw_assortment at cells=300:
  taper_45 (32 boundary edges)
  taper_30_coarse (142)
  taper_30_buttress (68)
  taper_30_left_buttress (68))

Fix: at construction compute rSurfaceMin = bb.Max.Y − length·tan(taper)
− threadDepth (the smallest possible surface r anywhere on the screw),
floored at 5% of the crest radius so σ_max stays bounded when the cone
pinches to or past the axis (e.g. taper=45° length=20 R=5 has a cone
tip at z=5). In Evaluate, clamp rEff to rSurfaceMin from above before
the σ_max calculation. For cylindrical screws this is a no-op for
queries inside the screw radius and a small extra correction outside.

render/screw_test.go: pin the 4 failing configs as a high-resolution
watertight test at cells=300 to match the assortment example so future
regressions surface.
Was 4 originally-reported failing configs; now 14 covering the wider
hole-prone space:
  - the 4 originals (taper 30/45 × iso/buttress)
  - taper sweep at 35°, 40°, 45° on iso and buttress
  - high-taper multi-start (4 and 8 starts)
  - alternative profiles at high taper (acme, plastic-buttress, iso-int)
  - longer cone (length=40) — narrows further
  - fine pitch (p=1) at high taper — small rEff at the tip stresses
    the rSurfaceMin floor

All assert zero boundary edges from the octree at cells=300 (matching
the assortment example).
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