From a073449231aceddbac140f284ead5828dac2755c Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Mon, 13 Jul 2026 15:25:49 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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 From c7a6d82a76f9a92d51b5c1a4a8f57a689d3b52e8 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Wed, 15 Jul 2026 10:57:00 +0200 Subject: [PATCH 4/4] Add robust cusp detection --- CHANGELOG.md | 2 +- src/render/discretization.jl | 76 +++++++++++++++++++++++++++++++----- test/test_bspline.jl | 14 +++++++ 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc4ad678..4b3fa4900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format of this changelog is based on ## Unreleased - - Added a warning when cusps are detected when rendering + - Added a guard against over-refining near cusps due to diverging curvature of offset curves of B-splines, and added a warning when cusps are detected during curve discretization - 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 e62b6fd3b..34751fcb7 100644 --- a/src/render/discretization.jl +++ b/src/render/discretization.jl @@ -105,12 +105,19 @@ function discretization_grid( κ0 = _bspline_signed_curvature(s.seg.r, 0.0) κ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; clamp_radius_ratio=0.1), + tracker = CuspTracker() + grid = discretization_grid( + t -> _offset_bspline_curvature(s, t; clamp_radius_ratio=0.1, tracker=tracker), tolerance; t_scale=l * max_speed, rtol=rtol ) + # A sign change of 1 − offset·κ means the offset point's velocity reverses + # along the base tangent: a cusp, regardless of where samples landed. + if tracker.tangential_min < 0 < tracker.tangential_max + _warn_cusp(s.offset, s.seg.r(tracker.t_nearest)) + end + return grid end function discretization_grid( @@ -126,12 +133,29 @@ function discretization_grid( (1 - Paths.getoffset(s, zero(l)) * κ0)^2 + Paths.offset_derivative(s, zero(l))^2 ) speed1 = sqrt((1 - Paths.getoffset(s, l) * κ1)^2 + Paths.offset_derivative(s, l)^2) - return discretization_grid( - t -> _offset_bspline_curvature(s, t; clamp_radius_ratio=0.1), + clamp_radius_ratio = 0.1 + tracker = CuspTracker() + grid = discretization_grid( + t -> _offset_bspline_curvature( + s, + t; + clamp_radius_ratio=clamp_radius_ratio, + tracker=tracker + ), tolerance; t_scale=l * max(speed0, speed1), rtol=rtol ) + # A tangential-speed reversal is only a cusp if the full speed + # √((1 − offset·κ)² + offset′²) also vanishes there; at the reversal it is + # |offset′|, so a fast-varying offset turns the would-be cusp into smooth + # sideways motion and should not warn. + if tracker.tangential_min < 0 < tracker.tangential_max && + abs(tracker.offset_deriv_nearest) < clamp_radius_ratio + l_nearest = Paths.t_to_arclength(s.seg, tracker.t_nearest) + _warn_cusp(Paths.getoffset(s, l_nearest), s.seg.r(tracker.t_nearest)) + end + return grid end function _bspline_signed_curvature(r, t) @@ -153,21 +177,55 @@ function _offset_bspline_point(s::Paths.GeneralOffset, t) return s.seg.r(t) + Paths.getoffset(s, l) * Point(-tangent.y, tangent.x) end +# Track the extremes of the tangential speed component 1 − offset·κ +# across all kernel evaluations and check for a sign change after marching: +# the offset point's velocity reverses along the base tangent. +mutable struct CuspTracker + tangential_min::Float64 + tangential_max::Float64 + abs_tangential_nearest::Float64 # |tangential| at the evaluation nearest a reversal + t_nearest::Float64 + offset_deriv_nearest::Float64 # d(offset)/d(arclength) there; 0 for constant offsets +end +CuspTracker() = CuspTracker(Inf, -Inf, Inf, NaN, 0.0) + +function _track_cusp!(tracker::CuspTracker, tangential, t, offset_deriv=0.0) + tracker.tangential_min = min(tracker.tangential_min, tangential) + tracker.tangential_max = max(tracker.tangential_max, tangential) + if abs(tangential) < tracker.abs_tangential_nearest + tracker.abs_tangential_nearest = abs(tangential) + tracker.t_nearest = t + tracker.offset_deriv_nearest = offset_deriv + end + return nothing +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; clamp_radius_ratio=0.0) +function _offset_bspline_curvature( + s::Paths.ConstantOffset, + t; + clamp_radius_ratio=0.0, + tracker=nothing +) # 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 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) + tangential = 1 - s.offset * κ + isnothing(tracker) || _track_cusp!(tracker, tangential, t) + return abs(κ) / max(abs(tangential), clamp_radius_ratio) end # Mirrors curvatureradius(::GeneralOffset, s), including its ignored offset*dκ/ds term. -function _offset_bspline_curvature(s::Paths.GeneralOffset, t; clamp_radius_ratio=0.0) +function _offset_bspline_curvature( + s::Paths.GeneralOffset, + t; + clamp_radius_ratio=0.0, + tracker=nothing +) l = Paths.t_to_arclength(s.seg, t) r = 1 / _bspline_signed_curvature(s.seg.r, t) offset = Paths.getoffset(s, l) @@ -176,7 +234,7 @@ function _offset_bspline_curvature(s::Paths.GeneralOffset, t; clamp_radius_ratio # Same denominator clamp as for ConstantOffset denom = sqrt((1 - offset / r)^2 + doffset^2) - isapprox(denom, 0.0, atol=1e-3) && _warn_cusp(offset, s.seg.r(t)) + isnothing(tracker) || _track_cusp!(tracker, 1 - offset / r, t, doffset) ds_dl = 1 / max(denom, clamp_radius_ratio) d2s_dl2 = -ds_dl^3 * doffset * (d2offset - (1 - offset / r) / r) diff --git a/test/test_bspline.jl b/test/test_bspline.jl index e156025fb..77c981730 100644 --- a/test/test_bspline.jl +++ b/test/test_bspline.jl @@ -300,6 +300,20 @@ end ) ) + ## Fast-varying offset: tangential speed reverses sign but |offset′| is large, + ## so the reversal is smooth sideways motion, not a cusp — no warning. + pa = Path() + bspline!( + pa, + [Point(500.0μm, 500.0μm)], + 90°, + Paths.Trace(x -> 600.0μm + 0.5 * x); + endpoints_speed=800.0μm, + auto_curvature=true + ) + cp = Curvilinear.pathtopolys(pa[1]) + @test_logs min_level = Logging.Warn DeviceLayout.discretize_curve(cp.curves[2], 1.0nm) + ## Large ratio between base and offset radius without cusps pa = Path() bspline!(