ConformalRender: add preprocessing (split_pinches, split_t_junctions!) - #282
ConformalRender: add preprocessing (split_pinches, split_t_junctions!)#282ybrightye wants to merge 4 commits into
split_pinches, split_t_junctions!)#282Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Do you have test cases coming out of Clipper? In simple examples it seems to separate contours that touch at a point. |
|
You're right that direct Clipper on simple touching shapes doesn't produce pinches — it separates them into distinct polygons. I added a testset that documents this ( The failure mode Concretely (also as a testset in b1cad18,
I also added an end-to-end testset (76ab7f6, If you'd rather scope this PR down to just Also worth flagging: |
Add two preprocessing utilities that operate on `CurvilinearRegion` (or
`Vector{CurvilinearRegion}`) output from `union2d_curved` and similar
boolean ops, before `render_conformal!`:
1. `split_pinches(regions)` — detects self-touching contours (zero-width
necks / figure-8s from Clipper's union) via a hash-grid pinch finder,
splits them into simple sub-regions. Curves are preserved by remapping
`curve_start_idx` to the new vertex indices. Two nm detection tolerance
matches the default `ConformalRenderContext` vertex_merge_atol so
detection predicts OCC's post-merge behaviour.
2. `split_t_junctions!(target, sources...)` — restores 1:1 edge
correspondence between adjacent groups that share a boundary by
injecting foreign vertices from `sources` onto matching edges of
`target`. Straight edges get the foreign Point inserted verbatim
(bit-exact — cached merge unifies at render); Turn curves get split
via `Paths.split` at the appropriate arclength so sub-arcs remain
native. Fixes TetGen PLC "vertex lies in segment" failures on adjacent-
group boundaries.
Also `find_pinch_points(pts; atol_nm=2.0)` exposed as a public helper.
Both operate on native curve types (`Paths.Turn`, `Paths.BSpline`,
`Paths.OffsetSegment`) — no discretization.
Exports added to `DeviceLayout.SolidModels` for convenience.
Tests: 6 new testsets in test_conformal_render.jl covering pinch
detection, figure-8 split, no-op on clean regions, T-junction injection on
straight edges, no-op when no foreign vertex lands on target, and Turn
splitting via Paths.split at a mid-arc foreign vertex.
…mpty guards, multi-arc splits Codecov flagged preprocess.jl at 78% patch coverage. Adds tests for the uncovered edge cases: - Multi-lobe pinch: 3-square chain sharing pinch points, hole assignment by point-in-polygon test to the correct output lobe. - Sliver drop: pinch pattern that carves off a 2-point sub-loop; verify it gets dropped and no output region has < 3 vertices. - Source vertex from a hole: exercises the `for h in r.holes` branch of `_collect_vertices!`. - Guard clauses: empty target list, empty sources list — both return 0 without erroring. - Multiple foreign points on one arc: exercises the sort-by-arc-angle branch of `_find_points_on_arc` (>1 result path). 64/64 pass locally.
Two new testsets in the preprocessing block: * "Clipper does not produce pinches on simple touching shapes" — demonstrates that `union2d` on diagonal-touching rectangles and hourglass triangles separates them into distinct polygons rather than emitting a self-touching outline. This is the case a reader might expect `find_pinch_points`/`split_pinches` to be defending against; they are not. * "find_pinch_points catches noding-induced pinches" — demonstrates the case that DOES need the safety valve: a shared-boundary vertex injection pass (used to make adjacent physical groups share bit- identical vertex sequences on their common boundary) can turn a Clipper-clean outline into a self-touching one by injecting a coordinate the host region already visits elsewhere. `split_pinches` then cleaves the pinched outline into two simple faces. Coordinates in both testsets are ~100 µm so the 2 nm `find_pinch_points` tolerance doesn't accidentally flag distinct-but-close vertices as coincident.
…ches fixes it Extends the noding-induced-pinch testset with a full round trip: * Build a `CurvilinearRegion` whose exterior visits the same coord (150 µm, 0) at two non-adjacent outline positions — the shape a symmetric shared-boundary vertex-injection pass produces. * Assert that `render_conformal!` on that region throws `ErrorException` (OCC emits "Curve loop is not closed" from `add_curve_loop`). * Preprocess with `split_pinches` first — assert the render then completes and produces the expected physical group. Demonstrates why `split_pinches` is load-bearing in the render pipeline, not just a defensive check.
76ab7f6 to
763b1d0
Compare
There was a problem hiding this comment.
Yes, let's scope down to just splitting T junctions for now.
That's also a feature that's useful for avoiding 1nm gaps due to grid rounding for GDS output, so I'd request putting this in src/postrender.jl (can see it as either a 2D geometry postrendering operation or preprocessing for 3D) and including a method that takes Polygon vectors as input.
On the unit issue, we have the onenanometer(T) idiom that avoids ustrip everywhere entirely. Things like tol = 50.0, perp_tol = 3.0, len_sq < 1.0, bbox pad 5.0 can be mytol*onenanometer(T) etc where the methods take ::T rather than Float64. I don't think any of the conversions back and forth are necessary. Tests should verify output is the same regardless of input coordinate type.
Those tolerances (and the 0.01 radian angular tolerance) also have an unclear relationship to other default tolerances (in particular the default 1nm rendering tolerance, the GDS 1nm grid, or the vertex merge tolerance for render_conformal!). Right now it seems like you can get conflicts with supposed guarantees where the tolerance admits split points that don't get merged or skips vertices that wouldn't normally be mergeable. Ideally they would be configured by a small number of unit-correct keyword arguments (or derived from a small number of constants) with meaningful names.
| # expressed as `Point{T}` at their (x, y). Radial tolerance is 50 nm. | ||
| function _find_points_on_arc(turn::Paths.Turn{T}, pts_set) where {T} | ||
| cx = | ||
| Float64(ustrip(getx(Paths.p0(turn)))) + |
There was a problem hiding this comment.
I don't think the ustrips are necessary, and they're not idiomatic for lengths or for angles. Also ustrip without a unit is not correct for arbitrary unitful coordinate type T as you flagged elsewhere.
| dθ = θ - θ_start | ||
| while dθ > π | ||
| dθ -= 2π | ||
| end | ||
| while dθ < -π | ||
| dθ += 2π | ||
| end |
There was a problem hiding this comment.
dθ = rem2pi(θ - θ_start, RoundNearest)?
| Float64(ustrip(turn.r * sign(turn.α) * cos(turn.α0))) | ||
| r = Float64(ustrip(abs(turn.r))) | ||
| θ_start = | ||
| Float64(ustrip(turn.α0)) * π / 180.0 + (turn.α > zero(turn.α) ? -π / 2 : π / 2) |
There was a problem hiding this comment.
You shouldn't need to ustrip any angles, but if you did, ustrip(NoUnits, turn.α0) would give you the angle in radians as Float64 without having to know the implementation detail that turn.α0 is typeof(1.0°).
| sub_turns = Paths.split(turn, t_split) | ||
| push!(new_curves, sub_turns[1]) | ||
| push!(new_csi, length(new_points)) | ||
| push!(new_points, split_pt) |
There was a problem hiding this comment.
This is not actually the on-arc point sub_turns[2](zero(l)) but the point from split_pts which may be up to 50nm away, which will break the CurvilinearPolygon (which has 1e-3 nm tolerance in the constructor for point deduplication and in to_polygons for agreement between the points field and curve endpoints). But if you do use the on-arc point then the split_pts version doesn't get merged because the merge tolerance is much smaller. I think you do want to use the actual on-arc point, and probably a tighter tolerance.
| push!(results, Point(T(px) * oneunit(T) / T(1), T(py) * oneunit(T) / T(1))) | ||
| end | ||
| if length(results) > 1 | ||
| sort!( |
There was a problem hiding this comment.
For CW arcs (e.g. from CurvilinearRegion holes), this means points will be processed starting from the end, which will give incorrect results if there are multiple on-arc points. Sorting by sign(turn.α) * dθ should work, along with a test covering multiple T junctions on a hole.
| """ | ||
| function split_t_junctions!(target_regions, source_regions_list...) | ||
| isempty(target_regions) && return 0 | ||
| adjacent_pts = Tuple{Float64, Float64, Any}[] |
There was a problem hiding this comment.
Avoid Any in a container, something like Point{coordinatetype(target_regions)} would be better
| θ_start = | ||
| Float64(ustrip(turn.α0)) * π / 180.0 + (turn.α > zero(turn.α) ? -π / 2 : π / 2) | ||
| θ_sweep = Float64(ustrip(turn.α)) * π / 180.0 | ||
| tol = 50.0 |
There was a problem hiding this comment.
Why is this tolerance so large?
| ("vertex lies in segment"). Typical usage after [`union2d_curved`](@ref): | ||
|
|
||
| ```julia | ||
| groups = compute_groups(cs) # boolean output |
There was a problem hiding this comment.
compute_groups isn't a function in this package and I'm not sure what "boolean output" means here. Maybe clearer to say something like
target_regions = union2d_curved(cs => :metal_negative) # Returns Vector{<:CurvilinearRegion}
source_regions = union2d_curved(cs => :metal_positive)
split_t_junctions!(target_regions, source_regions)| """ | ||
| split_t_junctions!(target_regions, source_regions...) -> Int | ||
|
|
||
| Inject foreign on-edge vertices from every `source_regions` group into every |
There was a problem hiding this comment.
Docstring could be clearer that inputs are iterables over CurvilinearRegion
Adds two geometry-preprocessing utilities to
ConformalRenderthatdownstream users otherwise have to reimplement locally to feed clean
input to
render_conformal!.Motivation
After boolean operations (
union2d_curved,difference2d_curved,etc.) hand back a
Vector{CurvilinearRegion}, two classes of topologybug can still make OCC or the mesher reject the geometry:
polygons where two non-adjacent vertices coincide — a zero-width
neck or figure-8. OCC rejects these with "Curve loop is not
closed".
share a boundary but Clipper doesn't put a vertex at the same
location on both sides, TetGen rejects the input with "vertex lies
in segment" PLC errors.
Neither is caught by the existing
render_conformal!cache — thatcache assumes correct topology going in.
API
Three new symbols exported from
DeviceLayout.SolidModels(alsoavailable via
DeviceLayout.SolidModels.ConformalRender):find_pinch_points(pts; atol_nm=2.0)— O(n) hash-grid detector forcoincident non-adjacent contour vertices. Returns
(i, j)indexpairs.
split_pinches(regions)— splits every self-touching region (on theexterior AND every hole) into simple sub-regions. Curves are
preserved by remapping
curve_start_idx. Sub-loops with < 3vertices are dropped as zero-area slivers.
split_t_junctions!(target, sources...)— injects on-edge verticesfrom
sourcesinto edges oftarget, restoring 1:1 edgecorrespondence. Straight edges get the foreign
Pointverbatim(bit-exact — the cached point merge unifies the two copies at
render).
Paths.Turncurves get split viaPaths.splitat theappropriate arclength so sub-arcs remain native.
All three operate on native curve types (
Paths.Turn,Paths.BSpline,Paths.OffsetSegment) with no discretization.Typical usage
Tests
6 new testsets in
test_conformal_render.jl:find_pinch_pointsdetects self-touching contoursfind_pinch_pointsreturns empty for a clean polygonsplit_pinchessplits a figure-8 into two simple loopssplit_pinchesleaves clean regions untouchedsplit_t_junctions!injects a foreign midpoint on a straight edgesplit_t_junctions!is a no-op when no source vertex lies on targetsplit_t_junctions!splits aPaths.Turnat a foreign point on thearc
Pkg.testlocally: 55/55 pass. Formatter clean.Not in this PR
arc-recovery helpers; separate follow-up.
simple 2 nm-tolerance regime; separate follow-up if requested.