Skip to content

Add ConformalRender: parallel render strategy with shared-edge cache#260

Open
ybrightye wants to merge 6 commits into
mainfrom
by/conformal-render
Open

Add ConformalRender: parallel render strategy with shared-edge cache#260
ybrightye wants to merge 6 commits into
mainfrom
by/conformal-render

Conversation

@ybrightye

@ybrightye ybrightye commented Jul 14, 2026

Copy link
Copy Markdown

Motivation

At chip scale (~10⁵ faces) the stock render! path creates a distinct OCC edge/point per face boundary and relies on the post-render _fragment_and_map! pass to reconcile coincident entities. That reconciliation is O(N²) and, at production scale, non-manifold artifacts can survive it.

On an internal experimental pipeline we've been running an edge/curve cache that deduplicates OCC entities as they are created, so two adjacent faces requesting the "same" line or arc receive the same OCC tag. The resulting model is conformal by construction and _fragment_and_map! is skipped. This was implemented as a monkey-patch stack (lib/patches.jl + lib/edge_cache.jl) that overrides _add_loop!, _add_curve!, and _get_or_add_point! on DeviceLayout's private symbols.

This MR is a de-monkey-patched extraction of that cache.

What this MR adds

  • render_conformal!(sm, cs; kwargs...) — full-orchestrator entry point paralleling render!. Same kwargs. _fragment_and_map! is not called after postrender operations (the cache guarantees conformality on rendered geometry).
  • add_conformal_loop!(ctx, cl, k, z; points_tree, atol) — public seam for callers that build OCC geometry themselves (e.g. loop-by-loop custom orchestrators). Uses the same cache.
  • ConformalRenderContext(; vertex_merge_atol=2e-3, center_merge_atol=POINT_MERGE_ATOL) — per-render cache + settings (no globals; nested/parallel renders are safe).

What this MR does NOT touch

Explicit non-goal: existing render behavior is byte-identical. Concretely:

  • render! (stock) — untouched
  • _add_loop!, _add_curve!, _get_or_add_point! (stock) — untouched
  • _fragment_and_map! (stock) — untouched, still called by render!

Users on render! see zero behavior change.

Design choices, framing as "one way, open to other routes"

I went with a parallel-strategy approach (new entry point + submodule, stock path fully preserved) because the downstream constraint was "don't risk load-bearing render behavior for existing users." Two alternatives I considered:

  1. Kwargs on stock render! (use_conformal_cache=false, skip_fragment=false, point_merge_atol=...). More discoverable but pushes conditional branches into the load-bearing function.
  2. Strategy dispatch on a marker type: thread strategy::AbstractRenderStrategy through render! and its helpers, default StockStrategy(). Elegant Julia-y, but requires touching every function in the render chain.

Happy to rework in either direction if you'd prefer. Also happy to refactor stock render! into _render_metadata_iteration(...) + _render_default_add!(...) (byte-preserving) so render_conformal! reuses the orchestrator; the current MR duplicates the metadata-iteration loop for isolation.

Two design decisions worth flagging

  • Two point-merge tolerances: polygon vertices default to 2 nm (absorbs Clipper integer-grid + discretize_curve float-drift divergence between the two sides of a shared boundary — ~1.5 nm observed in production), arc centers / BSpline control points stay at strict POINT_MERGE_ATOL (1 pm). Relaxing the strict tolerance would corrupt curve geometry.
  • "Prefer curve" invariant: when a curve (arc or BSpline) already spans endpoints (e1, e2) in the cache, subsequent line requests on those endpoints reuse the curve tag. This makes adjacent faces converge to one OCC entity even when one side computed a straight chord and the other recovered the arc.

What this MR does NOT cover (deferred)

The experimental monkey-patch stack also touches Curvilinear.pathtopolys(::CompoundSegment, ::SimpleCPW) (splits into per-sub-segment CurvilinearPolygons for provenance recovery) and discretize_curve(::ConstantOffset{Straight}) (exact-two-point return instead of 101-point polyline). Those aren't part of the render-side cache and are better addressed as standalone fixes in the curve-recovery pipeline; not in this MR.

Also: the experimental stack currently kills _fragment_and_map! globally. This MR skips it only inside render_conformal!; render! still fragments.

Verification

  • 4 test blocks covering context construction, shared-edge dedup on a two-rectangle fixture, render_conformal! full pipeline, and GmshNative rejection.
  • Byte-identical mesh output vs the experimental pipeline monkey-patch stack when both are run against the same DL fixture (mesh_2d + mesh_3d md5s match exactly).
  • No changes to public API of existing symbols.

Test plan

  • Format check green (julia scripts/format.jl check)
  • test/test_conformal_render.jl all 14 assertions pass
  • Downstream verification: Experimental pipeline swap from monkey-patched _add_loop! to add_conformal_loop! produces byte-identical mesh (same md5, same tet count, same worst kappa_proxy)
  • CI: awaiting pipeline

Introduces `render_conformal!` alongside the existing `render!` as an
alternative render strategy for SolidModel. Stock `render!` is left
untouched; users opt in explicitly via the new entry point.

## Problem

At chip scale (~10⁵ faces) the stock render path creates a distinct OCC
edge/point per face boundary and relies on the post-render
`_fragment_and_map!` pass to reconcile coincident entities. That
reconciliation is O(N²) and, at production scale, non-manifold artifacts
can survive it.

## Approach

`render_conformal!` uses an in-process **edge/curve cache** to
deduplicate OCC entities as they are created. Two adjacent faces
requesting the "same" line or arc receive the same OCC tag, producing
conformal geometry by construction. `_fragment_and_map!` is skipped.

The cache lives on a per-call `ConformalRenderContext` struct (no
globals; nested/parallel renders are safe). Two point-merge tolerances:
polygon vertices at 2 nm (absorbs Clipper integer-grid + float-drift
divergence between two sides of a shared boundary), arc centers /
BSpline control points at strict `POINT_MERGE_ATOL` (relaxing would
corrupt curve geometry). A "prefer curve" invariant ensures that when a
curve exists on endpoints (e1, e2), subsequent line requests on those
endpoints reuse the curve tag.

## Public API

- `render_conformal!(sm, cs; kwargs...)` — full-orchestrator entry point
  paralleling `render!`.
- `add_conformal_loop!(ctx, cl, k, z; points_tree, atol)` — seam for
  callers that build OCC geometry themselves (e.g. loop-by-loop custom
  orchestrators). Uses the same cache as `render_conformal!`.
- `ConformalRenderContext(; vertex_merge_atol, center_merge_atol)` —
  per-render cache + settings.

## What this MR does NOT touch

- `render!` (stock) — byte-identical
- `_add_loop!`, `_add_curve!`, `_get_or_add_point!` (stock) —
  byte-identical
- `_fragment_and_map!` (stock) — byte-identical, still called by
  `render!`

## Provenance

This is a de-monkey-patched extraction of the render-side cache that has
run in production on the CQC digital-twin-pipeline for ~2 months
(patches.jl + edge_cache.jl). Mesh output byte-identical between the
DTP-side monkey-patch stack and the equivalent flow calling
`add_conformal_loop!` on the same DL substrate.

## Framing

This is one way to expose the cache; happy to discuss alternative shapes
(e.g. strategy dispatch on a marker type, or `render!` kwargs). The
constraint driving the parallel-strategy design was "don't touch load-
bearing render behavior for existing users."

## Tests

- ConformalRenderContext construction with defaults + custom tolerances
- add_conformal_loop! reuses the shared edge between two adjacent
  rectangles (cache-hit ≥ 1 on the second loop)
- render_conformal! produces the expected physical group
- render_conformal! rejects GmshNative kernel
@gpeairs

gpeairs commented Jul 15, 2026

Copy link
Copy Markdown
Member

Thanks! Some quick comments/questions before a detailed review:

  • render_conformal! as a parallel entry point is the right way to go; we can re-evaluate as the feature matures
  • I think main should already have the fixes you need for compound pathtopolys and for avoiding straights passing through discretize_curve
  • "Prefer curve" invariant sounds like it has some failure cases where the true geometry has distinct edges spanning two points. We may need a geometric check.
  • The 2nm merge is probably OK if we document when it's safe, but it does make me nervous. We tried something like this in a COMSOL pipeline and it tended to corrupt geometry. For example, if we had the union of a rectangle with a circle in the GDS, arc recognition would leave a sliver edge at the intersection because the intersection point didn't lie on the circle (same as with partial curve recovery, at least until we implement finding the true intersection). We could get rid of this by merging points, but that then meant the rectangle edge was no longer axis aligned, which caused conflicts elsewhere in the geometry (e.g. if another polygon had points meant to be on the original rectangle edge). I think the experimental pipeline makes this safe with its "mutual noding" pass but I'm still considering how/whether we want to address things like that here. I will want to think through some other edge cases as well, and I'm also interested in your thoughts.
  • Suppose our geometry doesn't quite meet the requirements for this to work -- maybe nested entities or overlapping extrusions end up leaving nonconformal geometry, I'm not quite sure (would be good to document this more specifically). Does running this strategy still accelerate a final global fragment if we can make 99% of the geometry conformal by the time we get there? If so, should we add a keyword option on render_conformal! to run the three _fragment_and_map! calls as a backstop?

@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.

When you say "Downstream verification: Experimental pipeline swap from monkey-patched _add_loop! to add_conformal_loop! produces byte-identical mesh" -- was that run on this branch specifically via render_conformal!, or is that a variant of the experimental pipeline itself (i.e. without retargeting main / with the monkey patches omitted here still active)? Sounds like the latter, with a serialized geometry as the input fixture?

Some cosmetic comments below.

ctx::ConformalRenderContext,
x::CurvilinearPolygon,
m::Meta,
k;

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 ambiguity complaint from Aqua:

Suggested change
k;
k::OpenCascade;

end

# Broadcast dispatcher — top-level entry from render_conformal!'s metadata loop.
_add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k; kwargs...) =

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 ambiguity complaint from Aqua:

Suggested change
_add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k; kwargs...) =
_add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k::OpenCascade; kwargs...) =

Comment thread src/solidmodels/conformal/conformal.jl Outdated
Comment on lines +371 to +373
# PATCH 3 without the (disabled) short-edge batching from DTP. The batching was
# gated off in production (`SHORT_EDGE_BATCH_THRESHOLD=0.0`); reproducing it
# here would be dead code.

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.

Should remove or clean up comments with context opaque to present-DeviceLayout-only readers (references to patch number, DTP, production, history)

Comment thread src/solidmodels/conformal/conformal.jl Outdated

# ─── Curve dispatch (arcs, BSplines, offsets) ────────────────────────────────

# PATCH 4a: exact circular arc, cached, with strict-tolerance center.

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.

Clean up opaque patch number reference

Comment thread src/solidmodels/conformal/conformal.jl Outdated
end
end

# PATCH 4b: exact interpolating BSpline, cached, with strict-tolerance

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.

Clean up opaque patch number reference

Comment thread src/solidmodels/conformal/conformal.jl Outdated
return _cached_add_spline!(k, ctx, pts, tangents)
end

# PATCH 4c: offset segments — constant offset of a Turn is still a circular

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.

Clean up opaque patch number reference

Comment on lines +47 to +48
passed through calls. There is no global mutable state; nested/parallel
renders are safe.

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.

Let's not make claims about parallelism, which is unsafe for other reasons in this case.

Comment thread src/solidmodels/conformal/conformal.jl Outdated
kwargs...
)

# CurvilinearRegion: single add_plane_surface call with hole loops (PATCH 1).

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.

Clean up opaque patch number reference

Two changes in response to review:

1. **Fix Aqua ambiguity** (CI regression). Removed the redundant
   `_add_conformal!(ctx, el, m, k::GmshNative; kwargs...)` rejection
   method — `render_conformal!` already guards `kernel(sm) isa
   OpenCascade` at entry, so the per-primitive rejection was dead code
   AND it was ambiguous with the `AbstractVector` broadcast dispatcher
   for the `Vector` × `GmshNative` case that never occurs in practice.
   Aqua now green.

2. **Add `fragment_backstop::Bool=false` kwarg** to `render_conformal!`.
   When true, the stock 3-pass `_fragment_and_map!` sequence runs after
   postrender as a safety net. Faces already resolved to a single OCC
   entity by the cache pass through fragment as no-ops, so it's safe to
   combine. Enable when input geometry may not fully meet the
   "shared endpoints ⟹ shared curve" precondition, or when
   `postrender_ops` introduce overlapping entities that need
   reconciliation. Default false preserves the "conformal by
   construction, no fragment" contract for callers who've established
   the precondition upstream.

Also expanded the module docstring with a **Preconditions** section
that spells out the three conditions the cache assumes:

  (1) shared endpoints ⟹ shared curve geometry (met by callers that
      reference the same Segment object on both sides, e.g. via
      `union2d_curved`'s coordinated recovery, or by upstream
      mutual-noding),
  (2) no distinct co-endpoint edges (the prefer-curve rule fuses them),
  (3) relaxed vertex merge is safe iff features > 500× the merge tol
      (default 2 nm ≪ 1 µm minimum feature).

New test: `fragment_backstop` kwarg accepts both false (default) and
true; both produce the expected physical group on a two-rectangle
fixture.

Test count: 14 → 16 assertions, all pass. Format check green.
Aqua ambiguity test now green (previously failed with 2 ambiguities
involving the GmshNative rejection method).
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.22642% with 61 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/solidmodels/conformal/conformal.jl 71.22% 61 Missing ⚠️

📢 Thoughts on this report? Let us know!

@ybrightye

Copy link
Copy Markdown
Author

Thanks for the fast look. Just pushed a740f37 which fixes the Aqua ambiguity CI red (removed a redundant GmshNative-rejection method that was already covered by the entry-point guard; the ambiguity was between that dead-code path and the AbstractVector broadcast dispatcher). Aqua's ambiguity test is green locally.

Also added the fragment_backstop::Bool=false kwarg you suggested — see below.

To your review question: yes, the latter. The byte-identical verification I ran was on our downstream layout pipeline, not on this branch's render_conformal! directly. Specifically:

  • Same downstream orchestrator (which builds OCC entities loop-by-loop rather than calling render!)
  • Gutted our monkey-patch stack to remove render-side overrides, kept only the two Curvilinear.pathtopolys(::CompoundSegment) + discretize_curve(::ConstantOffset{Straight}) methods (separate territory)
  • Swapped direct calls to _add_loop! for add_conformal_loop!(ctx, region.exterior, k, z; points_tree) — the new public seam
  • Ran end-to-end on the same serialized CoordinateSystem fixture; mesh_2d + mesh_3d md5s match the previous run byte-for-byte

The full render_conformal! orchestrator is only exercised by the unit tests on toy CoordinateSystems — I don't yet have a chip-scale layout that goes through DL's render!-style entry point. If it'd be useful, I can construct one from DemoQPU17 or similar.

Points 1–5

  1. Parallel entry point OK — great, glad the architecture holds up.

  2. main fixes for CompoundSegment / straight-through-discretize — thanks for the pointer. That retires the two we still had locally, and I'll leave them out of this MR (they're not render-side) and revisit downstream once we bump the DL pin.

  3. "Prefer curve" invariant has failure cases. You're right. The current implementation assumes shared-endpoint ⟹ shared-curve-geometry, which is a real precondition. In our downstream pipeline that precondition is established upstream (mutual-noding pass over the boolean output, then curves flow through the render step as the same Paths.Segment object on both sides). Without that upstream discipline, distinct co-endpoint edges will get incorrectly fused. I've added a Preconditions section to the module docstring making that explicit, and:

    • fragment_backstop=true kwarg (new in a740f37) runs the stock 3-pass _fragment_and_map! sequence as a safety net when the caller can't guarantee (1) shared-endpoint ⟹ shared-curve. Faces already resolved by the cache pass through fragment as no-ops, so it composes cleanly.

    A stricter check would be to inspect the actual curve geometry at cache-key collision time (e.g. sample-point comparison, or require an explicit Paths.Segment identity match) — happy to add if you prefer that over the "document + backstop" approach. My hesitation is that inspecting curve geometry inside the cache turns a Dict lookup into an O(n) compare, negating the speedup.

  4. 2 nm merge nervousness / COMSOL sliver experience. Fair. The specific failure mode you describe (Circle+Rectangle union → sliver at intersection because intersection-point doesn't lie on the circle, then merge moves rectangle points off the axis-aligned edge) is exactly the class we hit before adopting an upstream mutual-noding pass. Two mitigations in place:

    • The default 2 nm is 500× smaller than a 1 µm minimum feature, which is our design floor. vertex_merge_atol is configurable via the context.
    • fragment_backstop=true catches survivor slivers when the merge doesn't do the job.

    Concretely for the Rectangle+Circle case: our mutual-noding pass handles it by injecting the intersection point onto both curves before render, so both sides end up with the exact same discretized point. That's upstream of render — happy to have a follow-up conversation about whether DL should absorb some analog of that mutual-noding step.

    Do you have a small reproducer of the COMSOL failure that we could run through this pipeline? If it survives (or fails visibly), that's a solid test case to add.

  5. Fallback fragment as backstop — done via fragment_backstop=true. Default false preserves the "conformal by construction, no fragment" contract for callers who've established the precondition. When true, the three _fragment_and_map! calls run after postrender. Test added: same fixture with backstop on vs off both produce the expected physical group.

Let me know if the Preconditions doc + backstop is enough clarity for merge, or if you'd like me to tighten the invariant with a geometric check.

Bright Ye added 4 commits July 15, 2026 19:14
Adds targeted tests for the curve dispatch paths that were previously
uncovered:

- add_conformal_loop! with Paths.Turn (short arc, |α| < 180°)
- add_conformal_loop! with Paths.Turn (large arc, |α| >= 180° → multi-arc)
- add_conformal_loop! with Paths.BSpline
- render_conformal! with CurvilinearRegion (rounded rectangle exterior)
- render_conformal! with ClippedPolygon (exercises CurvilinearRegion holes)
- render_conformal! shared-vertex dedup assertion at edge-count level
- render_conformal! zmap kwarg positions surfaces at nonzero z

Codecov flagged 40.2% patch coverage on conformal.jl; these additions target
the per-primitive dispatchers (_add_conformal!(CurvilinearRegion),
_add_conformal_curve!(Turn), _add_conformal_curve!(BSpline), and the strict
merge in _cached_add_arc!/_cached_add_spline!) which had no direct tests.

All 26 assertions pass locally (29s wall).
Line-wrap cosmetics only; no behavior changes.
_cached_add_spline! previously did not consult or register
endpoint_curve_index, breaking the module's stated invariant that "when a
curve exists on endpoints (e1, e2), every subsequent request on (e1, e2) —
line or arc — returns that curve." Effects:

- An arc rendered on (a, b) followed by a spline request on (a, b) produced
  two distinct OCC entities on one geometric boundary.
- A spline rendered on (a, b) followed by a line request on (a, b) produced
  a chord duplicate; the polygon bounded by the chord was non-conformal
  with the spline-side face.

Fix mirrors _cached_add_arc!: consult endpoint_curve_index on lookup and
register the signed tag on miss. Two new testsets assert the invariant
holds across arc→line and spline→line orderings on shared endpoints.

29/29 tests pass locally.
The section-header comments previously referenced downstream patch numbers
(PATCH 1/3/4a/4b/4c) and a downstream tuning knob (SHORT_EDGE_BATCH_THRESHOLD)
that mean nothing in this file's context. Reword each header to describe
what the section does — surface assembly, curve-loop assembly, curve dispatch
for arcs/splines/offsets — without referring to prior code lineage.

No functional changes.
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