From a073449231aceddbac140f284ead5828dac2755c Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 13 Jul 2026 15:25:49 +0200 Subject: [PATCH 1/3] Guard against diverging offset curve curvature --- src/render/discretization.jl | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/render/discretization.jl b/src/render/discretization.jl index 32e42e3ba..999f59569 100644 --- a/src/render/discretization.jl +++ b/src/render/discretization.jl @@ -106,7 +106,7 @@ function discretization_grid( κ1 = _bspline_signed_curvature(s.seg.r, 1.0) max_speed = max(abs(1 - s.offset * κ0), abs(1 - s.offset * κ1)) return discretization_grid( - t -> _offset_bspline_curvature(s, t), + t -> _offset_bspline_curvature(s, t; clamp_radius_ratio=0.1), tolerance; t_scale=l * max_speed, rtol=rtol @@ -127,7 +127,7 @@ function discretization_grid( ) speed1 = sqrt((1 - Paths.getoffset(s, l) * κ1)^2 + Paths.offset_derivative(s, l)^2) return discretization_grid( - t -> _offset_bspline_curvature(s, t), + t -> _offset_bspline_curvature(s, t; clamp_radius_ratio=0.1), tolerance; t_scale=l * max(speed0, speed1), rtol=rtol @@ -155,20 +155,26 @@ end # Offset-BSpline curvature in base spline `t` space, avoiding an arclength-to-`t` # root-find at every discretization step. -function _offset_bspline_curvature(s::Paths.ConstantOffset, t) - return _bspline_curvature(s.seg.r, t) / - abs(1 - s.offset * _bspline_signed_curvature(s.seg.r, t)) +function _offset_bspline_curvature(s::Paths.ConstantOffset, t; clamp_radius_ratio=0.0) + # Near offset-curve cusps (|1 − offset·κ_base| → 0) the true offset curvature + # diverges; clamp the denominator so the kernel doesn't chase sub-tolerance + # bowtie loops. Identical to the true κ_off wherever |1 − offset·κ_base| > ε. + # ε bounds the chord deviation as a multiple of rate of change of base radius + # at ~ε·|dR/ds|·tol (dimensionless |dR/ds| ≈ O(1) for smooth splines). + κ = _bspline_signed_curvature(s.seg.r, t) + return abs(κ) / max(abs(1 - s.offset * κ), clamp_radius_ratio) end # Mirrors curvatureradius(::GeneralOffset, s), including its ignored offset*dκ/ds term. -function _offset_bspline_curvature(s::Paths.GeneralOffset, t) +function _offset_bspline_curvature(s::Paths.GeneralOffset, t; clamp_radius_ratio=0.0) l = Paths.t_to_arclength(s.seg, t) r = 1 / _bspline_signed_curvature(s.seg.r, t) offset = Paths.getoffset(s, l) doffset = Paths.offset_derivative(s, l) d2offset = Paths.ForwardDiff.derivative(l_ -> Paths.offset_derivative(s, l_), l) - ds_dl = 1 / sqrt((1 - offset / r)^2 + doffset^2) + # Same denominator clamp as for ConstantOffset + ds_dl = 1 / max(sqrt((1 - offset / r)^2 + doffset^2), clamp_radius_ratio) d2s_dl2 = -ds_dl^3 * doffset * (d2offset - (1 - offset / r) / r) g = Paths.Interpolations.gradient(s.seg.r, t)[1] @@ -190,8 +196,7 @@ end # Known limitation: the curvature guard below is t_scale-dependent, so it over-refines # short, tight arcs (a 30°/2μm fillet gets ~101 points vs ~10 from circular_arc). # A correct fix must be t_scale-independent while still sampling the middle of -# variable-curvature curves whose endpoints have cc≈0. That touches the broader -# path-rendering pipeline, so it is deferred from the rounding unification. +# variable-curvature curves whose endpoints have cc≈0. function discretization_grid( ddf, tolerance, From 69ff8eb041555c4b9d3aed9e128d10e9ccc84b80 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 13 Jul 2026 16:02:19 +0200 Subject: [PATCH 2/3] Add cusp test --- test/test_bspline.jl | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/test_bspline.jl b/test/test_bspline.jl index aa13bb3f5..d3d9552af 100644 --- a/test/test_bspline.jl +++ b/test/test_bspline.jl @@ -232,6 +232,38 @@ end end end +@testitem "BSpline offset cusps" setup = [CommonTestSetup] begin + pa = Path() + # Maximum base curvature radius ~338um + # No cusp with trace width < 2*radius + bspline!( + pa, + [Point(500.0μm, 500.0μm)], + 90°, + Paths.Trace(600.0μm); + endpoints_speed=800.0μm, + auto_curvature=true + ) + cp = Curvilinear.pathtopolys(pa[1]) + pts_no_cusp = DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) # inner curve + # Cusp + pa = Path() + bspline!( + pa, + [Point(500.0μm, 500.0μm)], + 90°, + Paths.Trace(680.0μm); + endpoints_speed=800.0μm, + auto_curvature=true + ) + cp = Curvilinear.pathtopolys(pa[1]) + pts_cusp = DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) + # Number of points is not excessive + @test length(pts_cusp) < 1.25 * length(pts_no_cusp) + # Discretization is still ~ within tolerance + @test all(is_sliver.(xor2d(to_polygons(cp), to_polygons(cp, atol=0.1nm)), atol=2.0nm)) +end + @testitem "BSpline approximation" setup = [CommonTestSetup] begin pa = Path(Point(0.0, 0.0)nm, α0=90°) bspline!( From f37299076f94d7ba4dfe290dcfb727f8cfa06611 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 13 Jul 2026 20:37:31 +0200 Subject: [PATCH 3/3] Add cusp warning and GeneralOffset guard --- CHANGELOG.md | 1 + src/render/discretization.jl | 16 ++++++++-- test/test_bspline.jl | 62 +++++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51146c6e1..1fc4ad678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format of this changelog is based on ## Unreleased + - Added a warning when cusps are detected when rendering - Added `WithDirection <: GeometryEntityStyle` to annotate geometry entities with a direction (CCW from +x in local frame). The direction transforms with the entity under rotations and reflections, allowing extraction of the final global direction for use in simulation configuration. - Added `SolidModels.check_port_connectivity`, using `SolidModels.connected_components` to report ports as `:open`, `:short`, `:floating`, or `:missing` - Added `detect_non_boundary_contacts=false` keyword argument to `SolidModels.connected_components`; when `true`, 1d edges embedded in the interior of 2D surfaces (like the feet of staple air bridges) will be treated as connecting diff --git a/src/render/discretization.jl b/src/render/discretization.jl index 999f59569..e62b6fd3b 100644 --- a/src/render/discretization.jl +++ b/src/render/discretization.jl @@ -159,9 +159,10 @@ function _offset_bspline_curvature(s::Paths.ConstantOffset, t; clamp_radius_rati # Near offset-curve cusps (|1 − offset·κ_base| → 0) the true offset curvature # diverges; clamp the denominator so the kernel doesn't chase sub-tolerance # bowtie loops. Identical to the true κ_off wherever |1 − offset·κ_base| > ε. - # ε bounds the chord deviation as a multiple of rate of change of base radius + # ε bounds the chord deviation as a multiple of derivative of base radius w.r.t. arclength # at ~ε·|dR/ds|·tol (dimensionless |dR/ds| ≈ O(1) for smooth splines). κ = _bspline_signed_curvature(s.seg.r, t) + isapprox(s.offset * κ, 1.0, atol=1e-3) && _warn_cusp(s.offset, s.seg.r(t)) return abs(κ) / max(abs(1 - s.offset * κ), clamp_radius_ratio) end @@ -174,7 +175,9 @@ function _offset_bspline_curvature(s::Paths.GeneralOffset, t; clamp_radius_ratio d2offset = Paths.ForwardDiff.derivative(l_ -> Paths.offset_derivative(s, l_), l) # Same denominator clamp as for ConstantOffset - ds_dl = 1 / max(sqrt((1 - offset / r)^2 + doffset^2), clamp_radius_ratio) + denom = sqrt((1 - offset / r)^2 + doffset^2) + isapprox(denom, 0.0, atol=1e-3) && _warn_cusp(offset, s.seg.r(t)) + ds_dl = 1 / max(denom, clamp_radius_ratio) d2s_dl2 = -ds_dl^3 * doffset * (d2offset - (1 - offset / r) / r) g = Paths.Interpolations.gradient(s.seg.r, t)[1] @@ -191,6 +194,15 @@ function _offset_bspline_curvature(s::Paths.GeneralOffset, t; clamp_radius_ratio return norm(d2_seg) end +function _warn_cusp(offset, base_pt) + @warn """ + Offset curve has a cusp where the offset $(offset) approaches the curvature radius + of the base curve, near the base curve point $(base_pt). + Check that your geometry is correct—cusps and related self-intersections are usually unwanted. + Some operations may not handle cusps or self-intersecting curves as expected. + """ +end + # Discretize using marching algorithm based on Hessian or curvature. # # Known limitation: the curvature guard below is t_scale-dependent, so it over-refines diff --git a/test/test_bspline.jl b/test/test_bspline.jl index d3d9552af..e156025fb 100644 --- a/test/test_bspline.jl +++ b/test/test_bspline.jl @@ -233,9 +233,10 @@ end end @testitem "BSpline offset cusps" setup = [CommonTestSetup] begin + import Logging + ### Maximum base curvature radius ~338um + ## No cusp with trace width < 2*radius pa = Path() - # Maximum base curvature radius ~338um - # No cusp with trace width < 2*radius bspline!( pa, [Point(500.0μm, 500.0μm)], @@ -245,8 +246,10 @@ end auto_curvature=true ) cp = Curvilinear.pathtopolys(pa[1]) + # No warning + @test_logs min_level = Logging.Warn DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) pts_no_cusp = DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) # inner curve - # Cusp + ## Cusp pa = Path() bspline!( pa, @@ -257,11 +260,62 @@ end auto_curvature=true ) cp = Curvilinear.pathtopolys(pa[1]) + @test_logs (:warn, r"cusp") match_mode = :any DeviceLayout.discretize_curve( + cp.curves[2], + 1.0nm + ) + pts_cusp = DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) + # Number of points is not excessive + @test length(pts_cusp) < 1.25 * length(pts_no_cusp) + # Discretization is still ~ within tolerance + @test all( + is_sliver.( + to_polygons(xor2d(to_polygons(cp), to_polygons(cp, atol=0.1nm))), + atol=2.0nm + ) + ) + ## Same but with GeneralTrace + pa = Path() + bspline!( + pa, + [Point(500.0μm, 500.0μm)], + 90°, + Paths.Trace(x -> 680.0μm); + endpoints_speed=800.0μm, + auto_curvature=true + ) + cp = Curvilinear.pathtopolys(pa[1]) + @test_logs (:warn, r"cusp") match_mode = :any DeviceLayout.discretize_curve( + cp.curves[2], + 1.0nm + ) pts_cusp = DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) # Number of points is not excessive @test length(pts_cusp) < 1.25 * length(pts_no_cusp) # Discretization is still ~ within tolerance - @test all(is_sliver.(xor2d(to_polygons(cp), to_polygons(cp, atol=0.1nm)), atol=2.0nm)) + @test all( + is_sliver.( + to_polygons(xor2d(to_polygons(cp), to_polygons(cp, atol=0.1nm))), + atol=2.0nm + ) + ) + + ## Large ratio between base and offset radius without cusps + pa = Path() + bspline!( + pa, + [Point(500.0μm, 500.0μm)], + 90°, + Paths.Trace(970μm); + endpoints_speed=824μm # Base curvature roughly constant 500um, inner curve r ≈ 5um - 15um + ) + cp = Curvilinear.pathtopolys(pa[1]) + @test all( + is_sliver.( + to_polygons(xor2d(to_polygons(cp), to_polygons(cp, atol=0.1nm))), + atol=2.0nm + ) + ) end @testitem "BSpline approximation" setup = [CommonTestSetup] begin