Add ConformalRender: parallel render strategy with shared-edge cache#260
Add ConformalRender: parallel render strategy with shared-edge cache#260ybrightye wants to merge 6 commits into
Conversation
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
|
Thanks! Some quick comments/questions before a detailed review:
|
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Avoid ambiguity complaint from Aqua:
| 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...) = |
There was a problem hiding this comment.
Avoid ambiguity complaint from Aqua:
| _add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k; kwargs...) = | |
| _add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k::OpenCascade; kwargs...) = |
| # 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. |
There was a problem hiding this comment.
Should remove or clean up comments with context opaque to present-DeviceLayout-only readers (references to patch number, DTP, production, history)
|
|
||
| # ─── Curve dispatch (arcs, BSplines, offsets) ──────────────────────────────── | ||
|
|
||
| # PATCH 4a: exact circular arc, cached, with strict-tolerance center. |
There was a problem hiding this comment.
Clean up opaque patch number reference
| end | ||
| end | ||
|
|
||
| # PATCH 4b: exact interpolating BSpline, cached, with strict-tolerance |
There was a problem hiding this comment.
Clean up opaque patch number reference
| return _cached_add_spline!(k, ctx, pts, tangents) | ||
| end | ||
|
|
||
| # PATCH 4c: offset segments — constant offset of a Turn is still a circular |
There was a problem hiding this comment.
Clean up opaque patch number reference
| passed through calls. There is no global mutable state; nested/parallel | ||
| renders are safe. |
There was a problem hiding this comment.
Let's not make claims about parallelism, which is unsafe for other reasons in this case.
| kwargs... | ||
| ) | ||
|
|
||
| # CurvilinearRegion: single add_plane_surface call with hole loops (PATCH 1). |
There was a problem hiding this comment.
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Thanks for the fast look. Just pushed Also added the To your review question: yes, the latter. The byte-identical verification I ran was on our downstream layout pipeline, not on this branch's
The full Points 1–5
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. |
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.
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 isO(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 parallelingrender!. 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 byrender!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:
render!(use_conformal_cache=false,skip_fragment=false,point_merge_atol=...). More discoverable but pushes conditional branches into the load-bearing function.strategy::AbstractRenderStrategythroughrender!and its helpers, defaultStockStrategy(). 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) sorender_conformal!reuses the orchestrator; the current MR duplicates the metadata-iteration loop for isolation.Two design decisions worth flagging
discretize_curvefloat-drift divergence between the two sides of a shared boundary — ~1.5 nm observed in production), arc centers / BSpline control points stay at strictPOINT_MERGE_ATOL(1 pm). Relaxing the strict tolerance would corrupt curve geometry.(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) anddiscretize_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 insiderender_conformal!;render!still fragments.Verification
render_conformal!full pipeline, and GmshNative rejection.Test plan
julia scripts/format.jl check)test/test_conformal_render.jlall 14 assertions pass_add_loop!toadd_conformal_loop!produces byte-identical mesh (same md5, same tet count, same worst kappa_proxy)