diff --git a/render/screw_test.go b/render/screw_test.go index 726ddb738..6dc4793d2 100644 --- a/render/screw_test.go +++ b/render/screw_test.go @@ -145,6 +145,61 @@ 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. +// +// 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) { + 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