Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 26 additions & 9 deletions src/render/discretization.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -155,20 +155,29 @@ 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 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

# 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
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]
Expand All @@ -185,13 +194,21 @@ function _offset_bspline_curvature(s::Paths.GeneralOffset, t)
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
# 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,
Expand Down
86 changes: 86 additions & 0 deletions test/test_bspline.jl
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,92 @@ end
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()
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])
# 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
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])
@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.(
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
pa = Path(Point(0.0, 0.0)nm, α0=90°)
bspline!(
Expand Down