Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions render/screw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions sdf/screw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down