From f2696f4390165cea2618637a4c4aa202e1d45acf Mon Sep 17 00:00:00 2001 From: snowbldr Date: Fri, 8 May 2026 16:07:48 -0600 Subject: [PATCH 1/2] fix Screw3D Lipschitz under-correction at high taper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- render/screw_test.go | 30 ++++++++++++++++++++++++++++++ sdf/screw.go | 25 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/render/screw_test.go b/render/screw_test.go index 726ddb738..ab3cc1025 100644 --- a/render/screw_test.go +++ b/render/screw_test.go @@ -145,6 +145,36 @@ func Test_Screw_Watertight_Tapered(t *testing.T) { } } +// Test_Screw_Watertight_HighTaperHighRes pins the configurations from the +// screw_assortment example that produced boundary edges on Linux at the +// example's cells=300 resolution: high-taper screws where the cone narrows +// far enough that the closest surface r at any query may be much smaller +// than r_query - threadDepth. Without clamping rEff at rSurfaceMin, the +// Lipschitz correction underestimates σ_max for those queries and the +// octree isEmpty check skips cubes that contain surface. +// +// Cell count matches the example so the borderline FP regime that +// surfaces these failures is exercised here too. +func Test_Screw_Watertight_HighTaperHighRes(t *testing.T) { + cases := []screwTestConfig{ + {"taper_45", 5, 2, 20, 45, 1, 300, "iso"}, + {"taper_30_coarse", 5, 5, 20, 30, 1, 300, "iso"}, + {"taper_30_buttress", 5, 2, 20, 30, 1, 300, "buttress"}, + {"taper_30_left_buttress", 5, 2, 20, 30, -1, 300, "buttress"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + screw := buildScrew(t, c) + tris := CollectTriangles(screw, NewMarchingCubesOctree(c.cells)) + be := CountBoundaryEdges(tris) + if be != 0 { + t.Errorf("octree mesh has %d boundary edges (want 0 for watertight)", be) + } + t.Logf("%d tris, %d boundary edges", len(tris), be) + }) + } +} + func Test_Screw_EndCap_Position(t *testing.T) { // Verify octree and uniform renderers agree on end-cap Z positions. configs := straightScrewConfigs() diff --git a/sdf/screw.go b/sdf/screw.go index eb2cc9768..c7ec35ba0 100644 --- a/sdf/screw.go +++ b/sdf/screw.go @@ -394,6 +394,7 @@ type ScrewSDF3 struct { tanTaper float64 // tan(taper), precomputed tan2Taper float64 // tan²(taper), precomputed threadDepth float64 // radial extent of thread profile (max.Y - min.Y) + rSurfaceMin float64 // lower bound on surface r anywhere on the screw starts int // number of thread starts bb Box3 // bounding box } @@ -469,6 +470,18 @@ func Screw3D( // add the taper increment r += s.length * s.tanTaper s.bb = Box3{v3.Vec{-r, -r, -s.length}, v3.Vec{r, r, s.length}} + // Lower bound on surface r anywhere on the screw, used to clamp rEff + // in Evaluate. For a cylindrical screw this equals the root radius + // (thread.Max.Y - threadDepth). For a tapered screw the cone narrows + // toward the top: at z = +length/2 the crest sits at thread.Max.Y − + // length·tan(taper), and the root is threadDepth shallower than that. + // At sharp tapers the cone may pinch to or past the axis — floor at + // 5% of the crest radius so σ_max stays bounded; the renderer will + // over-evaluate cubes near the cone tip but won't punch holes. + s.rSurfaceMin = bb.Max.Y - s.length*s.tanTaper - s.threadDepth + if floor := bb.Max.Y * 0.05; s.rSurfaceMin < floor { + s.rSurfaceMin = floor + } return &s, nil } @@ -552,11 +565,23 @@ func (s *ScrewSDF3) Evaluate(p v3.Vec) float64 { // be at a smaller r where the stretch is larger. Using r reduced by the // thread depth gives a conservative bound: the nearest surface point // on the thread can be at most threadDepth closer to the axis. + // + // For tapered screws the cone narrows along z, so the closest surface + // point at any query may sit at a much smaller r than r_query allows + // for — e.g. a query at the wide end can have its closest surface at + // the narrow end of the cone if the cube spans z. Clamping rEff at + // rSurfaceMin (the smallest surface r anywhere on the screw) keeps + // the bound conservative across the full z range. Cylindrical screws + // have rSurfaceMin = root radius and this clamp is a no-op for queries + // inside the screw radius, a small extra correction outside. if r > 0 { rEff := r - s.threadDepth if rEff < r*0.5 { rEff = r * 0.5 } + if rEff > s.rSurfaceMin { + rEff = s.rSurfaceMin + } k2 := s.leadOver2π / rEff k2 *= k2 a := 1 + k2 From bb33f8aff1246be0439172f607a9ee98cea2990c Mon Sep 17 00:00:00 2001 From: snowbldr Date: Sat, 9 May 2026 18:44:19 -0600 Subject: [PATCH 2/2] test: expand high-taper screw watertight matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- render/screw_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/render/screw_test.go b/render/screw_test.go index ab3cc1025..6dc4793d2 100644 --- a/render/screw_test.go +++ b/render/screw_test.go @@ -155,12 +155,37 @@ func Test_Screw_Watertight_Tapered(t *testing.T) { // // Cell count matches the example so the borderline FP regime that // surfaces these failures is exercised here too. +// +// Coverage: +// - the four originally-reported failing configs +// - a wider taper sweep (35°, 40°) at standard pitch/length +// - multi-start at high taper +// - alternative thread profiles (acme, plastic-buttress) at high taper +// - varied length (longer cone narrows further) +// - very fine pitch at high taper (small rEff → huge σ_max) func Test_Screw_Watertight_HighTaperHighRes(t *testing.T) { cases := []screwTestConfig{ + // Originally-reported failing configs. {"taper_45", 5, 2, 20, 45, 1, 300, "iso"}, {"taper_30_coarse", 5, 5, 20, 30, 1, 300, "iso"}, {"taper_30_buttress", 5, 2, 20, 30, 1, 300, "buttress"}, {"taper_30_left_buttress", 5, 2, 20, 30, -1, 300, "buttress"}, + // Wider taper sweep at standard pitch/length. + {"taper_35_iso", 5, 2, 20, 35, 1, 300, "iso"}, + {"taper_40_iso", 5, 2, 20, 40, 1, 300, "iso"}, + {"taper_45_buttress", 5, 2, 20, 45, 1, 300, "buttress"}, + // High-taper multi-start. + {"taper_30_4start", 5, 2, 20, 30, 4, 300, "iso"}, + {"taper_30_8start", 5, 2, 20, 30, 8, 300, "iso"}, + // Alternative profiles at high taper. + {"taper_30_acme", 5, 2, 20, 30, 1, 300, "acme"}, + {"taper_30_plastic_butt", 5, 2, 20, 30, 1, 300, "plastic-buttress"}, + {"taper_30_iso_internal", 5, 2, 20, 30, 1, 300, "iso-int"}, + // Longer cone — narrows further. + {"taper_30_long_l40", 5, 2, 40, 30, 1, 300, "iso"}, + // Fine pitch + high taper — small rEff at the tip → huge σ_max + // without rSurfaceMin floor. + {"taper_30_fine_p1", 5, 1, 20, 30, 1, 300, "iso"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) {