From 6afea13b2f3bb09ac1c34c75100c325832cbc58b Mon Sep 17 00:00:00 2001 From: Greg Peairs Date: Fri, 29 May 2026 13:59:43 +0200 Subject: [PATCH 01/19] Add provenance-based curve recovery --- docs/src/concepts/polygons.md | 28 ++ docs/src/reference/api.md | 10 + src/DeviceLayout.jl | 10 +- src/curve_recovery.jl | 312 +++++++++++++++++++ src/curvilinear.jl | 18 +- src/render/render.jl | 88 ++++++ src/solidmodels/render.jl | 87 ------ src/solidmodels/solidmodels.jl | 1 + test/test_provenance_recovery.jl | 518 +++++++++++++++++++++++++++++++ 9 files changed, 983 insertions(+), 89 deletions(-) create mode 100644 src/curve_recovery.jl create mode 100644 test/test_provenance_recovery.jl diff --git a/docs/src/concepts/polygons.md b/docs/src/concepts/polygons.md index a42a3f671..b18e6c404 100644 --- a/docs/src/concepts/polygons.md +++ b/docs/src/concepts/polygons.md @@ -45,6 +45,34 @@ A [`CurvilinearRegion`](@ref) pairs a `CurvilinearPolygon` exterior with zero or See [API Reference: Curvilinear geometry](@ref api-curvilinear). +### Recovering curves through clipping + +Boolean operations (`union2d`, `difference2d`, etc.) discretize curved geometry to polygons +before passing them to the Clipper library. Normally, the original curves (arcs, splines) +are lost in this process. The [`recover_curves`](@ref) function and its convenience +wrappers (`difference2d_curved`, `union2d_curved`, `intersect2d_curved`, `xor2d_curved`) +track each input curve's discretized integer-grid footprint and substitute the original +curve back into the result wherever that footprint survived the boolean operation intact. + +The curve-preserving variants return a `Vector{CurvilinearRegion}` rather than a single +`ClippedPolygon`. Each region in the vector corresponds to one outer contour in the clipped +result (the disjoint pieces each become a separate region). For example: + +```julia +# Standard clipping discretizes curves to polygons: +result = difference2d(a, b) # ClippedPolygon + +# Curve-preserving variant recovers arcs where possible: +regions = difference2d_curved(a, b) # Vector{CurvilinearRegion}, arcs preserved +``` + +**Current limitations:** A curve is recovered only if its entire discretized run survives +the boolean operation with exact integer equality. If the operation cuts through a curve +(e.g., a straight edge crossing an arc's interior), that curve falls back to a polyline. +Additionally, curves can only be recovered on CurvilinearRegion/CurvilinearPolygon, +Path nodes, and `Rounded`-styled `Polygon`/`Rectangle` entities. `Rounded` applied to +other entities, styled Curvilinear entities, and nested styles do not yet support curve recovery. + ## Styles In addition to other generic [entity styles](./geometry.md#Entity-Styles) like `NoRender`, `AbstractPolygon`s can be paired with the `Rounded` style. `ClippedPolygon`s support `StyleDict`, which allows for different styles to be applied to different contours in its tree. diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index 3eda018c4..4517fa491 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -185,6 +185,11 @@ clip Polygons.clip_tiled Polygons.StyleDict + recover_curves + difference2d_curved + union2d_curved + intersect2d_curved + xor2d_curved ``` #### [Curvilinear geometry](@id api-curvilinear) @@ -194,6 +199,11 @@ CurvilinearRegion Curvilinear.edge_type_at_vertex Curvilinear.line_arc_cornerindices + difference2d_curved + intersect2d_curved + recover_curves + union2d_curved + xor2d_curved ``` ### [Shapes](@id api-shapes) diff --git a/src/DeviceLayout.jl b/src/DeviceLayout.jl index 2fef9d2f7..8f1697550 100644 --- a/src/DeviceLayout.jl +++ b/src/DeviceLayout.jl @@ -563,7 +563,15 @@ include("render/render.jl") # After render.jl to ensure access to `to_polygons` include("curvilinear.jl") using .Curvilinear -export Curvilinear, CurvilinearPolygon, CurvilinearRegion, pathtopolys +export Curvilinear, + CurvilinearPolygon, + CurvilinearRegion, + pathtopolys, + recover_curves, + difference2d_curved, + union2d_curved, + intersect2d_curved, + xor2d_curved include("simple_shapes.jl") import .SimpleShapes: diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl new file mode 100644 index 000000000..6f0a74d03 --- /dev/null +++ b/src/curve_recovery.jl @@ -0,0 +1,312 @@ +# Provenance-based curve recovery. +# Included in Curvilinear — needs CurvilinearRegion, discretize_curve, and the Polygons clip functions. + +using .Polygons: clipperize, ClippedPolygon, union2d, difference2d, intersect2d, xor2d + +# One discretized curve's footprint on the integer grid, paired with its source segment. +# The integer-grid coordinate type P matches whatever Polygons.clipperize(::Point{R}) produces. +struct ProvenanceRun{P} + run::Vector{P} + curve::Paths.Segment +end + +# Returns (polys::Vector{Polygon{R}}, runs::Vector{ProvenanceRun}) for one operand. +# `R` is the promoted coordinate type clip will use; discretization uses clip's default atol. +function discretize_with_provenance(entities, ::Type{R}) where {R} + polys = Polygon{R}[] + runs = ProvenanceRun[] + atol = DeviceLayout.onenanometer(R) + for ent in entities + _collect_provenance!(polys, runs, ent, R, atol) + end + return polys, runs +end + +# CurvilinearPolygon: walk exactly like to_polygons(::CurvilinearPolygon), but also +# record each curve's full [start, interior…, end] run, snapped to the integer grid. +function _collect_provenance!(polys, runs, e::CurvilinearPolygon, ::Type{R}, atol) where {R} + ec = convert(CurvilinearPolygon{R}, e) + i = 1 + p = Point{R}[] + for (csi, c) in zip(ec.curve_start_idx, ec.curves) + append!(p, ec.p[i:csi]) + wrapped_end = mod1(csi + 1, length(ec.p)) + pp = DeviceLayout.discretize_curve(c, atol; rtol=nothing) + run_pts = vcat([ec.p[csi]], pp[2:(end - 1)], [ec.p[wrapped_end]]) + push!(runs, ProvenanceRun(clipperize.(run_pts), c)) + append!(p, pp[2:(end - 1)]) + i = csi + 1 + end + append!(p, ec.p[i:end]) + push!(polys, Polygon{R}(p)) + return nothing +end + +# CurvilinearRegion: exterior + holes, each a CurvilinearPolygon. +function _collect_provenance!(polys, runs, e::CurvilinearRegion, ::Type{R}, atol) where {R} + _collect_provenance!(polys, runs, e.exterior, R, atol) + for h in e.holes + _collect_provenance!(polys, runs, h, R, atol) + end + return nothing +end + +# Any other entity: no curves to recover; discretize to polygons. +# Operands that `clip` normalizes specially — `GeometryStructure`/`GeometryReference`/`Pair` +# (see `_normalize_clip_arg` in clipping.jl) — are not handled here; recover_curves expects entity-level inputs. +function _collect_provenance!(polys, runs, e, ::Type{R}, atol) where {R} + # Convert to polygons and append. to_polygons returns a polygon or array. + poly_result = DeviceLayout.to_polygons(e) + if poly_result isa Polygon + push!(polys, convert(Polygon{R}, poly_result)) + else + # It's a vector or other collection of polygons + for poly in poly_result + push!(polys, convert(Polygon{R}, poly)) + end + end + return nothing +end + +# Search `contour` (treated as cyclic) for `run` as a contiguous block, forward or reversed. +# Returns (start, reversed) of the first hit (1-based start index into contour), or nothing. +# Exact integer equality — no tolerance. +function match_run(contour::AbstractVector{P}, run::AbstractVector{P}) where {P} + n = length(contour) + m = length(run) + (m == 0 || m > n) && return nothing + rev = reverse(run) + for s = 1:n + fwd_ok = true + rev_ok = true + for k = 0:(m - 1) + cv = contour[mod1(s + k, n)] + fwd_ok &= (cv == run[k + 1]) + rev_ok &= (cv == rev[k + 1]) + (fwd_ok || rev_ok) || break + end + fwd_ok && return (start=s, reversed=false) + rev_ok && return (start=s, reversed=true) + end + return nothing +end + +# Walk a ClippedPolygon's PolyNode tree, substituting known curves back into each contour +# wherever a ProvenanceRun's discretized integer-grid point-run survived a boolean op intact. +# Returns Vector{CurvilinearRegion{T}}: one region per outer contour (its direct children are +# holes; grandchildren start new regions), mirroring to_primitives(::SolidModel, ::ClippedPolygon). +# +# Each run matches at most once globally — `used` is shared across all contours. +# `report`, if given, collects (status::Symbol, curve, contour_index) tuples with +# status ∈ (:recovered, :clipped); :clipped runs (never matched) carry contour_index 0. +function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) where {T} + out = CurvilinearRegion{T}[] + contour_index = Ref(0) + used = falses(length(runs)) # shared across ALL contours + function build_cpoly(node) + pts = collect(node.contour) # Vector{Point{T}} + snapped = clipperize.(pts) + n = length(pts) + contour_index[] += 1 + ci = contour_index[] + # Find each surviving run's span: (start, m, segment), start 1-based into `pts`, + # m = number of contour vertices the run occupies (cyclically start … start+m-1). + matched = Tuple{Int, Int, Paths.Segment}[] + # match_run is exact and translation-sensitive: two geometrically distinct curves + # produce distinct integer runs, so first-match-wins cannot misattribute. Only + # overlapping (degenerate) curves could share a run, which is not handled. + for (ri, pr) in enumerate(runs) + used[ri] && continue + hit = match_run(snapped, pr.run) + hit === nothing && continue + if hit.reversed && !hasmethod(reverse, Tuple{typeof(pr.curve)}) + continue # can't reverse → leave for :clipped + end + seg = hit.reversed ? reverse(pr.curve) : pr.curve + push!(matched, (hit.start, length(pr.run), seg)) + used[ri] = true + report !== nothing && push!(report, (:recovered, pr.curve, ci)) + end + isempty(matched) && return CurvilinearPolygon{T}(pts, Paths.Segment[], Int[]) + + # A curve replaces its discretized run: keep only the run's two endpoints, drop the + # m-2 interior vertices, and point curve_start_idx at the (reduced-list) start vertex. + # Mark interior positions to drop, and starts to tag. + drop = falses(n) + start_seg = Dict{Int, Paths.Segment}() + for (s, m, seg) in matched + start_seg[s] = seg + for k = 1:(m - 2) # strictly-interior positions, cyclic + drop[mod1(s + k, n)] = true + end + end + # Single ordered pass: build reduced point list and record curve starts at their + # index in the reduced list. (Starts/ends are never dropped, so they survive.) + reduced = Point{T}[] + curves = Paths.Segment[] + csis = Int[] + for i = 1:n + drop[i] && continue + push!(reduced, pts[i]) + if haskey(start_seg, i) + push!(curves, start_seg[i]) + push!(csis, length(reduced)) # this vertex's index in `reduced` + end + end + return CurvilinearPolygon{T}(reduced, curves, csis) + end + function add_region(node) + ext = build_cpoly(node) + # Holes must be CCW, but come out CW from Clipper + holes = CurvilinearPolygon{T}[ + _reverse_curvilinear_polygon(build_cpoly(child)) for child in node.children + ] + push!(out, CurvilinearRegion{T}(ext, holes)) + for child in node.children + for gc in child.children + add_region(gc) + end + end + end + for child in clipped.tree.children + add_region(child) + end + if report !== nothing + for (ri, pr) in enumerate(runs) + used[ri] || push!(report, (:clipped, pr.curve, 0)) + end + end + return out +end + +function _reverse_curvilinear_polygon(e::CurvilinearPolygon) + csi_rev = (i, N) -> mod1(i + 1, N) - N - 1 + return CurvilinearPolygon( + reverse(e.p), + reverse(e.curves), + reverse(csi_rev.(e.curve_start_idx, length(e.p))) + ) +end + +# Normalize a clip operand into a flat Vector of entities, preserving curve-bearing +# entities intact (unlike _normalize_clip_arg, which discretizes via to_polygons). +_as_entities(p::DeviceLayout.GeometryEntity) = [p] +_as_entities(p::AbstractArray) = collect(Iterators.flatten(_as_entities.(p))) +_as_entities(p::Union{GeometryStructure, GeometryReference}) = + _as_entities(flat_elements(p)) +_as_entities(p::Pair{<:Union{GeometryStructure, GeometryReference}}) = + _as_entities(flat_elements(p)) +function _as_entities(p::Paths.Node) + iszero(pathlength(p.seg)) && p.sty isa ContinuousStyle && return [] + return _as_entities(pathtopolys(p.seg, p.sty)) +end +# A Rounded-styled straight Polygon recovers as its exact-arc CurvilinearPolygon, so +# corners survive the clip. Mirrors to_primitives(::SolidModel, ::StyledEntity{Polygon,Rounded}). +function _as_entities( + p::StyledEntity{T, <:Union{Polygon{T}, Polygons.Rectangle{T}}, <:Polygons.Rounded} +) where {T} + return [ + round_to_curvilinearpolygon( + p.ent, + radius(p.sty), + min_side_len=p.sty.min_side_len, + corner_indices=cornerindices(p.ent, p.sty), + min_angle=p.sty.min_angle + ) + ] +end + +# Promoted coordinate type matching what clip's promote_type would pick. +function _recover_coordtype(plus, minus) + return promote_type( + DeviceLayout.coordinatetype(plus), + DeviceLayout.coordinatetype(minus) + ) +end + +""" + recover_curves(op, plus, minus; report=nothing) + +Run a boolean operation `op` on `plus` and `minus`, then recover original curves from the +discretized result wherever they survived the operation intact. Returns +`Vector{CurvilinearRegion}` — one region per outer contour in the clipped result (each +disjoint piece becomes a separate region). + +## Positional arguments + +The first argument `op` must be one of the polygon clipping operations: `difference2d`, +`union2d`, `intersect2d`, or `xor2d`. + +The second and third arguments (`plus` and `minus`) may each be a `GeometryEntity` or array +of `GeometryEntity`. Curve-bearing entities (`CurvilinearPolygon`, `CurvilinearRegion`, and +`Path` segments like `Turn`/`BSpline`) have their curves tracked through discretization and +recovered in the result. All other entities are discretized via `to_polygons`. + +## Keyword arguments + +`report` may be a `Vector` that will be filled with `(status, curve, contour_index)` tuples +tracking recovery results. `status` is `:recovered` if the curve's entire discretized run +survived the boolean operation intact, or `:clipped` if it was cut and fell back to a +polyline. `contour_index` is the 1-based index of the output contour where the curve was +recovered, or `0` for `:clipped` curves. + +## Return type + +Returns `Vector{CurvilinearRegion}` (one region per outer contour). This differs from +`difference2d` and similar functions, which return a single `ClippedPolygon`. Callers +migrating from `difference2d(a, b)` to `recover_curves(difference2d, a, b)` must handle a +vector result. + +## Limitations + +**All-or-nothing recovery:** A curve is recovered only if its entire discretized run (the +sequence of integer-grid vertices produced by `discretize_curve`) survives the boolean +operation with exact integer equality. If the operation cuts through a curve, that curve is +reported `:clipped` and falls back to a polyline. Partial-curve recovery is not supported. + +Additionally, curves can only be recovered on CurvilinearRegion/CurvilinearPolygon, +Path nodes, and `Rounded`-styled `Polygon`/`Rectangle` entities. `Rounded` applied to +other entities, styled Curvilinear entities, and nested styles do not yet support curve recovery. + +See also [`difference2d`](@ref), [`union2d`](@ref), [`intersect2d`](@ref), [`xor2d`](@ref), +[`difference2d_curved`](@ref), [`union2d_curved`](@ref), [`intersect2d_curved`](@ref), +[`xor2d_curved`](@ref). +""" +function recover_curves(op, plus, minus; report=nothing) + R = _recover_coordtype(plus, minus) + pp, runs_p = discretize_with_provenance(_as_entities(plus), R) + pm, runs_m = discretize_with_provenance(_as_entities(minus), R) + clipped = op(pp, pm) + return substitute_curves(clipped, vcat(runs_p, runs_m); report=report) +end + +""" + difference2d_curved(plus, minus; report=nothing) + +Curve-preserving variant of [`difference2d`](@ref), returning `Vector{CurvilinearRegion}`. +See [`recover_curves`](@ref). +""" +difference2d_curved(p, m; kwargs...) = recover_curves(difference2d, p, m; kwargs...) +""" + union2d_curved(p1, p2; report=nothing) + +Curve-preserving variant of [`union2d`](@ref), returning `Vector{CurvilinearRegion}`. +See [`recover_curves`](@ref). +""" +union2d_curved(p, m; kwargs...) = recover_curves(union2d, p, m; kwargs...) +union2d_curved(p; kwargs...) = recover_curves(union2d, p, []; kwargs...) + +""" + intersect2d_curved(p1, p2; report=nothing) + +Curve-preserving variant of [`intersect2d`](@ref), returning `Vector{CurvilinearRegion}`. +See [`recover_curves`](@ref). +""" +intersect2d_curved(p, m; kwargs...) = recover_curves(intersect2d, p, m; kwargs...) +""" + xor2d_curved(p1, p2; report=nothing) + +Curve-preserving variant of [`xor2d`](@ref), returning `Vector{CurvilinearRegion}`. +See [`recover_curves`](@ref). +""" +xor2d_curved(p, m; kwargs...) = recover_curves(xor2d, p, m; kwargs...) diff --git a/src/curvilinear.jl b/src/curvilinear.jl index bc072cf74..633115172 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -11,14 +11,27 @@ import DeviceLayout: AbstractPolygon, GeometryEntity, GeometryEntityStyle, + GeometryStructure, + GeometryReference, Paths, Reflection, Rotation, ScaledIsometry, + StyledEntity, Transformation, Translation import DeviceLayout: - to_polygons, points, rotation, origin, mag, xrefl, transform, perimeter, isapprox_angle + flat_elements, + to_polygons, + points, + rotation, + round_to_curvilinearpolygon, + origin, + mag, + xrefl, + transform, + perimeter, + isapprox_angle using DeviceLayout.Paths import DeviceLayout.Polygons: cornerindices @@ -27,6 +40,7 @@ using ..Polygons using ..Paths export CurvilinearPolygon, CurvilinearRegion, pathtopolys, line_arc_cornerindices +export recover_curves, difference2d_curved, intersect2d_curved, union2d_curved, xor2d_curved """ struct CurvilinearPolygon{T} <: GeometryEntity{T} @@ -960,4 +974,6 @@ function rounded_corner_line_arc( return (; points=fillet_pts, T_arc=T_arc) end +include("curve_recovery.jl") + end # module diff --git a/src/render/render.jl b/src/render/render.jl index 61758aeba..f5f2144fa 100644 --- a/src/render/render.jl +++ b/src/render/render.jl @@ -213,3 +213,91 @@ Additional keyword arguments are passed to [`to_polygons`](@ref) for each entity certain entity types to control how they are converted to polygons. """ render!(c::Cell, s::GeometryStructure; kwargs...) = _render!(c, s; kwargs...) + +####### Rendering pathway through Curvilinear +function round_to_curvilinearpolygon( + pol::GeometryEntity{T}, + radius::S; + corner_indices=eachindex(points(pol)), + line_arc_corner_indices=nothing, + min_angle=1e-3, + relative::Bool=(T <: Length) && (S <: Real), + min_side_len=relative ? zero(T) : radius +) where {T, S <: Coordinate} + # If radius is dimensional, non-relative rounding. + V = float(T) + # Tie break for Real, Real introduces a type instability for non-dimensional. + relative = ((T <: Length) && (S <: Real)) || (relative && T <: Real && S <: Real) + + poly = points(pol) + len = length(poly) + new_points = Point{V}[] + new_curves = Paths.Turn{V}[] + new_curve_start_idx = Int[] + + for i in eachindex(poly) + if !(i in corner_indices) + push!(new_points, poly[i]) + else + p0 = poly[mod1(i - 1, len)] # handles the cyclic boundary condition + p1 = poly[i] + p2 = poly[mod1(i + 1, len)] + radius_dim = relative ? radius * min(norm(p0 - p1), norm(p1 - p2)) : radius + seg_or_p1 = rounded_corner_segment( + p0, + p1, + p2, + radius_dim, + min_side_len=min_side_len, + min_angle=min_angle + ) + if seg_or_p1 isa Paths.Turn + push!(new_points, Paths.p0(seg_or_p1)) + push!(new_curves, seg_or_p1) + push!(new_curve_start_idx, length(new_points)) + push!(new_points, Paths.p1(seg_or_p1)) + else + push!(new_points, seg_or_p1) + end + end + end + + return CurvilinearPolygon(new_points, new_curves, new_curve_start_idx) +end + +function rounded_corner_segment( + p0::Point{T}, + p1::Point{T}, + p2::Point{T}, + radius::S; + min_side_len=radius, + min_angle=1e-3 +) where {T, S <: Coordinate} + V = float(T) + rad = convert(V, radius) + + v1 = (p1 - p0) / norm(p1 - p0) + v2 = (p2 - p1) / norm(p2 - p1) + α1 = atan(v1.y, v1.x) # between -π and π + α2 = atan(v2.y, v2.x) + + if min_side_len > norm(p1 - p0) || min_side_len > norm(p2 - p1) # checks that the side lengths against min_side_len + return p1 + elseif isapprox(rem2pi(α1 - α2, RoundNearest), 0, atol=min_angle) # checks if the points are collinear, within tolerance + return p1 + end + + dir = orientation(p0, p1, p2) # checks the direction of the corner + dα = α2 - α1 # always between +/- 2π + if sign(dα) != dir # Make sure turn is in the correct direction + dα = dα + dir * 2π # Still between +/- 2π + end + + # p0_seg is the start of the arc, determined by the intersection + # of lines parallel to v1, v2 + k = + inv([v1.x -v2.x; v1.y -v2.y]) * + [p2.x - p0.x + dir * rad * (v1.y - v2.y), p2.y - p0.y + dir * rad * (v2.x - v1.x)] + p0_seg = p0 + k[1] * v1 + return Paths.Turn(uconvert(°, dα), rad, p0_seg, uconvert(°, α1)) +end diff --git a/src/solidmodels/render.jl b/src/solidmodels/render.jl index 16987f05b..d6854f45c 100644 --- a/src/solidmodels/render.jl +++ b/src/solidmodels/render.jl @@ -188,56 +188,6 @@ function to_primitives( ) end -function round_to_curvilinearpolygon( - pol::GeometryEntity{T}, - radius::S; - corner_indices=eachindex(points(pol)), - line_arc_corner_indices=nothing, - min_angle=1e-3, - relative::Bool=(T <: Length) && (S <: Real), - min_side_len=relative ? zero(T) : radius -)::CurvilinearPolygon{T} where {T, S <: Coordinate} - # If radius is dimensional, non-relative rounding. - V = float(T) - # Tie break for Real, Real introduces a type instability for non-dimensional. - relative = ((T <: Length) && (S <: Real)) || (relative && T <: Real && S <: Real) - - poly = points(pol) - len = length(poly) - new_points = Point{V}[] - new_curves = Paths.Turn{V}[] - new_curve_start_idx = Int[] - - for i in eachindex(poly) - if !(i in corner_indices) - push!(new_points, poly[i]) - else - p0 = poly[mod1(i - 1, len)] # handles the cyclic boundary condition - p1 = poly[i] - p2 = poly[mod1(i + 1, len)] - radius_dim = relative ? radius * min(norm(p0 - p1), norm(p1 - p2)) : radius - seg_or_p1 = rounded_corner_segment( - p0, - p1, - p2, - radius_dim, - min_side_len=min_side_len, - min_angle=min_angle - ) - if seg_or_p1 isa Paths.Turn - push!(new_points, Paths.p0(seg_or_p1)) - push!(new_curves, seg_or_p1) - push!(new_curve_start_idx, length(new_points)) - push!(new_points, Paths.p1(seg_or_p1)) - else - push!(new_points, seg_or_p1) - end - end - end - - return CurvilinearPolygon(new_points, new_curves, new_curve_start_idx) -end - function round_to_curvilinearpolygon( pol::CurvilinearPolygon{T}, radius::S; @@ -374,43 +324,6 @@ function round_to_curvilinearpolygon( return CurvilinearPolygon(new_points, new_curves, new_curve_start_idx) end -function rounded_corner_segment( - p0::Point{T}, - p1::Point{T}, - p2::Point{T}, - radius::S; - min_side_len=radius, - min_angle=1e-3 -) where {T, S <: Coordinate} - V = float(T) - rad = convert(V, radius) - - v1 = (p1 - p0) / norm(p1 - p0) - v2 = (p2 - p1) / norm(p2 - p1) - α1 = atan(v1.y, v1.x) # between -π and π - α2 = atan(v2.y, v2.x) - - if min_side_len > norm(p1 - p0) || min_side_len > norm(p2 - p1) # checks that the side lengths against min_side_len - return p1 - elseif isapprox(rem2pi(α1 - α2, RoundNearest), 0, atol=min_angle) # checks if the points are collinear, within tolerance - return p1 - end - - dir = orientation(p0, p1, p2) # checks the direction of the corner - dα = α2 - α1 # always between +/- 2π - if sign(dα) != dir # Make sure turn is in the correct direction - dα = dα + dir * 2π # Still between +/- 2π - end - - # p0_seg is the start of the arc, determined by the intersection - # of lines parallel to v1, v2 - k = - inv([v1.x -v2.x; v1.y -v2.y]) * - [p2.x - p0.x + dir * rad * (v1.y - v2.y), p2.y - p0.y + dir * rad * (v2.x - v1.x)] - p0_seg = p0 + k[1] * v1 - return Paths.Turn(uconvert(°, dα), rad, p0_seg, uconvert(°, α1)) -end - # TODO: The fillet center geometry here duplicates `rounded_corner_line_arc` in # curvilinear.jl. Extract the shared solver into a common helper when consolidating # rounding logic through CurvilinearPolygon. diff --git a/src/solidmodels/solidmodels.jl b/src/solidmodels/solidmodels.jl index 38cc920a2..4b3bfa18d 100644 --- a/src/solidmodels/solidmodels.jl +++ b/src/solidmodels/solidmodels.jl @@ -66,6 +66,7 @@ import DeviceLayout: level, norm, render!, + round_to_curvilinearpolygon, uniquename, isapprox_angle import DeviceLayout.Paths: trace, gap, offset, extent, pathlength, bspline_approximation diff --git a/test/test_provenance_recovery.jl b/test/test_provenance_recovery.jl new file mode 100644 index 000000000..af16afefa --- /dev/null +++ b/test/test_provenance_recovery.jl @@ -0,0 +1,518 @@ +@testitem "Provenance — preserved vertices survive Clipper" setup = [CommonTestSetup] begin + using DeviceLayout: Point, Polygon, union2d + snap(p) = DeviceLayout.Polygons.clipperize(p) + + R = 10.0 + N = 64 + circ = [Point(R * cos(t), R * sin(t)) for t in range(0, 2π, length=N + 1)[1:(end - 1)]] + poly = Polygon(circ) + in_int = snap.(circ) + + sq = Polygon([ + Point(1000.0, 1000.0), + Point(1001.0, 1000.0), + Point(1001.0, 1001.0), + Point(1000.0, 1001.0) + ]) + res = union2d(poly, sq) + + function allcontours(node, acc) + for c in node.children + push!(acc, c.contour) + allcontours(c, acc) + end + return acc + end + cons = allcontours(res.tree, Vector{Point{Float64}}[]) + circ_con = argmax(length, cons) + out_set = Set(snap.(circ_con)) + + @test all(p -> p in out_set, in_int) + @test length(circ_con) == N +end + +@testitem "Provenance — discretize_with_provenance captures arc runs" setup = + [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, Paths, Polygon + using DeviceLayout.Curvilinear: discretize_with_provenance + # Create a square with one curved edge: (0,0) -> (10,0) -> Turn -> (0,10) + # Turn from (10,0) with α0=90° (pointing up), radius 10, sweeping 90° ends at (0,10) + pts = Point{Float64}[(0, 0), (10, 0), (0, 10)] + turn = Paths.Turn(90.0°, 10.0; p0=Point(10.0, 0.0), α0=90.0°) + cpoly = CurvilinearPolygon(pts, [turn], [2]) + polys, runs = discretize_with_provenance([cpoly], Float64) + @test polys isa Vector{<:Polygon} + @test length(runs) == 1 + @test runs[1].curve === turn + @test runs[1].run[1] == DeviceLayout.Polygons.clipperize(Point(10.0, 0.0)) + @test length(runs[1].run) ≥ 3 +end + +@testitem "Provenance — match_run cyclic + bidirectional" setup = [CommonTestSetup] begin + using DeviceLayout: Point + M = DeviceLayout.Curvilinear # match_run is internal (not exported) + ip(x) = Point{Int64}(x, 0) + contour = ip.([1, 2, 3, 4, 5, 6]) + @test M.match_run(contour, ip.([2, 3, 4])) == (start=2, reversed=false) + @test M.match_run(contour, ip.([5, 6, 1])) == (start=5, reversed=false) # cyclic wrap + @test M.match_run(contour, ip.([4, 3, 2])) == (start=2, reversed=true) # reversed + @test M.match_run(contour, ip.([2, 4, 3])) === nothing # no contiguous match +end + +@testitem "Provenance — substitute_curves recovers one arc" setup = [CommonTestSetup] begin + using DeviceLayout: + Point, CurvilinearPolygon, CurvilinearRegion, Paths, union2d, Polygon + using DeviceLayout.Curvilinear: discretize_with_provenance, substitute_curves + # A CurvilinearPolygon whose exterior has a single recoverable arc. + pts = Point{Float64}[(0, 0), (10, 0), (0, 10)] + turn = Paths.Turn(90.0°, 10.0; p0=Point(10.0, 0.0), α0=90.0°) + region = CurvilinearRegion(CurvilinearPolygon(pts, [turn], [2])) + + polys, runs = discretize_with_provenance([region], Float64) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + clipped = union2d(polys, [sq]) # ClippedPolygon, arc untouched + report = Tuple[] + out = substitute_curves(clipped, runs; report=report) + @test out isa Vector{<:CurvilinearRegion} + @test sum(length(r.exterior.curves) for r in out) == 1 + @test count(t -> t[1] == :recovered, report) == 1 + @test count(t -> t[1] == :clipped, report) == 0 +end + +@testitem "Provenance — substitute_curves recovers two arcs in order" setup = + [CommonTestSetup] begin + using DeviceLayout: + Point, CurvilinearPolygon, CurvilinearRegion, Paths, union2d, Polygon + using DeviceLayout.Curvilinear: discretize_with_provenance, substitute_curves + # A CurvilinearPolygon with two recoverable arcs (two rounded corners) on its exterior. + # Exercises the multi-curve ordering path: matched runs must be sorted by start index + # so to_polygons' monotonic cursor doesn't drop/misorder vertices. + t1 = Paths.Turn(90.0°, 2.0; p0=Point(10.0, 0.0), α0=0.0°) # (10,0) heading +x → (12,2) + t2 = Paths.Turn(90.0°, 2.0; p0=Point(12.0, 10.0), α0=90.0°) # (12,10) heading +y → (10,12) + pts = Point{Float64}[(0, 0), (10, 0), (12, 2), (12, 10), (10, 12), (0, 12)] + region = CurvilinearRegion(CurvilinearPolygon(pts, [t1, t2], [2, 4])) + + polys, runs = discretize_with_provenance([region], Float64) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + clipped = union2d(polys, [sq]) + report = Tuple[] + out = substitute_curves(clipped, runs; report=report) + # Clipper may order the disjoint square's region first, so select the curved + # region by curve count rather than assuming an index. + curved = out[findfirst(r -> !isempty(r.exterior.curves), out)] + @test length(curved.exterior.curves) == 2 + # curve_start_idx must be ascending after sorting. + @test issorted(curved.exterior.curve_start_idx) + # to_polygons must not error or drop everything when walking the two curves. + @test length(DeviceLayout.to_polygons(curved.exterior).p) > 0 + @test count(t -> t[1] == :recovered, report) == 2 + @test count(t -> t[1] == :clipped, report) == 0 +end + +@testitem "Provenance — recover_curves end to end" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d, union2d_curved, difference2d_curved + pts = Point{Float64}[(0, 0), (10, 0), (0, 10)] + turn = Paths.Turn(90.0°, 10.0; p0=Point(10.0, 0.0), α0=90.0°) + region = CurvilinearRegion(CurvilinearPolygon(pts, [turn], [2])) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + + a = recover_curves(union2d, region, sq) + b = union2d_curved(region, sq) + @test a isa Vector{<:CurvilinearRegion} + @test b isa Vector{<:CurvilinearRegion} + @test sum(length(r.exterior.curves) for r in a) == 1 + @test sum(length(r.exterior.curves) for r in b) == 1 + # report kwarg flows through + report = Tuple[] + recover_curves(union2d, region, sq; report=report) + @test count(t -> t[1] == :recovered, report) == 1 +end + +@testitem "Provenance — interface methods" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d, union2d_curved, difference2d_curved + pts = Point{Float64}[(0, 0), (10, 0), (0, 10)] + turn = Paths.Turn(90.0°, 10.0; p0=Point(10.0, 0.0), α0=90.0°) + region = CurvilinearRegion(CurvilinearPolygon(pts, [turn], [2])) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + cs1 = CoordinateSystem{Float64}("test1") + cs2 = CoordinateSystem{Float64}("test2") + place!(cs1, region, :curved) + place!(cs1, sq, :square) + place!(cs2, sq, :square) + out = union2d_curved(region, sq) + a = union2d_curved(cs1) + b = union2d_curved(region, cs2) + c = union2d_curved(cs1 => :curved, cs1 => :square) + d = union2d_curved([sq, cs1 => :curved]) + @test isempty(to_polygons(xor2d(out, a))) + @test isempty(to_polygons(xor2d(a, b))) + @test isempty(to_polygons(xor2d(b, c))) + @test isempty(to_polygons(xor2d(c, d))) +end + +@testitem "Provenance — Path round trips" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d, union2d_curved, difference2d_curved + # Trace + pa = Path() + turn!(pa, 90°, 50μm, Paths.Trace(5μm)) + turn!(pa, 90°, 50μm, Paths.Trace(5μm)) + out = union2d_curved(pa) + @test length(out) == 1 # Unioned into a single CurvilinearRegion + curved = out[1] + @test length(curved.exterior.curves) == 4 # original 4 curves + @test length(curved.exterior.p) == 6 # original 6 points + @test isempty(to_polygons(xor2d(curved, pathtopolys(pa)))) + + # CPW + pa = Path() + turn!(pa, 90°, 50μm, Paths.CPW(5μm, 5μm)) + turn!(pa, 90°, 50μm, Paths.CPW(5μm, 5μm)) + out = union2d_curved(pa) + @test length(out) == 2 + curved = out[1] + @test length(curved.exterior.curves) == 4 # original 4 curves + @test length(curved.exterior.p) == 6 # original 6 points + @test isempty(to_polygons(xor2d(out, pathtopolys(pa)))) + + # Generic curves + pa = Path() + turn!(pa, 90°, 50μm, Paths.TaperTrace(5μm, 10μm)) + out = union2d_curved(pa) + @test length(out) == 1 # Unioned into a single CurvilinearRegion + curved = out[1] + @test length(curved.exterior.curves) == 2 # Recognizes offset turn + @test isempty(to_polygons(xor2d(curved, pathtopolys(pa)))) + # BSpline offset + pa = Path() + bspline!(pa, [Point(0μm, 1mm)], 180°, Paths.Trace(10μm)) + out = union2d_curved(pa) + @test length(out) == 1 # Unioned into a single CurvilinearRegion + curved = out[1] + @test length(curved.exterior.curves) == 2 + @test isempty(to_polygons(xor2d(curved, pathtopolys(pa)))) +end + +@testitem "Provenance — Rounded polygon round trips" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d, union2d_curved, difference2d_curved + r = centered(Rectangle(10.0μm, 10.0μm)) + rr = Rounded(r, 1μm) + out = intersect2d_curved(r, rr) + curved = out[1] + @test length(curved.exterior.curves) == 4 + @test length(curved.exterior.p) == 8 + @test isempty(to_polygons(xor2d(curved, Curvilinear._as_entities(rr)))) + r2 = centered(Rectangle(100μm, 2μm)) + out = union2d_curved(rr, r2) + curved = out[1] + @test length(curved.exterior.curves) == 4 + @test length(curved.exterior.p) == 16 + @test isempty(to_polygons(xor2d(curved, [r2, Curvilinear._as_entities(rr)]))) + out = difference2d_curved(rr, r2) + @test length(out) == 2 + @test length(out[1].exterior.curves) == 2 + @test length(out[2].exterior.curves) == 2 + @test isempty(to_polygons(xor2d(out, difference2d(Curvilinear._as_entities(rr), r2)))) +end + +# ── Acceptance fixtures ───────────────────────────────────────────────────── + +@testitem "Provenance — single untouched arc, varied radius" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d, to_polygons + # An arc-bearing region unioned with a DISJOINT square recovers its arc intact + # across a range of radii (the integer-grid run survives byte-identical). + for R in (1.0, 100.0, 1.0μm, 100.0μm, 1.0mm) + z = zero(R) + T = typeof(R) + um = DeviceLayout.onemicron(T) + turn = Paths.Turn(90.0°, R; p0=Point(R, z), α0=90.0°) # (R,0) heading +y → (0,R) + region = CurvilinearRegion( + CurvilinearPolygon(Point{T}[(z, z), (R, z), (z, R)], [turn], [2]) + ) + sq = Polygon([ + Point(1e4, 1e4)um, + Point(1e4 + 1, 1e4)um, + Point(1e4 + 1, 1e4 + 1)um, + Point(1e4, 1e4 + 1)um + ]) + report = Tuple[] + out = recover_curves(union2d, region, sq; report=report) + @test count(t -> t[1] == :recovered, report) == 1 + @test count(t -> t[1] == :clipped, report) == 0 + curved = out[findfirst(r -> !isempty(r.exterior.curves), out)] + @test length(curved.exterior.curves) == 1 + @test length(curved.exterior.p) == 3 + @test length(to_polygons(curved.exterior).p) > 0 + @test isempty(to_polygons(xor2d(region, curved))) + end +end + +@testitem "Provenance — seam-rotation (union with self)" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d + # A rectangle with ONE rounded corner unioned with a copy of itself. Clipper + # rotates the start vertex of the output contour so the seam lands on a kink. + # The cyclic match_run handles it. + t = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 0.0), α0=0.0°) # (18,0) heading +x → (20,2) + region = CurvilinearRegion( + CurvilinearPolygon( + Point{Float64}[(0, 0), (18, 0), (20, 2), (20, 10), (0, 10)], + [t], + [2] + ) + ) + report = Tuple[] + out = recover_curves(union2d, region, region; report=report) + # The arc survives the union-with-self and is recovered onto the merged contour. + @test count(t -> t[1] == :recovered, report) ≥ 1 + @test count(t -> t[1] == :clipped, report) == 0 + curved = out[findfirst(r -> !isempty(r.exterior.curves), out)] + @test length(curved.exterior.curves) == 1 + @test length(curved.exterior.p) == 5 + @test curved.exterior.curves[1] isa Paths.Turn + @test isempty(to_polygons(xor2d(region, out))) +end + +@testitem "Provenance — reversed winding (arc on a hole)" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout.Curvilinear: recover_curves, difference2d, discretize_with_provenance + # Differencing a curved region out of a solid square puts the arc on the + # resulting HOLE boundary, whose winding is reversed relative to the input + # exterior. The reversed branch of match_run must fire to recover it. + plus = Polygon([Point(0.0, 0), Point(30, 0), Point(30, 30), Point(0, 30)]) + tm = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 10.0), α0=0.0°) # (18,10) → (20,12) + minus = CurvilinearRegion( + CurvilinearPolygon( + Point{Float64}[(10, 10), (18, 10), (20, 12), (20, 20), (10, 20)], + [tm], + [2] + ) + ) + report = Tuple[] + out = recover_curves(difference2d, plus, minus; report=report) + @test count(t -> t[1] == :recovered, report) == 1 + @test count(t -> t[1] == :clipped, report) == 0 + # The recovered curve lands on a hole, not the exterior. + @test sum(length(r.exterior.curves) for r in out) == 0 + @test sum(sum(length(h.curves) for h in r.holes; init=0) for r in out) == 1 + + # Confirm the reversed branch of match_run actually fired on the hole contour. + R = Float64 + polys_plus, _ = discretize_with_provenance([plus], R) + polys_minus, runs_m = discretize_with_provenance([minus], R) + clipped = difference2d(polys_plus, polys_minus) + # Output is geometrically identical + @test isempty(to_polygons(xor2d(clipped, out))) + saw_reversed = Ref(false) + walk(node) = + for c in node.children + snapped = DeviceLayout.Polygons.clipperize.(collect(c.contour)) + for pr in runs_m + hit = Curvilinear.match_run(snapped, pr.run) + (hit !== nothing && hit.reversed) && (saw_reversed[] = true) + end + walk(c) + end + walk(clipped.tree) + @test saw_reversed[] +end + +@testitem "Provenance — arcs on exterior and a hole" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, difference2d + # A region with an arc on its exterior, differenced by a curved region that + # becomes a hole carrying its own arc. Both arcs must be recovered, one on + # the exterior contour and one on the hole contour. + # NOTE: union2d with a disjoint square cannot be used here — discretizing a + # CurvilinearRegion yields exterior+hole as separate polygons, and union2d + # fills the hole back in. difference2d preserves both contours. + te = Paths.Turn(90.0°, 2.0; p0=Point(28.0, 0.0), α0=0.0°) # exterior arc (28,0) → (30,2) + plus = CurvilinearRegion( + CurvilinearPolygon( + Point{Float64}[(0, 0), (28, 0), (30, 2), (30, 30), (0, 30)], + [te], + [2] + ) + ) + th = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 10.0), α0=0.0°) # hole arc (18,10) → (20,12) + minus = CurvilinearRegion( + CurvilinearPolygon( + Point{Float64}[(10, 10), (18, 10), (20, 12), (20, 20), (10, 20)], + [th], + [2] + ) + ) + report = Tuple[] + out = recover_curves(difference2d, plus, minus; report=report) + @test count(t -> t[1] == :recovered, report) == 2 + @test count(t -> t[1] == :clipped, report) == 0 + n_ext = sum(length(r.exterior.curves) for r in out) + n_hole = sum(sum(length(h.curves) for h in r.holes; init=0) for r in out) + @test n_ext == 1 # one arc on an exterior contour + @test n_hole == 1 # one arc on a hole contour + @test isempty(to_polygons(xor2d(difference2d(plus, minus), out))) +end + +@testitem "Provenance — annulus / collision probe" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout.Curvilinear: recover_curves, union2d, discretize_with_provenance + # Two arcs of DIFFERENT radii in one geometry. Each must map to its own curve. + # Different radii produce different integer runs, so no cross-match collision + # is possible — we assert the runs differ to make that intent explicit. + t1 = Paths.Turn(90.0°, 2.0; p0=Point(10.0, 0.0), α0=0.0°) # r=2: (10,0) → (12,2) + t2 = Paths.Turn(90.0°, 5.0; p0=Point(12.0, 10.0), α0=90.0°) # r=5: (12,10) → (7,15) + pts = Point{Float64}[(0, 0), (10, 0), (12, 2), (12, 10), (7, 15), (0, 15)] + region = CurvilinearRegion(CurvilinearPolygon(pts, [t1, t2], [2, 4])) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + report = Tuple[] + out = recover_curves(union2d, region, sq; report=report) + @test count(t -> t[1] == :recovered, report) == 2 + @test count(t -> t[1] == :clipped, report) == 0 + curved = out[findfirst(r -> !isempty(r.exterior.curves), out)] + @test length(curved.exterior.curves) == 2 + radii = sort([c.r for c in curved.exterior.curves]) + @test radii == [2.0, 5.0] # each arc recovered as its own distinct curve + @test isempty(to_polygons(xor2d(region, curved))) + + # The two provenance runs differ → no cross-match collision is possible. + _, runs = discretize_with_provenance([region], Float64) + @test runs[1].run != runs[2].run +end + +@testitem "Provenance — straight edge cuts an arc (clipped, not recovered)" setup = + [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, difference2d + # A straight rectangle that crosses the arc interior. Clipper inserts a new + # intersection vertex on the arc, breaking its run. Recovery is all-or-nothing, + # so the cut arc is reported :clipped (NOT recovered) and falls back to polyline. + t = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 10.0), α0=0.0°) # (18,10) → (20,12) + region = CurvilinearRegion( + CurvilinearPolygon( + Point{Float64}[(10, 10), (18, 10), (20, 12), (20, 20), (10, 20)], + [t], + [2] + ) + ) + cutter = Polygon([ + Point(18.5, 10.5), + Point(25.0, 10.5), + Point(25.0, 13.0), + Point(18.5, 13.0) + ]) + report = Tuple[] + out = recover_curves(difference2d, region, cutter; report=report) + @test count(t -> t[1] == :recovered, report) == 0 + @test count(t -> t[1] == :clipped, report) == 1 + @test sum(length(r.exterior.curves) for r in out) == 0 # polyline fallback +end + +@testitem "Provenance — full circle across the seam" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d, to_polygons + # A closed-loop circle built from two half-circle Turns (a single 360° Turn is + # degenerate — start and end vertices coincide), unioned with a disjoint square. + # Both half-arcs must be recovered onto the single closed contour. + R = 10.0 + t1 = Paths.Turn(180.0°, R; p0=Point(R, 0.0), α0=90.0°) # top half: (R,0) → (-R,0) + t2 = Paths.Turn(180.0°, R; p0=Point(-R, 0.0), α0=-90.0°) # bottom half: (-R,0) → (R,0) + region = CurvilinearRegion( + CurvilinearPolygon(Point{Float64}[(R, 0), (-R, 0)], [t1, t2], [1, 2]) + ) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + report = Tuple[] + out = recover_curves(union2d, region, sq; report=report) + @test count(t -> t[1] == :recovered, report) == 2 + @test count(t -> t[1] == :clipped, report) == 0 + curved = out[findfirst(r -> !isempty(r.exterior.curves), out)] + @test length(curved.exterior.p) == 2 + @test length(curved.exterior.curves) == 2 + @test length(to_polygons(curved.exterior).p) > 0 + @test isempty(to_polygons(xor2d(region, curved))) +end + +@testitem "Provenance — BSpline segment (type-agnostic)" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, union2d + # A BSpline-bearing region unioned with a disjoint square. The recovery + # mechanism is curve-type-agnostic: it must recover the BSpline, not just arcs. + bpts = Point{Float64}[(10, 0), (13, 3), (16, 2), (18, 6)] + bs = Paths.BSpline(bpts, Point(1.0, 1.0), Point(1.0, 1.0)) + pts = Point{Float64}[(0, 0), bs.p0, bs.p1, (0, 10)] + region = CurvilinearRegion(CurvilinearPolygon(pts, [bs], [2])) + sq = Polygon([ + Point(1e4, 1e4), + Point(1e4 + 1, 1e4), + Point(1e4 + 1, 1e4 + 1), + Point(1e4, 1e4 + 1) + ]) + report = Tuple[] + out = recover_curves(union2d, region, sq; report=report) + @test count(t -> t[1] == :recovered, report) == 1 + @test count(t -> t[1] == :clipped, report) == 0 + curved = out[findfirst(r -> !isempty(r.exterior.curves), out)] + @test length(curved.exterior.curves) == 1 + @test curved.exterior.curves[1] isa Paths.BSpline + @test isempty(to_polygons(xor2d(region, curved))) +end + +@testitem "Provenance — clipped curve not spuriously recovered" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon + using DeviceLayout: recover_curves, difference2d + # Safety property: a destroyed arc must NEVER emit a (wrong) curve. Uses the same + # cut-arc geometry as the "straight edge cuts an arc" case and asserts zero + # recovery, a :clipped report, and a polyline (zero-curve) exterior. + t = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 10.0), α0=0.0°) + region = CurvilinearRegion( + CurvilinearPolygon( + Point{Float64}[(10, 10), (18, 10), (20, 12), (20, 20), (10, 20)], + [t], + [2] + ) + ) + cutter = Polygon([ + Point(18.5, 10.5), + Point(25.0, 10.5), + Point(25.0, 13.0), + Point(18.5, 13.0) + ]) + report = Tuple[] + out = recover_curves(difference2d, region, cutter; report=report) + @test count(t -> t[1] == :recovered, report) == 0 + @test any(t -> t[1] == :clipped, report) + @test all(isempty(r.exterior.curves) for r in out) +end From e73a338036e8fa176cce27b700d41a9ba8ab9821 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 2 Jun 2026 16:00:46 +0200 Subject: [PATCH 02/19] Remove duplicate API docs --- docs/src/reference/api.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index 4517fa491..a5b9c51dc 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -185,11 +185,6 @@ clip Polygons.clip_tiled Polygons.StyleDict - recover_curves - difference2d_curved - union2d_curved - intersect2d_curved - xor2d_curved ``` #### [Curvilinear geometry](@id api-curvilinear) From 906eabb5aa9713e8a52e93b8cfa762c1cb4bf252 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 2 Jun 2026 16:42:54 +0200 Subject: [PATCH 03/19] Fix missing import --- src/solidmodels/solidmodels.jl | 1 + 1 file changed, 1 insertion(+) diff --git a/src/solidmodels/solidmodels.jl b/src/solidmodels/solidmodels.jl index 4b3bfa18d..1acc806a0 100644 --- a/src/solidmodels/solidmodels.jl +++ b/src/solidmodels/solidmodels.jl @@ -67,6 +67,7 @@ import DeviceLayout: norm, render!, round_to_curvilinearpolygon, + rounded_corner_segment, uniquename, isapprox_angle import DeviceLayout.Paths: trace, gap, offset, extent, pathlength, bspline_approximation From faf0400bdf01b74ba745471c82b2ab06c1f2749f Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Tue, 2 Jun 2026 17:58:54 +0200 Subject: [PATCH 04/19] Fix segment identity comparison in test --- test/test_provenance_recovery.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/test_provenance_recovery.jl b/test/test_provenance_recovery.jl index af16afefa..3d398beaa 100644 --- a/test/test_provenance_recovery.jl +++ b/test/test_provenance_recovery.jl @@ -43,7 +43,10 @@ end polys, runs = discretize_with_provenance([cpoly], Float64) @test polys isa Vector{<:Polygon} @test length(runs) == 1 - @test runs[1].curve === turn + @test runs[1].curve.p0 === turn.p0 # Can't just check segment identity bc Julia 1.10 makes a copy in Curvilinear constructor + @test runs[1].curve.α0 === turn.α0 + @test runs[1].curve.α === turn.α + @test runs[1].curve.r === turn.r @test runs[1].run[1] == DeviceLayout.Polygons.clipperize(Point(10.0, 0.0)) @test length(runs[1].run) ≥ 3 end From 98a439bb555363faa1b1f3a95ad2e091e7baa657 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Wed, 3 Jun 2026 11:10:03 +0200 Subject: [PATCH 05/19] Fix recovery for input CurvilinearRegion with holes --- src/curve_recovery.jl | 24 +++++------------------- src/curvilinear.jl | 9 +++++++++ test/test_provenance_recovery.jl | 20 +++++++++++--------- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 6f0a74d03..27963543d 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -46,14 +46,12 @@ end function _collect_provenance!(polys, runs, e::CurvilinearRegion, ::Type{R}, atol) where {R} _collect_provenance!(polys, runs, e.exterior, R, atol) for h in e.holes - _collect_provenance!(polys, runs, h, R, atol) + _collect_provenance!(polys, runs, _reverse(h), R, atol) end return nothing end # Any other entity: no curves to recover; discretize to polygons. -# Operands that `clip` normalizes specially — `GeometryStructure`/`GeometryReference`/`Pair` -# (see `_normalize_clip_arg` in clipping.jl) — are not handled here; recover_curves expects entity-level inputs. function _collect_provenance!(polys, runs, e, ::Type{R}, atol) where {R} # Convert to polygons and append. to_polygons returns a polygon or array. poly_result = DeviceLayout.to_polygons(e) @@ -118,14 +116,11 @@ function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) whe for (ri, pr) in enumerate(runs) used[ri] && continue hit = match_run(snapped, pr.run) - hit === nothing && continue - if hit.reversed && !hasmethod(reverse, Tuple{typeof(pr.curve)}) - continue # can't reverse → leave for :clipped - end + isnothing(hit) && continue seg = hit.reversed ? reverse(pr.curve) : pr.curve push!(matched, (hit.start, length(pr.run), seg)) used[ri] = true - report !== nothing && push!(report, (:recovered, pr.curve, ci)) + !isnothing(report) && push!(report, (:recovered, pr.curve, ci)) end isempty(matched) && return CurvilinearPolygon{T}(pts, Paths.Segment[], Int[]) @@ -159,7 +154,7 @@ function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) whe ext = build_cpoly(node) # Holes must be CCW, but come out CW from Clipper holes = CurvilinearPolygon{T}[ - _reverse_curvilinear_polygon(build_cpoly(child)) for child in node.children + _reverse(build_cpoly(child)) for child in node.children ] push!(out, CurvilinearRegion{T}(ext, holes)) for child in node.children @@ -171,7 +166,7 @@ function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) whe for child in clipped.tree.children add_region(child) end - if report !== nothing + if !isnothing(report) for (ri, pr) in enumerate(runs) used[ri] || push!(report, (:clipped, pr.curve, 0)) end @@ -179,15 +174,6 @@ function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) whe return out end -function _reverse_curvilinear_polygon(e::CurvilinearPolygon) - csi_rev = (i, N) -> mod1(i + 1, N) - N - 1 - return CurvilinearPolygon( - reverse(e.p), - reverse(e.curves), - reverse(csi_rev.(e.curve_start_idx, length(e.p))) - ) -end - # Normalize a clip operand into a flat Vector of entities, preserving curve-bearing # entities intact (unlike _normalize_clip_arg, which discretizes via to_polygons). _as_entities(p::DeviceLayout.GeometryEntity) = [p] diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 633115172..697113985 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -146,6 +146,15 @@ function to_polygons( return Polygon{T}(p) end +function _reverse(e::CurvilinearPolygon) + csi_rev = (i, N) -> mod1(i + 1, N) - N - 1 + return CurvilinearPolygon( + reverse(e.p), + reverse(e.curves), + reverse(csi_rev.(e.curve_start_idx, length(e.p))) + ) +end + function transform(e::CurvilinearPolygon, f::Transformation) # If the transformation is a reflection, have to fix the winding. # curve_start_idx are shifted forward 1, reversed, then negated. diff --git a/test/test_provenance_recovery.jl b/test/test_provenance_recovery.jl index 3d398beaa..796fbceac 100644 --- a/test/test_provenance_recovery.jl +++ b/test/test_provenance_recovery.jl @@ -241,8 +241,6 @@ end @test isempty(to_polygons(xor2d(out, difference2d(Curvilinear._as_entities(rr), r2)))) end -# ── Acceptance fixtures ───────────────────────────────────────────────────── - @testitem "Provenance — single untouched arc, varied radius" setup = [CommonTestSetup] begin using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon using DeviceLayout: recover_curves, union2d, to_polygons @@ -277,7 +275,7 @@ end @testitem "Provenance — seam-rotation (union with self)" setup = [CommonTestSetup] begin using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon using DeviceLayout: recover_curves, union2d - # A rectangle with ONE rounded corner unioned with a copy of itself. Clipper + # A rectangle with one rounded corner unioned with a copy of itself. Clipper # rotates the start vertex of the output contour so the seam lands on a kink. # The cyclic match_run handles it. t = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 0.0), α0=0.0°) # (18,0) heading +x → (20,2) @@ -304,7 +302,7 @@ end using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon using DeviceLayout.Curvilinear: recover_curves, difference2d, discretize_with_provenance # Differencing a curved region out of a solid square puts the arc on the - # resulting HOLE boundary, whose winding is reversed relative to the input + # resulting hole boundary, whose winding is reversed relative to the input # exterior. The reversed branch of match_run must fire to recover it. plus = Polygon([Point(0.0, 0), Point(30, 0), Point(30, 30), Point(0, 30)]) tm = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 10.0), α0=0.0°) # (18,10) → (20,12) @@ -342,6 +340,13 @@ end end walk(clipped.tree) @test saw_reversed[] + + # Now curve recovery with CR with a hole as input + self_union = union2d_curved(out) + @test sum(length(r.exterior.curves) for r in self_union) == 0 + @test sum(sum(length(h.curves) for h in r.holes; init=0) for r in self_union) == 1 + @test isempty(to_polygons(xor2d(self_union, out))) + @test isempty(to_polygons(xor2d(self_union, union2d(out)))) end @testitem "Provenance — arcs on exterior and a hole" setup = [CommonTestSetup] begin @@ -350,9 +355,6 @@ end # A region with an arc on its exterior, differenced by a curved region that # becomes a hole carrying its own arc. Both arcs must be recovered, one on # the exterior contour and one on the hole contour. - # NOTE: union2d with a disjoint square cannot be used here — discretizing a - # CurvilinearRegion yields exterior+hole as separate polygons, and union2d - # fills the hole back in. difference2d preserves both contours. te = Paths.Turn(90.0°, 2.0; p0=Point(28.0, 0.0), α0=0.0°) # exterior arc (28,0) → (30,2) plus = CurvilinearRegion( CurvilinearPolygon( @@ -383,7 +385,7 @@ end @testitem "Provenance — annulus / collision probe" setup = [CommonTestSetup] begin using DeviceLayout: Point, CurvilinearPolygon, CurvilinearRegion, Paths, Polygon using DeviceLayout.Curvilinear: recover_curves, union2d, discretize_with_provenance - # Two arcs of DIFFERENT radii in one geometry. Each must map to its own curve. + # Two arcs of different radii in one geometry. Each must map to its own curve. # Different radii produce different integer runs, so no cross-match collision # is possible — we assert the runs differ to make that intent explicit. t1 = Paths.Turn(90.0°, 2.0; p0=Point(10.0, 0.0), α0=0.0°) # r=2: (10,0) → (12,2) @@ -417,7 +419,7 @@ end using DeviceLayout: recover_curves, difference2d # A straight rectangle that crosses the arc interior. Clipper inserts a new # intersection vertex on the arc, breaking its run. Recovery is all-or-nothing, - # so the cut arc is reported :clipped (NOT recovered) and falls back to polyline. + # so the cut arc is reported :clipped (not recovered) and falls back to polyline. t = Paths.Turn(90.0°, 2.0; p0=Point(18.0, 10.0), α0=0.0°) # (18,10) → (20,12) region = CurvilinearRegion( CurvilinearPolygon( From a3284fa2c095251472a0c5dea5c8d5daa6e4bf4e Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Wed, 3 Jun 2026 11:23:47 +0200 Subject: [PATCH 06/19] Test multi-polygon entity processing in curved boolean --- test/test_provenance_recovery.jl | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/test_provenance_recovery.jl b/test/test_provenance_recovery.jl index 796fbceac..b35904b61 100644 --- a/test/test_provenance_recovery.jl +++ b/test/test_provenance_recovery.jl @@ -173,6 +173,9 @@ end @test isempty(to_polygons(xor2d(a, b))) @test isempty(to_polygons(xor2d(b, c))) @test isempty(to_polygons(xor2d(c, d))) + # Mutli-polygon ClippedPolygon input + multi_cp = union2d(sq, sq + Point(10, 10)) + @test isempty(to_polygons(xor2d(multi_cp, union2d_curved(multi_cp)))) end @testitem "Provenance — Path round trips" setup = [CommonTestSetup] begin @@ -347,6 +350,15 @@ end @test sum(sum(length(h.curves) for h in r.holes; init=0) for r in self_union) == 1 @test isempty(to_polygons(xor2d(self_union, out))) @test isempty(to_polygons(xor2d(self_union, union2d(out)))) + + # Clipper op returns ClippedPolygon with grandchildren + outer = centered(Rectangle(100.0, 100.0)) + curved_gc = difference2d_curved(outer, out) + @test length(curved_gc) == 2 # Inner is separate region + @test sum(length(r.exterior.curves) for r in curved_gc) == 1 + @test sum(sum(length(h.curves) for h in r.holes; init=0) for r in curved_gc) == 0 + @test isempty(to_polygons(xor2d(curved_gc, difference2d(outer, out)))) + @test isempty(to_polygons(xor2d(curved_gc, difference2d(outer, out)))) end @testitem "Provenance — arcs on exterior and a hole" setup = [CommonTestSetup] begin From 6a19e5fbd25ecdeb5dbd4a28c1c18c5109e2b5c3 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Wed, 3 Jun 2026 11:24:10 +0200 Subject: [PATCH 07/19] Run formatter --- src/curve_recovery.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 27963543d..0832087d0 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -153,9 +153,8 @@ function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) whe function add_region(node) ext = build_cpoly(node) # Holes must be CCW, but come out CW from Clipper - holes = CurvilinearPolygon{T}[ - _reverse(build_cpoly(child)) for child in node.children - ] + holes = + CurvilinearPolygon{T}[_reverse(build_cpoly(child)) for child in node.children] push!(out, CurvilinearRegion{T}(ext, holes)) for child in node.children for gc in child.children From d5cf862989defd15225f7d375d834b13d2a0c392 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Thu, 11 Jun 2026 02:05:04 +0200 Subject: [PATCH 08/19] Fix unsorted curve start index bugs --- src/curvilinear.jl | 10 +++++++++ src/solidmodels/render.jl | 9 +------- test/test_provenance_recovery.jl | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 697113985..9a9b358e3 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -99,6 +99,16 @@ struct CurvilinearPolygon{T} <: GeometryEntity{T} for (curve_idx, start_idx) in enumerate(csi) csi[curve_idx] = start_idx - count(dup_idx .< start_idx) end + # Consumers (`to_polygons`, `_collect_provenance!`) walk curves with a running + # cursor and slice `p[i:csi]`, which requires `csi` ascending. Producers such as + # `_reverse` and reflective `transform` can emit unsorted indices when a curve + # wraps around to the last vertex, so sort curves and indices jointly here to + # restore the invariant for every producer. + if length(csi) > 1 + perm = sortperm(csi) + c = c[perm] + csi = csi[perm] + end return new{T}(p, c, csi) end end diff --git a/src/solidmodels/render.jl b/src/solidmodels/render.jl index d6854f45c..84cf6902f 100644 --- a/src/solidmodels/render.jl +++ b/src/solidmodels/render.jl @@ -313,14 +313,7 @@ function round_to_curvilinearpolygon( push!(new_curve_start_idx, csi) end end - - # Sort curves by start index so to_polygons can iterate in vertex order - if length(new_curve_start_idx) > 1 - perm = sortperm(new_curve_start_idx) - new_curves = new_curves[perm] - new_curve_start_idx = new_curve_start_idx[perm] - end - + # Constructor will sort curves by start index return CurvilinearPolygon(new_points, new_curves, new_curve_start_idx) end diff --git a/test/test_provenance_recovery.jl b/test/test_provenance_recovery.jl index b35904b61..1f128df03 100644 --- a/test/test_provenance_recovery.jl +++ b/test/test_provenance_recovery.jl @@ -533,3 +533,38 @@ end @test any(t -> t[1] == :clipped, report) @test all(isempty(r.exterior.curves) for r in out) end + +@testitem "Provenance — curve_start_idx sorted invariant" setup = [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, Paths, to_polygons, Reflection, ° + using DeviceLayout.Curvilinear: _reverse + # to_polygons walks curves with a running cursor and slices p[i:csi], which requires + # curve_start_idx ascending. _reverse and reflective transform both flip a curve that + # wraps to the last vertex into the unsorted order [4, 2], which either throws an + # AssertionError or silently duplicates vertices (corrupt geometry). The constructor + # sorts curves and indices jointly so every producer preserves the invariant. + + # Stadium: straight bottom/top, semicircle caps. The left cap (vertex 4 → vertex 1) + # is the wrap-around curve that triggers the bug under reversal/reflection. + pts = Point{Float64}[(0, 0), (10, 0), (10, 10), (0, 10)] + cap_r = Paths.Turn(180.0°, 5.0; p0=Point(10.0, 0.0), α0=0.0°) # (10,0) → (10,10) + cap_l = Paths.Turn(180.0°, 5.0; p0=Point(0.0, 10.0), α0=180.0°) # (0,10) → (0,0) + cp = CurvilinearPolygon(copy(pts), [cap_r, cap_l], [2, 4]) + n_fwd = length(to_polygons(cp).p) + + # Constructor reorders curves jointly with indices when given unsorted input. + unsorted = CurvilinearPolygon(copy(pts), [cap_l, cap_r], [4, 2]) + @test issorted(unsorted.curve_start_idx) + @test unsorted.curves == cp.curves + @test to_polygons(unsorted).p == to_polygons(cp).p + + # _reverse produces a wrap-around curve (csi would be [4, 2]) — must stay sorted and + # round-trip without the extra vertices the corrupt path introduced. + rev = _reverse(cp) + @test issorted(rev.curve_start_idx) + @test length(to_polygons(rev).p) == n_fwd + + # Reflective transform uses the same csi-flip logic and had the identical bug. + refl = DeviceLayout.transform(cp, Reflection(0.0°)) + @test issorted(refl.curve_start_idx) + @test length(to_polygons(refl).p) == n_fwd +end From 119ba9849eab044f30969482b058370056cd9937 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Thu, 11 Jun 2026 02:42:49 +0200 Subject: [PATCH 09/19] Update docs and changelog --- CHANGELOG.md | 4 ++++ docs/src/concepts/polygons.md | 2 +- src/curve_recovery.jl | 20 +++++++++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 547b3edd6..4314f0a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ The format of this changelog is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + + - Added `recover_curves` and the curve-preserving Boolean variants `union2d_curved`, `difference2d_curved`, `intersect2d_curved`, and `xor2d_curved`, which recover original curves (arcs, splines) from a clipped result wherever their discretized footprint survived the operation intact, returning a `Vector{CurvilinearRegion}` + ## 1.14.0 (2026-05-28) - Added `ParameterSet`, a nested dictionary wrapper with dot-access for reading and diff --git a/docs/src/concepts/polygons.md b/docs/src/concepts/polygons.md index b18e6c404..39a8d5cea 100644 --- a/docs/src/concepts/polygons.md +++ b/docs/src/concepts/polygons.md @@ -21,7 +21,7 @@ Geometric Boolean operations on polygons are called "clipping" operations. For 2 !!! info - Boolean operations in 3D with `SolidModel` are handled by the Open CASCADE Technology kernel, which works directly with rich geometry types rendered from our native `CoordinateSystem`. If you need boolean operations involving curved geometry whose results can't be achieved by clipping-then-rounding, then your 2D geometry should defer the boolean operation until `SolidModel` postrendering so that the result will still be represented with curves. + Boolean operations in 3D with `SolidModel` are handled by the Open CASCADE Technology kernel, which works directly with rich geometry types rendered from our native `CoordinateSystem`. If you need boolean operations involving curved geometry whose results can't be achieved by clipping-then-rounding, you have two options: keep curves in 2D using the curve-preserving Boolean variants (see [Recovering curves through clipping](@ref) below), or defer the boolean operation until `SolidModel` postrendering so that the result will still be represented with curves. For many use cases, `union2d`, `difference2d`, `intersect2d`, and `xor2d` behave as expected and are easiest to use. More general operations may be accomplished using the `clip` function. diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 0832087d0..401ee070c 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -222,10 +222,15 @@ disjoint piece becomes a separate region). The first argument `op` must be one of the polygon clipping operations: `difference2d`, `union2d`, `intersect2d`, or `xor2d`. -The second and third arguments (`plus` and `minus`) may each be a `GeometryEntity` or array -of `GeometryEntity`. Curve-bearing entities (`CurvilinearPolygon`, `CurvilinearRegion`, and -`Path` segments like `Turn`/`BSpline`) have their curves tracked through discretization and -recovered in the result. All other entities are discretized via `to_polygons`. +The second and third arguments (`plus` and `minus`) accept the same forms as the +corresponding clipping operation: a `GeometryEntity` or array of `GeometryEntity`, a +`GeometryStructure` or `GeometryReference` (whose flattened elements are used), or a pair +`geom => layer` selecting only elements in those layers from the flattened structure. + +Curve-bearing entities have their curves tracked through discretization and recovered in +the result: `CurvilinearPolygon`, `CurvilinearRegion`, `Path` nodes (e.g. `Turn`/`BSpline` +segments rendered with a `Style`), and `Rounded`-styled `Polygon`/`Rectangle`. All other +entities are discretized via `to_polygons`. ## Keyword arguments @@ -261,7 +266,9 @@ function recover_curves(op, plus, minus; report=nothing) R = _recover_coordtype(plus, minus) pp, runs_p = discretize_with_provenance(_as_entities(plus), R) pm, runs_m = discretize_with_provenance(_as_entities(minus), R) - clipped = op(pp, pm) + # Annotate the result so a wrong `op` (not one of difference2d/union2d/intersect2d/ + # xor2d) fails here with a clear TypeError rather than deep inside substitute_curves. + clipped = op(pp, pm)::ClippedPolygon return substitute_curves(clipped, vcat(runs_p, runs_m); report=report) end @@ -274,8 +281,11 @@ See [`recover_curves`](@ref). difference2d_curved(p, m; kwargs...) = recover_curves(difference2d, p, m; kwargs...) """ union2d_curved(p1, p2; report=nothing) + union2d_curved(p; report=nothing) Curve-preserving variant of [`union2d`](@ref), returning `Vector{CurvilinearRegion}`. +The single-argument form self-unions `p` (equivalent to `union2d_curved(p, [])`), which is +useful for merging a collection of overlapping curved entities into one region per piece. See [`recover_curves`](@ref). """ union2d_curved(p, m; kwargs...) = recover_curves(union2d, p, m; kwargs...) From 29fd346cf2e002b0a002031f4adef09a8a11fb69 Mon Sep 17 00:00:00 2001 From: Gregory Peairs Date: Thu, 11 Jun 2026 19:06:22 +0200 Subject: [PATCH 10/19] Use islinear(seg, sty) dispatch for pathtopolys in curve recovery --- src/curve_recovery.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 401ee070c..d3f2b4b97 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -183,7 +183,7 @@ _as_entities(p::Pair{<:Union{GeometryStructure, GeometryReference}}) = _as_entities(flat_elements(p)) function _as_entities(p::Paths.Node) iszero(pathlength(p.seg)) && p.sty isa ContinuousStyle && return [] - return _as_entities(pathtopolys(p.seg, p.sty)) + return _as_entities(pathtopolys(p)) # Use `islinear` dispatch on segment and style end # A Rounded-styled straight Polygon recovers as its exact-arc CurvilinearPolygon, so # corners survive the clip. Mirrors to_primitives(::SolidModel, ::StyledEntity{Polygon,Rounded}). From 773387ad0f7d3bd6587e39ebdc0dc9d7591458de Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Mon, 15 Jun 2026 17:39:48 +0000 Subject: [PATCH 11/19] Add _as_entities for Paths.Node + fix CurvilinearPolygon{T} constructor - Import ContinuousStyle in curve_recovery.jl for zero-length path check - Fix parametric constructor call in CurvilinearPolygon(::Polygon{T}) --- src/curve_recovery.jl | 1 + src/curvilinear.jl | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index d3f2b4b97..cd249605a 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -2,6 +2,7 @@ # Included in Curvilinear — needs CurvilinearRegion, discretize_curve, and the Polygons clip functions. using .Polygons: clipperize, ClippedPolygon, union2d, difference2d, intersect2d, xor2d +import ..Paths: ContinuousStyle # One discretized curve's footprint on the integer grid, paired with its source segment. # The integer-grid coordinate type P matches whatever Polygons.clipperize(::Point{R}) produces. diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 9a9b358e3..85e68f095 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -118,7 +118,7 @@ function CurvilinearPolygon(points::Vector{Point{T}}) where {T} # Straight segments are implicit return CurvilinearPolygon{T}(points, Paths.Segment[], Int[]) end -CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon{T}(points(p)) +CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon(points(p)) ### Conversion methods function to_polygons( From a7ce02750d366b5960e3f0cd1f4ac153fbb53c5f Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Mon, 15 Jun 2026 19:21:13 +0000 Subject: [PATCH 12/19] Curve-preserving _as_entities: unwrap StyledEntity to recover native curves Adds _as_entities(::StyledEntity) = _as_entities(p.ent) so a wrapped Node / Rounded-Polygon / CurvilinearPolygon is curve-recovered through the boolean instead of discretized (the geometrically-transparent style wrappers otherwise fall to the generic discretizing path, silently losing arcs). Mirrors the render-side to_primitives(::StyledEntity) default. The specific Rounded(Polygon/Rectangle) method remains more specific and still wins. Also keeps the prior _as_entities(::Node) + CurvilinearPolygon{T} ctor fix. --- src/curve_recovery.jl | 10 +++++++++- src/curvilinear.jl | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index cd249605a..ee702c046 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -2,7 +2,6 @@ # Included in Curvilinear — needs CurvilinearRegion, discretize_curve, and the Polygons clip functions. using .Polygons: clipperize, ClippedPolygon, union2d, difference2d, intersect2d, xor2d -import ..Paths: ContinuousStyle # One discretized curve's footprint on the integer grid, paired with its source segment. # The integer-grid coordinate type P matches whatever Polygons.clipperize(::Point{R}) produces. @@ -201,6 +200,15 @@ function _as_entities( ) ] end +# A geometrically-transparent style (e.g. MeshSized — a mesh-density hint applied via +# `to_polygons(ent, ::MeshSized) = to_polygons(ent)`) must NOT block curve recovery: a +# MeshSized-wrapped Node / Rounded-Polygon / CurvilinearPolygon carries the same curves as +# the bare entity. Without this, such a wrapped entity falls to the generic +# `_as_entities(::GeometryEntity) = [p]` and is discretized by `to_polygons`, silently losing +# its arcs through the boolean. Recurse to the inner entity (mirrors the render-side default +# `to_primitives(::SolidModel, ::StyledEntity) = to_primitives(sm, ent.ent)`). The specific +# Rounded method above is more specific and still wins for Rounded-styled Polygon/Rectangle. +_as_entities(p::StyledEntity) = _as_entities(p.ent) # Promoted coordinate type matching what clip's promote_type would pick. function _recover_coordtype(plus, minus) diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 85e68f095..9a9b358e3 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -118,7 +118,7 @@ function CurvilinearPolygon(points::Vector{Point{T}}) where {T} # Straight segments are implicit return CurvilinearPolygon{T}(points, Paths.Segment[], Int[]) end -CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon(points(p)) +CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon{T}(points(p)) ### Conversion methods function to_polygons( From 55bd4a78cf1c16498ee0ec0b6bd76c4a3165827a Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Mon, 15 Jun 2026 19:28:03 +0000 Subject: [PATCH 13/19] Fix CurvilinearPolygon(::Polygon{T}) to call working 1-arg ctor Line 111 called CurvilinearPolygon{T}(points(p)) but no 1-arg {T} method exists (only the 3-arg {T}(p,c,csi)). Restore CurvilinearPolygon(points(p)), which hits the 1-arg non-parametric method at line 107. Triggered by the CPWOpenTermination pathtopolys path, now reachable because _as_entities unwraps MeshSized{Node} (CPW paths) for curve recovery. --- src/curvilinear.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 9a9b358e3..85e68f095 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -118,7 +118,7 @@ function CurvilinearPolygon(points::Vector{Point{T}}) where {T} # Straight segments are implicit return CurvilinearPolygon{T}(points, Paths.Segment[], Int[]) end -CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon{T}(points(p)) +CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon(points(p)) ### Conversion methods function to_polygons( From 81bd91acb2140819f23d4c912b7de72fdfa4da46 Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Mon, 15 Jun 2026 19:57:28 +0000 Subject: [PATCH 14/19] Qualify Paths.ContinuousStyle in _as_entities(::Node) A bare ContinuousStyle is not in scope inside the Curvilinear submodule (it's defined in Paths and not visibly imported), throwing UndefVarError on the L1 zero-length-node path. Paths.ContinuousStyle is robust and needs no import. (The workflow agent flagged this; qualifying is cleaner than re-adding the import that kept getting lost in the shared-repo churn.) --- src/curve_recovery.jl | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index ee702c046..cd2dace66 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -182,8 +182,11 @@ _as_entities(p::Union{GeometryStructure, GeometryReference}) = _as_entities(p::Pair{<:Union{GeometryStructure, GeometryReference}}) = _as_entities(flat_elements(p)) function _as_entities(p::Paths.Node) - iszero(pathlength(p.seg)) && p.sty isa ContinuousStyle && return [] - return _as_entities(pathtopolys(p)) # Use `islinear` dispatch on segment and style + # Paths.-qualified: ContinuousStyle is defined in the Paths submodule but is NOT in scope + # unqualified inside Curvilinear (a bare `ContinuousStyle` throws UndefVarError on the L1 + # zero-length-node path). Qualifying is robust and needs no import. + iszero(pathlength(p.seg)) && p.sty isa Paths.ContinuousStyle && return [] + return _as_entities(pathtopolys(p.seg, p.sty)) # Use `islinear` dispatch on segment and style end # A Rounded-styled straight Polygon recovers as its exact-arc CurvilinearPolygon, so # corners survive the clip. Mirrors to_primitives(::SolidModel, ::StyledEntity{Polygon,Rounded}). From 153c8dc6d68cb85e77fcb6edb5ac94b8e57a1e5f Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Mon, 15 Jun 2026 21:36:44 +0000 Subject: [PATCH 15/19] _as_entities recovers Rounded/StyleDict ClippedPolygon corners as arcs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Rounded- or StyleDict{Rounded}-styled ClippedPolygon (non-curved difference2d → ClippedPolygon then rounded on top) fell to the generic _as_entities(::GeometryEntity)=[p] → to_polygons → discretized, silently losing every rounded corner through the boolean. Add _as_entities methods that reuse the proven render-side SolidModels.to_curvilinear_regions (walks the clipped tree, rounds each contour into a CurvilinearPolygon with exact fillet arcs) so the corners are tracked + recovered as native arcs. Verified: a Rounded(ClippedPolygon) now yields Turn arcs through union2d_curved (was 0). SolidModels loads after this file but _as_entities only runs at boolean time, so the qualified name resolves at call time. --- src/curve_recovery.jl | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index cd2dace66..d0d2b6eb2 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -203,6 +203,25 @@ function _as_entities( ) ] end +# A Rounded- or StyleDict{Rounded}-styled ClippedPolygon (e.g. the output of a non-curved +# `difference2d`/`union2d` then rounded — followed by post-clip rounding) recovers as exact +# fillet arcs per contour, so its corners survive the clip instead of discretizing. The render +# path already does this conversion (`to_curvilinear_regions` walks the clipped tree, applying +# `styled_loop`+`round_to_curvilinearpolygon` per contour → CurvilinearRegions with arcs); we +# reuse that exact function so boolean recovery matches the render. `SolidModels` loads after +# this file, but `_as_entities` only runs at boolean time (all modules loaded), so the qualified +# name resolves at call time. Returns a Vector{CurvilinearRegion}; `_as_entities(::AbstractArray)` +# flattens it, and `_collect_provenance!(::CurvilinearRegion)` records each contour's curve runs. +function _as_entities( + p::StyledEntity{T, ClippedPolygon{T}, <:Polygons.StyleDict} +) where {T} + return DeviceLayout.SolidModels.to_curvilinear_regions(p.ent, p.sty) +end +function _as_entities( + p::StyledEntity{T, ClippedPolygon{T}, <:Polygons.Rounded} +) where {T} + return DeviceLayout.SolidModels.to_curvilinear_regions(p.ent, Polygons.StyleDict(p.sty)) +end # A geometrically-transparent style (e.g. MeshSized — a mesh-density hint applied via # `to_polygons(ent, ::MeshSized) = to_polygons(ent)`) must NOT block curve recovery: a # MeshSized-wrapped Node / Rounded-Polygon / CurvilinearPolygon carries the same curves as @@ -210,7 +229,7 @@ end # `_as_entities(::GeometryEntity) = [p]` and is discretized by `to_polygons`, silently losing # its arcs through the boolean. Recurse to the inner entity (mirrors the render-side default # `to_primitives(::SolidModel, ::StyledEntity) = to_primitives(sm, ent.ent)`). The specific -# Rounded method above is more specific and still wins for Rounded-styled Polygon/Rectangle. +# Rounded / ClippedPolygon methods above are more specific and still win. _as_entities(p::StyledEntity) = _as_entities(p.ent) # Promoted coordinate type matching what clip's promote_type would pick. From 4563d7522830c09aef0517cda792b12bdd0ec68b Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Tue, 16 Jun 2026 01:15:30 +0000 Subject: [PATCH 16/19] Warn when recover_curves silently discretizes a curve-bearing entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dangerous curve-loss path was invisible: an entity whose (type,style) has no _as_entities method falls to the generic _collect_provenance! → to_polygons → discretized to polyline with NO ProvenanceRun, so it never appears in the boolean report and its arcs vanish silently. (This is how several styled-ClippedPolygon cases hid their losses.) Add _count_arclike_runs (Kasa fit on the discretized polygon) to detect a likely curve loss, @warn once per entity (type,style), and tally into curve_loss_log() so a run reports which entity classes lost curves. Gated by warn_on_curve_loss[] (default true). Plain rectilinear polygons fit nothing → no warning. --- src/curve_recovery.jl | 70 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index d0d2b6eb2..15faeec07 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -51,17 +51,71 @@ function _collect_provenance!(polys, runs, e::CurvilinearRegion, ::Type{R}, atol return nothing end -# Any other entity: no curves to recover; discretize to polygons. +# Count, in already-clipperized integer space, runs of short edges that fit a circle — a cheap +# proxy for "this discretized polygon CONTAINS curve geometry we are about to lose". Used only to +# warn on the silent-discretization path below; not exact, just enough to flag a likely loss. +function _count_arclike_runs(pts; short_nm=6000.0, min_run=4, resid_nm=2.0) + n = length(pts); n < min_run + 1 && return 0 + xy = [(Float64(ustrip(getx(p))) / 1000, Float64(ustrip(gety(p))) / 1000) for p in pts] # → ~nm scale-agnostic + # edge lengths in the same units as xy + el = [hypot(xy[mod1(i + 1, n)][1] - xy[i][1], xy[mod1(i + 1, n)][2] - xy[i][2]) for i in 1:n] + short = short_nm / 1000 + cnt = 0; i = 1 + while i <= n + if !(0 < el[i] < short); i += 1; continue; end + j = i; while j < n && 0 < el[mod1(j + 1, n)] < short; j += 1; end + rl = j - i + 1 + if rl >= min_run + vx = [xy[mod1(i + k, n)][1] for k in 0:rl]; vy = [xy[mod1(i + k, n)][2] for k in 0:rl] + m = length(vx); sx = sum(vx); sy = sum(vy) + sxx = sum(vx .^ 2); syy = sum(vy .^ 2); sxy = sum(vx .* vy) + sxxx = sum(vx .^ 3); syyy = sum(vy .^ 3); sxyy = sum(vx .* vy .^ 2); sxxy = sum(vx .^ 2 .* vy) + A = m * sxx - sx^2; B = m * sxy - sx * sy; Cc = m * syy - sy^2 + D = 0.5 * (m * sxxx + m * sxyy - sx * sxx - sx * syy); E = 0.5 * (m * syyy + m * sxxy - sy * syy - sy * sxx) + den = A * Cc - B^2 + if abs(den) > 1e-12 + cx = (D * Cc - B * E) / den; cy = (A * E - B * D) / den + r = sqrt(max(0.0, (sxx + syy - 2cx * sx - 2cy * sy) / m + cx^2 + cy^2)) + if 1e-3 < r < 1e5 + mr = maximum(abs(hypot(vx[k] - cx, vy[k] - cy) - r) for k in 1:m) + mr < resid_nm / 1000 && (cnt += 1) + end + end + end + i = j + 1 + end + return cnt +end + +# Module-level switch for the silent-discretization warning (default ON; set false to silence). +const warn_on_curve_loss = Ref(true) +# Track per-(entity-type) loss counts so a run can report which entity classes lost curves. +const _curve_loss_log = Dict{String, Int}() +curve_loss_log() = _curve_loss_log +reset_curve_loss_log!() = empty!(_curve_loss_log) + +# Any other entity: no curves to recover; discretize to polygons. THIS is the silent-loss path — +# a curve-bearing entity whose (type, style) combination has no `_as_entities`/`_collect_provenance!` +# method falls here and is discretized to polyline with NO provenance, so its curves can never be +# recovered downstream. We detect the likely-loss case (the discretized polygon contains short-edge +# runs that fit a circle) and warn once per entity type, and tally it in `_curve_loss_log`, so such +# losses are observable instead of silent. (Plain rectilinear polygons fit nothing → no warning.) function _collect_provenance!(polys, runs, e, ::Type{R}, atol) where {R} # Convert to polygons and append. to_polygons returns a polygon or array. poly_result = DeviceLayout.to_polygons(e) - if poly_result isa Polygon - push!(polys, convert(Polygon{R}, poly_result)) - else - # It's a vector or other collection of polygons - for poly in poly_result - push!(polys, convert(Polygon{R}, poly)) - end + polylist = poly_result isa Polygon ? (poly_result,) : poly_result + arclike = 0 + for poly in polylist + push!(polys, convert(Polygon{R}, poly)) + warn_on_curve_loss[] && (arclike += _count_arclike_runs(points(poly))) + end + if warn_on_curve_loss[] && arclike > 0 + key = string(nameof(typeof(e))) + e isa StyledEntity && (key = "Styled{" * string(nameof(typeof(e.ent))) * "," * string(nameof(typeof(e.sty))) * "}") + first_seen = !haskey(_curve_loss_log, key) + _curve_loss_log[key] = get(_curve_loss_log, key, 0) + arclike + first_seen && @warn "recover_curves: discretizing a curve-bearing entity with no provenance — its arcs are LOST. " * + "Add an _as_entities method for this (type, style) to recover them." entity_type=key arclike_runs=arclike end return nothing end From f388049483b1b1b0864578b251f6b562555cbf1b Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Tue, 16 Jun 2026 23:17:55 +0000 Subject: [PATCH 17/19] Sort-tolerant to_polygons + _collect_provenance! walks; qualify ustrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes in the curve-recovery / discretization path: 1. ustrip crash (curve_recovery.jl:66): _count_arclike_runs used bare `ustrip`, which isn't imported into the Curvilinear module → UndefVarError whenever the silent-discretization warning path fires (any ClippedPolygon hitting the _collect_provenance! fallback). Qualified as DeviceLayout.ustrip. This was a hard crash hit independently from multiple downstream pipelines. 2. Sort-tolerant walks (to_polygons(::CurvilinearPolygon) + _collect_provenance!): both walks advance a running index i and emit p[i:csi] before each curve, assuming curve_start_idx is ascending. The constructor invariant (introduced earlier in this branch) normalizes csi on construction, but *deserialized* polygons that predate constructor-side normalization can still arrive with unsorted csi — e.g. [n, 4, 6, ...] when a curve at the wrap seam (csi == length(p)) is listed first — making p[i:csi] dump the whole ring up front and append that curve's arc at the tail, ending one discretization step short of its endpoint → a sub-µm near-pinch that a downstream boolean closes into a zero-area sliver (→ degenerate mesh element). The per-curve @asserts didn't catch it (they check each curve's own endpoints, never ring contiguity). Fix: walk in ascending start-index order (issorted fast-path → zero cost when already sorted) in both functions, so the walks tolerate any csi ordering regardless of how the polygon was produced. Regression test added in test_line_arc_rounding.jl (styled_loop box, reversed curve order → no sub-µm pinch). --- src/curve_recovery.jl | 11 +++++-- src/curvilinear.jl | 13 ++++++++- test/test_line_arc_rounding.jl | 52 ++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 15faeec07..4fd381864 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -28,7 +28,14 @@ function _collect_provenance!(polys, runs, e::CurvilinearPolygon, ::Type{R}, ato ec = convert(CurvilinearPolygon{R}, e) i = 1 p = Point{R}[] - for (csi, c) in zip(ec.curve_start_idx, ec.curves) + # Walk in ascending start-index order — must match to_polygons(::CurvilinearPolygon) exactly + # so the Clipper polygon and these provenance runs share the same point ordering. See the + # note there: an out-of-order (e.g. wrap-seam-first) curve list otherwise emits the wrong + # `ec.p[i:csi]` spans and a sub-µm near-pinch. + order = issorted(ec.curve_start_idx) ? eachindex(ec.curve_start_idx) : sortperm(ec.curve_start_idx) + for idx in order + csi = ec.curve_start_idx[idx] + c = ec.curves[idx] append!(p, ec.p[i:csi]) wrapped_end = mod1(csi + 1, length(ec.p)) pp = DeviceLayout.discretize_curve(c, atol; rtol=nothing) @@ -56,7 +63,7 @@ end # warn on the silent-discretization path below; not exact, just enough to flag a likely loss. function _count_arclike_runs(pts; short_nm=6000.0, min_run=4, resid_nm=2.0) n = length(pts); n < min_run + 1 && return 0 - xy = [(Float64(ustrip(getx(p))) / 1000, Float64(ustrip(gety(p))) / 1000) for p in pts] # → ~nm scale-agnostic + xy = [(Float64(DeviceLayout.ustrip(getx(p))) / 1000, Float64(DeviceLayout.ustrip(gety(p))) / 1000) for p in pts] # → ~nm scale-agnostic # edge lengths in the same units as xy el = [hypot(xy[mod1(i + 1, n)][1] - xy[i][1], xy[mod1(i + 1, n)][2] - xy[i][2]) for i in 1:n] short = short_nm / 1000 diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 85e68f095..9ac764acd 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -130,7 +130,18 @@ function to_polygons( i = 1 p = Point{T}[] - for (idx, (csi, c)) ∈ enumerate(zip(e.curve_start_idx, e.curves)) + # Walk curves in ascending start-index order. The running index `i` and `e.p[i:csi]` below + # assume `curve_start_idx` is monotonically increasing; a curve supplied out of order — in + # particular one at the wrap seam (csi == length(e.p)) listed first — would make `e.p[i:csi]` + # dump the whole point ring up front and append that curve's arc points at the tail, ending + # one step short of its endpoint and leaving a sub-µm near-pinch. Sorting locally (without + # mutating `e`) makes the walk robust to any curve ordering, including deserialized polygons + # that predate constructor-side normalization. + order = issorted(e.curve_start_idx) ? eachindex(e.curve_start_idx) : sortperm(e.curve_start_idx) + + for idx in order + csi = e.curve_start_idx[idx] + c = e.curves[idx] # Add the points from current to start of curve append!(p, e.p[i:csi]) diff --git a/test/test_line_arc_rounding.jl b/test/test_line_arc_rounding.jl index 468b5a1bb..68640202a 100644 --- a/test/test_line_arc_rounding.jl +++ b/test/test_line_arc_rounding.jl @@ -739,3 +739,55 @@ end place!(cs_sm, tiny_poly, GDSMeta(0, 0)) @test_nowarn render!(sm, cs_sm) end + +@testitem "CurvilinearPolygon walk is curve-order independent (wrap-seam regression)" setup = + [CommonTestSetup] begin + using DeviceLayout: Point, CurvilinearPolygon, Rounded, to_polygons, points + import DeviceLayout.SolidModels: styled_loop + # Regression for the seam-ordering bug: to_polygons(::CurvilinearPolygon) and + # _collect_provenance! advance a running index `i` and emit `p[i:csi]` before each curve, + # which assumes `curve_start_idx` is ascending. A curve at the wrap seam (csi == length(p)) + # listed out of order makes the walk dump the whole ring up front and append that curve's arc + # at the tail, ending one discretization step (~tens of nm) short of its endpoint → a sub-µm + # near-pinch that a downstream boolean closes into a zero-area sliver (a degenerate mesh + # element). The walk must be independent of the order curves are supplied in. + + # styled_loop on a box produces a valid multi-arc CurvilinearPolygon (one fillet per corner), + # including a corner at the wrap seam — exactly the shape that triggered the bug. + box = CurvilinearPolygon(Point{Float64}[(0, 0), (10, 0), (10, 10), (0, 10)] .* μm) + cp = styled_loop(box, Rounded(1.0μm)) + @test cp isa CurvilinearPolygon + @test !isempty(cp.curves) + + # Antenna signature: two vertices far apart on the ring (gap > 5) but spatially < 0.5µm, + # both sitting on long (> 0.5µm) edges — i.e. two boundary strands that should have met. + function has_pinch(poly) + q = [(Float64(ustrip(μm, DeviceLayout.getx(z))), + Float64(ustrip(μm, DeviceLayout.gety(z)))) for z in points(poly)] + m = length(q) + for i in 1:m, j in (i + 5):m + (i <= 2 && j >= m - 1) && continue + d = hypot(q[i][1] - q[j][1], q[i][2] - q[j][2]) + (0.001 < d < 0.5) || continue + ei = max(hypot(q[mod1(i + 1, m)][1] - q[i][1], q[mod1(i + 1, m)][2] - q[i][2]), + hypot(q[i][1] - q[mod1(i - 1, m)][1], q[i][2] - q[mod1(i - 1, m)][2])) + ej = max(hypot(q[mod1(j + 1, m)][1] - q[j][1], q[mod1(j + 1, m)][2] - q[j][2]), + hypot(q[j][1] - q[mod1(j - 1, m)][1], q[j][2] - q[mod1(j - 1, m)][2])) + (ei > 0.5 && ej > 0.5) && return true + end + return false + end + + # Natural order (as styled_loop produced it) — must be clean. + poly_natural = to_polygons(cp) + @test !has_pinch(poly_natural) + + # Reverse the curve order so a high-index (seam-adjacent) curve is processed first. This is + # the condition a deserialized polygon hits when its curves were stored out of canonical order. The constructor normalizes by sorting, + # and the walk sorts locally, so the result must match the natural order and stay pinch-free. + perm = reverse(eachindex(cp.curve_start_idx)) + cp_perm = CurvilinearPolygon(copy(cp.p), cp.curves[perm], cp.curve_start_idx[perm]) + poly_perm = to_polygons(cp_perm) + @test !has_pinch(poly_perm) + @test length(points(poly_perm)) == length(points(poly_natural)) +end From 99e6675163bf9fae05adb109332c35659f55f365 Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Sat, 20 Jun 2026 00:27:55 +0000 Subject: [PATCH 18/19] curve_recovery: partial-curve recovery for boolean-cut curves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds longest-contiguous-substring matching as a fallback in `substitute_curves` for curves whose full discretized run was bisected by another polygon's edge during a boolean operation. Without partial recovery, such cut curves fall through to polyline, producing hundreds of short Lines on what was a single Turn arc (e.g. a rim arc cut by a crossing path). New API: - `substitute_curves_partial::Ref{Bool}` — opt-in flag; default false (preserves existing behavior). Set true to enable. - `match_run_partial(contour, run; min_len=3)` — returns ALL maximal contiguous sub-blocks of `run` in `contour` (cyclic, both orientations), each ≥ min_len. - `_sub_curve(curve, m, run_lo, run_hi, reversed)` — splits the original Segment at parametric positions corresponding to run[run_lo:run_hi] and returns the sub-segment. Uses Paths.split (works for Turn, Straight, BSpline, ConstantOffset, GeneralOffset, CompoundSegment). `substitute_curves` modified: when full `match_run` fails AND the partial flag is enabled, falls back to `match_run_partial` and substitutes each surviving sub-block as its own sub-segment via `_sub_curve`. The original full-match path runs first and is preferred (faster, simpler) — partial only kicks in for genuinely-cut curves. Effect (measured on a representative full-chip test case with the flag enabled): a curve that previously fell through to ~hundreds of polyline edges recovers as a small set of native arc sub-segments (one per surviving block). Conformality PASS; mesh-element-count and .xao-edge totals improve by a few percent. No behavior change with the flag off. Caller responsibility: only enable for projects where partial sub-arc geometry is desired. The flag is OFF by default so existing pipelines see no behavior change. --- src/curve_recovery.jl | 163 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 5 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 4fd381864..4d4004d36 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -150,6 +150,137 @@ function match_run(contour::AbstractVector{P}, run::AbstractVector{P}) where {P} return nothing end +# Find ALL maximal contiguous sub-blocks of `run` that exist in `contour` (treated as cyclic), +# considering forward and reversed orientations. Returns a Vector of named-tuples +# `(contour_start, run_lo, run_hi, reversed)` where: +# - `contour_start` is the 1-based start position in the contour +# - `run_lo`, `run_hi` are 1-based inclusive indices into `run` (or `reverse(run)` if reversed) +# - `reversed::Bool` indicates orientation +# Each returned block has length ≥ `min_len` (default 3 — meaningful sub-arc, not a single edge). +# Sub-blocks that are subsumed by a larger block at the same contour position are dropped. +# +# Used by `substitute_curves` for partial recovery: when a curve is "genuinely cut" by a +# boolean operation (e.g. a Turn arc bisected by another polygon's edge), the run survives +# as multiple disjoint contiguous sub-blocks. Each sub-block becomes a sub-curve via the +# original segment's `Paths.split` interface. +function match_run_partial(contour::AbstractVector{P}, run::AbstractVector{P}; + min_len::Int = 3) where {P} + n = length(contour) + m = length(run) + (m == 0 || n == 0) && return NamedTuple{(:contour_start, :run_lo, :run_hi, :reversed), + Tuple{Int,Int,Int,Bool}}[] + # Build a position lookup: for each run point value, what indices in run have that value? + # Run is short (typically 50-200 points for an arc), contour can be longer; this trades a + # bit of memory for fast search. + run_pos = Dict{P, Vector{Int}}() + for (i, v) in enumerate(run) + push!(get!(run_pos, v, Int[]), i) + end + rev_run = reverse(run) + rev_pos = Dict{P, Vector{Int}}() + for (i, v) in enumerate(rev_run) + push!(get!(rev_pos, v, Int[]), i) + end + + matches = NamedTuple{(:contour_start, :run_lo, :run_hi, :reversed), + Tuple{Int,Int,Int,Bool}}[] + + # Try to extend a contiguous run-match starting at contour position s, run position r0, + # in the given orientation. Returns the longest extension length k such that + # contour[s..s+k-1] == run[r0..r0+k-1] (orientation-respecting). + extend_match(s::Int, r0::Int, src::AbstractVector{P}) = begin + m_local = length(src) + k = 0 + while r0 + k <= m_local && contour[mod1(s + k, n)] == src[r0 + k] + k += 1 + end + k + end + + for orient in (false, true) + src = orient ? rev_run : run + pos_lookup = orient ? rev_pos : run_pos + s = 1 + while s <= n + cv = contour[s] + candidates = get(pos_lookup, cv, nothing) + if candidates === nothing + s += 1 + continue + end + best_k = 0 + best_r0 = 0 + for r0 in candidates + k = extend_match(s, r0, src) + if k > best_k + best_k = k + best_r0 = r0 + end + end + if best_k >= min_len + run_lo = orient ? (m + 1 - (best_r0 + best_k - 1)) : best_r0 + run_hi = orient ? (m + 1 - best_r0) : (best_r0 + best_k - 1) + push!(matches, (contour_start=s, run_lo=run_lo, run_hi=run_hi, reversed=orient)) + s += best_k # skip the matched block; no overlap + else + s += 1 + end + end + end + + # Drop entries that are subsumed by a longer entry covering the same contour range. + # Sort by length descending; keep an entry only if no kept entry already covers its + # contour_start..contour_start+(hi-lo). + isempty(matches) && return matches + by_len = sort(matches; by=t -> -(t.run_hi - t.run_lo + 1)) + kept = empty(by_len) + for t in by_len + len = t.run_hi - t.run_lo + 1 + overlap = false + for kt in kept + klen = kt.run_hi - kt.run_lo + 1 + # Check cyclic overlap of [t.contour_start, t.contour_start+len-1] + # with [kt.contour_start, kt.contour_start+klen-1]. + for i = 0:(len-1) + pos = mod1(t.contour_start + i, n) + # Is pos inside kt's span? + for j = 0:(klen-1) + if mod1(kt.contour_start + j, n) == pos + overlap = true; break + end + end + overlap && break + end + overlap && break + end + overlap || push!(kept, t) + end + return kept +end + +# Given a curve and a sub-run [run_lo, run_hi] of its full discretization (length m), +# return the corresponding sub-segment (the same kind of curve restricted to the +# parametric range `t ∈ [(run_lo-1)/(m-1), (run_hi-1)/(m-1)] * pathlength(curve)`). +# Reversed sub-runs return a reversed sub-segment. +function _sub_curve(curve::Paths.Segment, m::Int, run_lo::Int, run_hi::Int, reversed::Bool) + pl = Paths.pathlength(curve) + t_lo = (run_lo - 1) / (m - 1) * pl + t_hi = (run_hi - 1) / (m - 1) * pl + # Take three-way split: pre, middle (the surviving sub-arc), post + if t_lo > zero(pl) + _, rest = Paths.split(curve, t_lo) + else + rest = curve + end + mid_len = t_hi - t_lo + if mid_len < Paths.pathlength(rest) + mid, _ = Paths.split(rest, mid_len) + else + mid = rest + end + return reversed ? Paths.reverse(mid) : mid +end + # Walk a ClippedPolygon's PolyNode tree, substituting known curves back into each contour # wherever a ProvenanceRun's discretized integer-grid point-run survived a boolean op intact. # Returns Vector{CurvilinearRegion{T}}: one region per outer contour (its direct children are @@ -158,6 +289,11 @@ end # Each run matches at most once globally — `used` is shared across all contours. # `report`, if given, collects (status::Symbol, curve, contour_index) tuples with # status ∈ (:recovered, :clipped); :clipped runs (never matched) carry contour_index 0. +# When true, `substitute_curves` falls back to longest-contiguous-substring matching +# for curves whose full run was cut by another polygon's edge — recovering each surviving +# sub-block as a sub-segment via `Paths.split`, instead of dropping the whole curve to polyline. +const substitute_curves_partial = Ref(false) + function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) where {T} out = CurvilinearRegion{T}[] contour_index = Ref(0) @@ -177,9 +313,26 @@ function substitute_curves(clipped::ClippedPolygon{T}, runs; report=nothing) whe for (ri, pr) in enumerate(runs) used[ri] && continue hit = match_run(snapped, pr.run) - isnothing(hit) && continue - seg = hit.reversed ? reverse(pr.curve) : pr.curve - push!(matched, (hit.start, length(pr.run), seg)) + if !isnothing(hit) + seg = hit.reversed ? reverse(pr.curve) : pr.curve + push!(matched, (hit.start, length(pr.run), seg)) + used[ri] = true + !isnothing(report) && push!(report, (:recovered, pr.curve, ci)) + continue + end + # Partial recovery: the full run didn't match (curve was cut by a boolean edge). + # Try longest-contiguous-substring matching: each surviving sub-block of the + # original run becomes a sub-segment of the original curve via Paths.split. + substitute_curves_partial[] || continue + partial_matches = match_run_partial(snapped, pr.run; min_len=3) + isempty(partial_matches) && continue + m_full = length(pr.run) + for pm in partial_matches + sub_len = pm.run_hi - pm.run_lo + 1 + # sub_len includes both endpoints; the contour occupies sub_len positions. + sub_seg = _sub_curve(pr.curve, m_full, pm.run_lo, pm.run_hi, pm.reversed) + push!(matched, (pm.contour_start, sub_len, sub_seg)) + end used[ri] = true !isnothing(report) && push!(report, (:recovered, pr.curve, ci)) end @@ -265,8 +418,8 @@ function _as_entities( ] end # A Rounded- or StyleDict{Rounded}-styled ClippedPolygon (e.g. the output of a non-curved -# `difference2d`/`union2d` then rounded — followed by post-clip rounding) recovers as exact -# fillet arcs per contour, so its corners survive the clip instead of discretizing. The render +# `difference2d`/`union2d` followed by post-clip rounding) recovers as exact fillet arcs +# per contour, so its corners survive the clip instead of discretizing. The render # path already does this conversion (`to_curvilinear_regions` walks the clipped tree, applying # `styled_loop`+`round_to_curvilinearpolygon` per contour → CurvilinearRegions with arcs); we # reuse that exact function so boolean recovery matches the render. `SolidModels` loads after From 9ba41f01463a6aeb02f2038c4b3023d8677709bb Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Tue, 7 Jul 2026 23:18:29 +0000 Subject: [PATCH 19/19] Apply JuliaFormatter to satisfy CI julia-format gate Pure formatting pass (`julia scripts/format.jl format`); no logic changes. Only touches files this branch has modified: src/curve_recovery.jl, src/curvilinear.jl, test/test_line_arc_rounding.jl. `check` mode now passes. --- src/curve_recovery.jl | 114 +++++++++++++++++++++++---------- src/curvilinear.jl | 4 +- test/test_line_arc_rounding.jl | 22 +++++-- 3 files changed, 98 insertions(+), 42 deletions(-) diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index 4d4004d36..325a2a1fe 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -32,7 +32,9 @@ function _collect_provenance!(polys, runs, e::CurvilinearPolygon, ::Type{R}, ato # so the Clipper polygon and these provenance runs share the same point ordering. See the # note there: an out-of-order (e.g. wrap-seam-first) curve list otherwise emits the wrong # `ec.p[i:csi]` spans and a sub-µm near-pinch. - order = issorted(ec.curve_start_idx) ? eachindex(ec.curve_start_idx) : sortperm(ec.curve_start_idx) + order = + issorted(ec.curve_start_idx) ? eachindex(ec.curve_start_idx) : + sortperm(ec.curve_start_idx) for idx in order csi = ec.curve_start_idx[idx] c = ec.curves[idx] @@ -62,29 +64,57 @@ end # proxy for "this discretized polygon CONTAINS curve geometry we are about to lose". Used only to # warn on the silent-discretization path below; not exact, just enough to flag a likely loss. function _count_arclike_runs(pts; short_nm=6000.0, min_run=4, resid_nm=2.0) - n = length(pts); n < min_run + 1 && return 0 - xy = [(Float64(DeviceLayout.ustrip(getx(p))) / 1000, Float64(DeviceLayout.ustrip(gety(p))) / 1000) for p in pts] # → ~nm scale-agnostic + n = length(pts) + n < min_run + 1 && return 0 + xy = [ + ( + Float64(DeviceLayout.ustrip(getx(p))) / 1000, + Float64(DeviceLayout.ustrip(gety(p))) / 1000 + ) for p in pts + ] # → ~nm scale-agnostic # edge lengths in the same units as xy - el = [hypot(xy[mod1(i + 1, n)][1] - xy[i][1], xy[mod1(i + 1, n)][2] - xy[i][2]) for i in 1:n] + el = [ + hypot(xy[mod1(i + 1, n)][1] - xy[i][1], xy[mod1(i + 1, n)][2] - xy[i][2]) for + i = 1:n + ] short = short_nm / 1000 - cnt = 0; i = 1 + cnt = 0 + i = 1 while i <= n - if !(0 < el[i] < short); i += 1; continue; end - j = i; while j < n && 0 < el[mod1(j + 1, n)] < short; j += 1; end + if !(0 < el[i] < short) + i += 1 + continue + end + j = i + while j < n && 0 < el[mod1(j + 1, n)] < short + j += 1 + end rl = j - i + 1 if rl >= min_run - vx = [xy[mod1(i + k, n)][1] for k in 0:rl]; vy = [xy[mod1(i + k, n)][2] for k in 0:rl] - m = length(vx); sx = sum(vx); sy = sum(vy) - sxx = sum(vx .^ 2); syy = sum(vy .^ 2); sxy = sum(vx .* vy) - sxxx = sum(vx .^ 3); syyy = sum(vy .^ 3); sxyy = sum(vx .* vy .^ 2); sxxy = sum(vx .^ 2 .* vy) - A = m * sxx - sx^2; B = m * sxy - sx * sy; Cc = m * syy - sy^2 - D = 0.5 * (m * sxxx + m * sxyy - sx * sxx - sx * syy); E = 0.5 * (m * syyy + m * sxxy - sy * syy - sy * sxx) + vx = [xy[mod1(i + k, n)][1] for k = 0:rl] + vy = [xy[mod1(i + k, n)][2] for k = 0:rl] + m = length(vx) + sx = sum(vx) + sy = sum(vy) + sxx = sum(vx .^ 2) + syy = sum(vy .^ 2) + sxy = sum(vx .* vy) + sxxx = sum(vx .^ 3) + syyy = sum(vy .^ 3) + sxyy = sum(vx .* vy .^ 2) + sxxy = sum(vx .^ 2 .* vy) + A = m * sxx - sx^2 + B = m * sxy - sx * sy + Cc = m * syy - sy^2 + D = 0.5 * (m * sxxx + m * sxyy - sx * sxx - sx * syy) + E = 0.5 * (m * syyy + m * sxxy - sy * syy - sy * sxx) den = A * Cc - B^2 if abs(den) > 1e-12 - cx = (D * Cc - B * E) / den; cy = (A * E - B * D) / den + cx = (D * Cc - B * E) / den + cy = (A * E - B * D) / den r = sqrt(max(0.0, (sxx + syy - 2cx * sx - 2cy * sy) / m + cx^2 + cy^2)) if 1e-3 < r < 1e5 - mr = maximum(abs(hypot(vx[k] - cx, vy[k] - cy) - r) for k in 1:m) + mr = maximum(abs(hypot(vx[k] - cx, vy[k] - cy) - r) for k = 1:m) mr < resid_nm / 1000 && (cnt += 1) end end @@ -118,11 +148,20 @@ function _collect_provenance!(polys, runs, e, ::Type{R}, atol) where {R} end if warn_on_curve_loss[] && arclike > 0 key = string(nameof(typeof(e))) - e isa StyledEntity && (key = "Styled{" * string(nameof(typeof(e.ent))) * "," * string(nameof(typeof(e.sty))) * "}") + e isa StyledEntity && ( + key = + "Styled{" * + string(nameof(typeof(e.ent))) * + "," * + string(nameof(typeof(e.sty))) * + "}" + ) first_seen = !haskey(_curve_loss_log, key) _curve_loss_log[key] = get(_curve_loss_log, key, 0) + arclike - first_seen && @warn "recover_curves: discretizing a curve-bearing entity with no provenance — its arcs are LOST. " * - "Add an _as_entities method for this (type, style) to recover them." entity_type=key arclike_runs=arclike + first_seen && + @warn "recover_curves: discretizing a curve-bearing entity with no provenance — its arcs are LOST. " * + "Add an _as_entities method for this (type, style) to recover them." entity_type = + key arclike_runs = arclike end return nothing end @@ -163,12 +202,17 @@ end # boolean operation (e.g. a Turn arc bisected by another polygon's edge), the run survives # as multiple disjoint contiguous sub-blocks. Each sub-block becomes a sub-curve via the # original segment's `Paths.split` interface. -function match_run_partial(contour::AbstractVector{P}, run::AbstractVector{P}; - min_len::Int = 3) where {P} +function match_run_partial( + contour::AbstractVector{P}, + run::AbstractVector{P}; + min_len::Int=3 +) where {P} n = length(contour) m = length(run) - (m == 0 || n == 0) && return NamedTuple{(:contour_start, :run_lo, :run_hi, :reversed), - Tuple{Int,Int,Int,Bool}}[] + (m == 0 || n == 0) && return NamedTuple{ + (:contour_start, :run_lo, :run_hi, :reversed), + Tuple{Int, Int, Int, Bool} + }[] # Build a position lookup: for each run point value, what indices in run have that value? # Run is short (typically 50-200 points for an arc), contour can be longer; this trades a # bit of memory for fast search. @@ -182,8 +226,10 @@ function match_run_partial(contour::AbstractVector{P}, run::AbstractVector{P}; push!(get!(rev_pos, v, Int[]), i) end - matches = NamedTuple{(:contour_start, :run_lo, :run_hi, :reversed), - Tuple{Int,Int,Int,Bool}}[] + matches = NamedTuple{ + (:contour_start, :run_lo, :run_hi, :reversed), + Tuple{Int, Int, Int, Bool} + }[] # Try to extend a contiguous run-match starting at contour position s, run position r0, # in the given orientation. Returns the longest extension length k such that @@ -220,7 +266,10 @@ function match_run_partial(contour::AbstractVector{P}, run::AbstractVector{P}; if best_k >= min_len run_lo = orient ? (m + 1 - (best_r0 + best_k - 1)) : best_r0 run_hi = orient ? (m + 1 - best_r0) : (best_r0 + best_k - 1) - push!(matches, (contour_start=s, run_lo=run_lo, run_hi=run_hi, reversed=orient)) + push!( + matches, + (contour_start=s, run_lo=run_lo, run_hi=run_hi, reversed=orient) + ) s += best_k # skip the matched block; no overlap else s += 1 @@ -241,12 +290,13 @@ function match_run_partial(contour::AbstractVector{P}, run::AbstractVector{P}; klen = kt.run_hi - kt.run_lo + 1 # Check cyclic overlap of [t.contour_start, t.contour_start+len-1] # with [kt.contour_start, kt.contour_start+klen-1]. - for i = 0:(len-1) + for i = 0:(len - 1) pos = mod1(t.contour_start + i, n) # Is pos inside kt's span? - for j = 0:(klen-1) + for j = 0:(klen - 1) if mod1(kt.contour_start + j, n) == pos - overlap = true; break + overlap = true + break end end overlap && break @@ -426,14 +476,10 @@ end # this file, but `_as_entities` only runs at boolean time (all modules loaded), so the qualified # name resolves at call time. Returns a Vector{CurvilinearRegion}; `_as_entities(::AbstractArray)` # flattens it, and `_collect_provenance!(::CurvilinearRegion)` records each contour's curve runs. -function _as_entities( - p::StyledEntity{T, ClippedPolygon{T}, <:Polygons.StyleDict} -) where {T} +function _as_entities(p::StyledEntity{T, ClippedPolygon{T}, <:Polygons.StyleDict}) where {T} return DeviceLayout.SolidModels.to_curvilinear_regions(p.ent, p.sty) end -function _as_entities( - p::StyledEntity{T, ClippedPolygon{T}, <:Polygons.Rounded} -) where {T} +function _as_entities(p::StyledEntity{T, ClippedPolygon{T}, <:Polygons.Rounded}) where {T} return DeviceLayout.SolidModels.to_curvilinear_regions(p.ent, Polygons.StyleDict(p.sty)) end # A geometrically-transparent style (e.g. MeshSized — a mesh-density hint applied via diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 9ac764acd..b7b646a21 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -137,7 +137,9 @@ function to_polygons( # one step short of its endpoint and leaving a sub-µm near-pinch. Sorting locally (without # mutating `e`) makes the walk robust to any curve ordering, including deserialized polygons # that predate constructor-side normalization. - order = issorted(e.curve_start_idx) ? eachindex(e.curve_start_idx) : sortperm(e.curve_start_idx) + order = + issorted(e.curve_start_idx) ? eachindex(e.curve_start_idx) : + sortperm(e.curve_start_idx) for idx in order csi = e.curve_start_idx[idx] diff --git a/test/test_line_arc_rounding.jl b/test/test_line_arc_rounding.jl index 68640202a..5edcbfa25 100644 --- a/test/test_line_arc_rounding.jl +++ b/test/test_line_arc_rounding.jl @@ -762,17 +762,25 @@ end # Antenna signature: two vertices far apart on the ring (gap > 5) but spatially < 0.5µm, # both sitting on long (> 0.5µm) edges — i.e. two boundary strands that should have met. function has_pinch(poly) - q = [(Float64(ustrip(μm, DeviceLayout.getx(z))), - Float64(ustrip(μm, DeviceLayout.gety(z)))) for z in points(poly)] + q = [ + ( + Float64(ustrip(μm, DeviceLayout.getx(z))), + Float64(ustrip(μm, DeviceLayout.gety(z))) + ) for z in points(poly) + ] m = length(q) - for i in 1:m, j in (i + 5):m + for i = 1:m, j = (i + 5):m (i <= 2 && j >= m - 1) && continue d = hypot(q[i][1] - q[j][1], q[i][2] - q[j][2]) (0.001 < d < 0.5) || continue - ei = max(hypot(q[mod1(i + 1, m)][1] - q[i][1], q[mod1(i + 1, m)][2] - q[i][2]), - hypot(q[i][1] - q[mod1(i - 1, m)][1], q[i][2] - q[mod1(i - 1, m)][2])) - ej = max(hypot(q[mod1(j + 1, m)][1] - q[j][1], q[mod1(j + 1, m)][2] - q[j][2]), - hypot(q[j][1] - q[mod1(j - 1, m)][1], q[j][2] - q[mod1(j - 1, m)][2])) + ei = max( + hypot(q[mod1(i + 1, m)][1] - q[i][1], q[mod1(i + 1, m)][2] - q[i][2]), + hypot(q[i][1] - q[mod1(i - 1, m)][1], q[i][2] - q[mod1(i - 1, m)][2]) + ) + ej = max( + hypot(q[mod1(j + 1, m)][1] - q[j][1], q[mod1(j + 1, m)][2] - q[j][2]), + hypot(q[j][1] - q[mod1(j - 1, m)][1], q[j][2] - q[mod1(j - 1, m)][2]) + ) (ei > 0.5 && ej > 0.5) && return true end return false