Curve-recovery additions: _as_entities for Node/StyledEntity/Rounded ClippedPolygon, silent-loss warning, sort-tolerant walks, opt-in partial recovery#243
Conversation
- Import ContinuousStyle in curve_recovery.jl for zero-length path check
- Fix parametric constructor call in CurvilinearPolygon(::Polygon{T})
…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.
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.
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.)
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.
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.
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).
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.
29fd346 to
27f6aa3
Compare
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.
|
Hi @gpeairs — bumping this now that it's out of DRAFT: Totally understand #232 is still awaiting @laylagi's review and this stacks on top of it, so no rush on the merge side. But if you have a few minutes to skim the disposition suggestions in the description — especially whether any of #5 / #2 / #6 / the small ctor fixes want to fold into #232 vs. stay here — that would help me plan on our end. Happy to rebase, split, or squash however works best. Thanks for the recent iterations on #232 — we're running downstream against this branch daily on |
|
Thanks @ybrightye for the ping and sorry for the wait. Will take a look tomorrow. |
|
Thanks for driving this @ybrightye! Here's what I'm thinking:
|
d5077bc to
0b6852e
Compare
|
Thanks Greg — dispositions all look right on our end. Comments 1-5 fully retire once #232 merges. The type-based curve-loss warning is a strict improvement over Kasa-fit — no false-positive risk on rectilinear-with-collinear-shorts polygons. Good to close out #6. On #7 (sort tolerance): confirmed unnecessary on our end. Every On #8: your capture-time arclength approach addresses exactly the residual-vertex symptom I was hitting at BSpline tails. Per-call kwarg is much cleaner than the One thing worth flagging before closeout — while testing coverage against #232 HEAD on our test geometry, we noticed a BSpline + SimpleCPW path-node regression against the currently pinned commit:
Geometry is preserved but topology and vertex count change. Traced to #236 replacing
Not a blocker on #243 as-is — happy for #232 to land with the disposition you laid out. But wanted to flag it in case fixing the "known limitation" is something you'd want to bundle with #232 or split as its own follow-up. Is it on your radar? Will close #243 once #232 merges and we bump the DL pin downstream. |
|
I think all BSplines were previously rendering with the marching kernel, but using the curvature of the base BSpline. In #236 they started taking into account the effect of the trace/gap offset on curvature, which could get it blocked by the curvature guard and trigger over-refinement. It's harder to see why there would be empty output regions. If you can share the specific geometry, that would help me look into it. EDIT: I've been convinced this is very likely because an offset curve has cusps (min curvature radius of the base BSpline is less than trace/2 [+ gap]). So the offset contribution to curvature causes it to diverge, giving you your over-refinement, and also creating the pathological cusp geometry that gets resolved to empty regions. This is not a great regime to be in, and I think elsewhere we've said 'create cusps at your own risk', but we should handle it gracefully. We have a follow up in #250 that avoids over-refinement for turns, but no fix for BSplines yet. Ideally we can get it fixed before tagging a new version. Please continue to report any issues or suspected issues like that, since we want to be very confident in changes to the rendering pipeline like that before tagging a new version. |
|
Confirmed — this reproducer IS in cusp territory, matching your diagnosis. The base BSpline has a fold at t≈0.8 with min curvature radius ~0.22 μm, while the outer offset (trace/2 + gap = 1.0 μm) exceeds that → Minimal reproducer (no external deps beyond using DeviceLayout, Unitful
using DeviceLayout: Point, Rectangle
import DeviceLayout.Paths
using DeviceLayout.Curvilinear: CurvilinearRegion, discretize_with_provenance
using Unitful: @u_str
const μm = u"μm"
pth = Paths.Path(Point(0.0μm, 0.0μm); α0=0.0u"°")
Paths.bspline!(pth,
[Point(5.0μm, 2.0μm), Point(10.0μm, 0.0μm)],
0.0u"°",
Paths.SimpleCPW(1.0μm, 0.5μm))
node = only(pth)
# Curvature diagnostic
seg = node.seg
max_κ = 0.0
for t in 0.0:0.01:1.0
g = Paths.Interpolations.gradient(seg.r, t)[1]
h = Paths.Interpolations.hessian(seg.r, t)[1]
κ = abs(g.x*h.y - g.y*h.x) / (g.x^2 + g.y^2)^(3//2)
max_κ = max(max_κ, Unitful.ustrip(u"μm^-1", κ))
end
# outer offset = trace/2 + gap = 1.0 μm; speed at max κ = 1 - 1.0·κ_max ≈ -3.6 → cusp
far = Rectangle(Point(-100.0μm, -100.0μm), Point(-99.0μm, -99.0μm))
T = typeof(0.0u"nm")
ptp = DeviceLayout.Curvilinear.pathtopolys(node)
_, runs = discretize_with_provenance(ptp, T)
@show [length(r.run) for r in runs]
regions = DeviceLayout.union2d_curved([node], [far])
@show length(regions)
for r in regions
r isa CurvilinearRegion || continue
@show length(r.exterior.p), length(r.holes)
endObserved:
Good to know #250 addresses Turns already. Glad to keep watching for BSpline-side follow-ups once you're ready. |
|
#259 will guard against over-refinement near cusps and warn when curve discretization finds diverging curvature. It could also make sense to prune degenerate regions as part of However, in general, cusps are still problematic, especially in the common case where they appear on self-intersecting offset curves. Various operations in DeviceLayout do not handle self-intersecting curves consistently [e.g., I think GDS treats inverted loops as positive while SolidModel drops them], and we're unlikely to start making promises about how they're handled -- users create them at their own risk. Partial curve recovery can in principle handle self intersections but we're unlikely to go out of our way to support it or establish a canonical handling of self-loops (drop, treat as positive, or treat as negative -- all plausible user intent depending on the situation, though "drop" makes the most sense here). |
|
Thanks Greg — took a closer look with a production build and want to correct my earlier characterization. Audited our production layout for BSpline+CPW nodes: 130 BSpline path-nodes total (118 SimpleCPW, 12 TaperCPW). Zero cusps. Curvature-radius / outer-offset ratio distribution: tightest 4.5×, p25 24×, median 53×. My earlier MRE hit a cusp at ratio 0.22× — that was pathological toy geometry from a hand-picked BSpline where the endpoint tangent constraint forced a hairpin fold. Not representative of anything our router produces. Sorry for the false alarm. Ran a full end-to-end mesh build on our production fixture, comparing
Extra Clipper samples on outer-offset BSplines (~15-18% on tightest cases in isolation) don't propagate downstream — Also: reading So on the cusp policy: fully agree, users create them at their own risk. Our router avoids them by construction. #259 is a nice-to-have guardrail; not blocking anything on our end. Once #232 shows up on a tagged release we'll bump the DTP pin and close #243. |
Hey @gpeairs — this is the follow-up branch to #232 with curve-recovery additions for our fast-mesh / digital-twin pipeline. Marking ready for review now that
julia-formatis green.Pushing as a PR against your
gp/provenance-curve-recovery(notmain) because a few of these might fit naturally into #232 rather than land as their own PR — happy to rework, split, squash, or rebase however works best.Summary of the 8 commits
773387a_as_entities(::Paths.Node)so a Node-wrapped curve recovers through booleans; ctor fix forCurvilinearPolygon{T}a7ce027_as_entities(::StyledEntity)default — unwraps style wrappers (e.g.MeshSized) so a wrapped Node/Rounded-Polygon/CurvilinearPolygon still curve-recovers (mirrorsto_primitives(::StyledEntity)on the render side)55bd4a7CurvilinearPolygon(::Polygon{T})ctor fix (was calling the wrong 1-arg form)81bd91aPaths.ContinuousStylein_as_entities(::Paths.Node)(wasUndefVarErroron the zero-length-node path)153c8dc_as_entities(::StyledEntity{ClippedPolygon, Rounded/StyleDict})— Rounded post-clip rounded polygons now recover as exact fillet arcs by reusing your render-sideSolidModels.to_curvilinear_regions4563d75recover_curvessilently discretizes a curve-bearing entity (Kasa-fit circle detector + per-typecurve_loss_log()tally)f388049to_polygons+_collect_provenance!walks — defensiveissorted ? : sortpermso deserialized polygons predating your constructor sort don't trigger the wrap-seam pinch; also qualifiesustrip(was hard-crashing in #6's path)99e6675substitute_curves_partial[]flag +match_run_partial(longest-contiguous-substring fallback) +_sub_curve(splits the original Segment at the surviving sub-block) — default off, no behavior change for existing pipelines9ba41f0julia-formatCI gate — pure whitespace, no logic changes, only touches files already modified in this branchState of the commits
substitute_curves_partialRef{Bool}global-flag pattern in Path halo issues #8 follows the existingwarn_on_curve_lossprecedent, but probably wants a more principled per-call option if you'd like it refactored.test_line_arc_rounding.jlfor the wrap-seam case; the new_as_entitiesmethods and the partial-recovery path lean on the existingtest_provenance_recoverysetup but aren't yet directly exercised there.match_run_partial(Path halo issues #8) is O(n·m) substring matching, no early-exit cleverness. Works in practice on chip-scale runs; happy to profile if you'd like.Conflicts resolved during rebase
Two overlaps with your recent commits, resolved keeping the union of both improvements:
_as_entities(::Paths.Node): kept yourpathtopolys(p.seg, p.sty)+ myPaths.ContinuousStylequalification (complementary).CurvilinearPolygonconstructor csi sort: dropped my version, kept yours — functionally identical. So my "Sort-tolerant walks" commit (Keyword arg slurping leads to silent errors #7) is now narrower: only the defensive walks remain.Verification
test_line_arc_rounding.jl"CurvilinearPolygon walk is curve-order independent") uses a generic styled_loop box — no project-specific geometry.julia scripts/format.jl checknow passes locally; CI should reflect on this push.Suggested disposition (yours to decide)
No rush — happy to rework, split, or squash however you'd like. Downstream, our fast-mesh / digital-twin-pipeline is running against this branch daily, so anything that lands cleanly here reduces our patch surface.