Skip to content

ConformalRender: add preprocessing (split_pinches, split_t_junctions!) - #282

Open
ybrightye wants to merge 4 commits into
mainfrom
by/conformal-preprocess
Open

ConformalRender: add preprocessing (split_pinches, split_t_junctions!)#282
ybrightye wants to merge 4 commits into
mainfrom
by/conformal-preprocess

Conversation

@ybrightye

Copy link
Copy Markdown
Contributor

Adds two geometry-preprocessing utilities to ConformalRender that
downstream 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 topology
bug can still make OCC or the mesher reject the geometry:

  1. Self-touching contours (pinches). Clipper's union can produce
    polygons where two non-adjacent vertices coincide — a zero-width
    neck or figure-8. OCC rejects these with "Curve loop is not
    closed".
  2. T-junctions across group boundaries. When two adjacent groups
    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 — that
cache assumes correct topology going in.

API

Three new symbols exported from DeviceLayout.SolidModels (also
available via DeviceLayout.SolidModels.ConformalRender):

  • find_pinch_points(pts; atol_nm=2.0) — O(n) hash-grid detector for
    coincident non-adjacent contour vertices. Returns (i, j) index
    pairs.
  • split_pinches(regions) — splits every self-touching region (on the
    exterior AND every hole) into simple sub-regions. Curves are
    preserved by remapping curve_start_idx. Sub-loops with < 3
    vertices are dropped as zero-area slivers.
  • split_t_junctions!(target, sources...) — injects on-edge vertices
    from sources into edges of target, restoring 1:1 edge
    correspondence. Straight edges get the foreign Point verbatim
    (bit-exact — the cached point merge unifies the two copies at
    render). Paths.Turn curves get split via Paths.split at the
    appropriate arclength so sub-arcs remain native.

All three operate on native curve types (Paths.Turn,
Paths.BSpline, Paths.OffsetSegment) with no discretization.

Typical usage

using DeviceLayout.SolidModels: split_pinches, split_t_junctions!,
                                render_conformal!

groups = compute_groups(cs)                     # boolean output

# Restore 1:1 edge correspondence between adjacent groups.
split_t_junctions!(groups[:base_negative],
                   groups[:bp], groups[:l2_gnd])

# Clean self-touching pieces before render.
groups[:base_negative] = split_pinches(groups[:base_negative])

render_conformal!(sm, cs; context=ctx)

Tests

6 new testsets in test_conformal_render.jl:

  • find_pinch_points detects self-touching contours
  • find_pinch_points returns empty for a clean polygon
  • split_pinches splits a figure-8 into two simple loops
  • split_pinches leaves clean regions untouched
  • split_t_junctions! injects a foreign midpoint on a straight edge
  • split_t_junctions! is a no-op when no source vertex lies on target
  • split_t_junctions! splits a Paths.Turn at a foreign point on the
    arc

Pkg.test locally: 55/55 pass. Formatter clean.

Not in this PR

  • Curve-aware mutual noding (broader whole-groups pass) — depends on
    arc-recovery helpers; separate follow-up.
  • Ramer-Douglas-Peucker polyline decimation — not exercised in the
    simple 2 nm-tolerance regime; separate follow-up if requested.

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.42146% with 25 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/solidmodels/conformal/preprocess.jl 90.42% 25 Missing ⚠️

📢 Thoughts on this report? Let us know!

@gpeairs

gpeairs commented Aug 14, 2026

Copy link
Copy Markdown
Member

Clipper's union can produce polygons where two non-adjacent vertices coincide

Do you have test cases coming out of Clipper? In simple examples it seems to separate contours that touch at a point.

@ybrightye

Copy link
Copy Markdown
Contributor Author

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 (Clipper does not produce pinches on simple touching shapes, in b1cad18) covering diagonal-touching rectangles and hourglass triangles.

The failure mode split_pinches is actually defending against is post-Clipper: when a downstream pipeline runs a symmetric shared-boundary vertex-injection pass to align adjacent physical groups' outlines (so their common boundary carries a bit-identical vertex sequence, which the conformal shared-edge cache needs), that injection can create pinches on Clipper-clean inputs.

Concretely (also as a testset in b1cad18, find_pinch_points catches noding-induced pinches):

  • Region A is a top slab with a V-notch touching the shared edge y=0 at (150 µm, 0). A's outline visits (150, 0) once — as the notch tip.
  • Region B is a bottom slab with an on-edge port anchor vertex at (150, 0). B's shared-edge traversal is broken at that anchor.
  • A's shared-edge traversal in the Clipper output is one long segment (-300, 0) → (300, 0), no interior vertex at x=150.
  • The noding step scans A's edges for donor vertices from B. B has (150, 0) strictly interior to that edge → inserts (150, 0) into A between (-300, 0) and (300, 0).
  • Now A's outline visits (150, 0) at two non-adjacent positions: the injected one and the original notch tip. That's a figure-8 that OCC rejects with Curve loop is not closed.

I also added an end-to-end testset (76ab7f6, render_conformal! fails on pinched outline, split_pinches fixes it) that builds this pinched CurvilinearRegion, calls render_conformal! (@test_throws ErrorException), then runs split_pinches first and asserts the render completes.

If you'd rather scope this PR down to just split_t_junctions! and hold split_pinches until I upstream the noding pass itself (last big preprocessing chunk on my list), happy to do that — but the two are naturally paired since the T-junction split introduces the vertices that can pinch. Your call.

Also worth flagging: find_pinch_points uses atol_nm::Float64=2.0 in the docstring but its impl calls ustrip(getx(p)) without a target unit, so it strips whatever unit the point carries. Points in μm get compared with a numeric-2.0 tolerance interpreted as their raw unit's scale — I hit false-positive pinches with small-coord test inputs (<2 µm apart) before switching to ~100 µm coordinates. Might want to either doc that the caller is responsible for the input unit or ustrip(u"nm", ...) explicitly.

Bright Ye added 4 commits August 19, 2026 23:02
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.
@ybrightye
ybrightye force-pushed the by/conformal-preprocess branch from 76ab7f6 to 763b1d0 Compare August 19, 2026 23:29

@gpeairs gpeairs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))) +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +229 to +235
dθ = θ - θ_start
while dθ > π
dθ -= 2π
end
while dθ < -π
dθ += 2π
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@gpeairs gpeairs Aug 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!(

@gpeairs gpeairs Aug 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}[]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this tolerance so large?

("vertex lies in segment"). Typical usage after [`union2d_curved`](@ref):

```julia
groups = compute_groups(cs) # boolean output

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring could be clearer that inputs are iterables over CurvilinearRegion

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants