From 618fdf65317d91659ad7985b4b6b7b9978bbbec7 Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Tue, 14 Jul 2026 22:27:43 +0000 Subject: [PATCH 1/6] Add ConformalRender: parallel render strategy with shared-edge cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/solidmodels/conformal/conformal.jl | 741 +++++++++++++++++++++++++ src/solidmodels/solidmodels.jl | 4 + test/test_conformal_render.jl | 110 ++++ 3 files changed, 855 insertions(+) create mode 100644 src/solidmodels/conformal/conformal.jl create mode 100644 test/test_conformal_render.jl diff --git a/src/solidmodels/conformal/conformal.jl b/src/solidmodels/conformal/conformal.jl new file mode 100644 index 000000000..38cf2c72c --- /dev/null +++ b/src/solidmodels/conformal/conformal.jl @@ -0,0 +1,741 @@ +""" + ConformalRender + +Alternative render strategy for [`SolidModel`](@ref) that emits **shared OCC edge +entities** for adjacent faces, producing conformal geometry without relying on +`_fragment_and_map!` after render. + +Motivation: at chip scale (~10⁵ faces), the stock render path creates a distinct +OCC edge/point per face boundary, then the post-render `_fragment_and_map!` pass +reconciles coincident entities. That reconciliation is `O(N²)` and, at production +scale, non-manifold artifacts can survive it. + +`render_conformal!` takes a different path: an in-process **edge/curve cache** +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. + +The stock [`render!`](@ref) is left unchanged; `render_conformal!` is a parallel +entry point that shares the [`SolidModel`](@ref) type and produces a `SolidModel` +that downstream operations (postrender, meshing, save) consume interchangeably. + +# Usage + +```julia +sm = SolidModel("mymodel"; overwrite=true) +render_conformal!(sm, cs; postrender_ops=..., zmap=..., kwargs...) +``` + +Same kwargs as `render!`, except `_fragment_and_map!` is not called after +postrender operations (the cache already guarantees conformality on rendered +geometry). If your postrender operations create new overlapping entities, use +`render!` instead (or call `_fragment_and_map!` explicitly). + +# Design notes + + - **Two point-merge tolerances**: polygon vertices use a **relaxed** tolerance + (default 2 nm) because Clipper's integer-grid output and DeviceLayout's + `discretize_curve` float drift can leave the two sides of a shared boundary + ~1.5 nm apart. Arc centers and BSpline control points use the strict tolerance + (`POINT_MERGE_ATOL`) — merging those at the relaxed tolerance would corrupt + geometry. + - **Prefer-curve invariant**: when a curve (arc or spline) exists on endpoints + `(e1, e2)`, every subsequent request on `(e1, e2)` — line or arc — returns + that curve. This is the "curve wins" rule that makes adjacent faces resolve + to one OCC entity even when one side computed a line and the other an arc. + - **Per-render cache**: the cache lives on a `ConformalRenderContext` struct + passed through calls. There is no global mutable state; nested/parallel + renders are safe. +""" +module ConformalRender + +using ..SolidModels +using ..SolidModels: + SolidModel, + OpenCascade, + GmshNative, + kernel, + gmsh, + STP_UNIT, + POINT_MERGE_ATOL, + _synchronize!, + _add_curve!, + _get_or_add_point!, + _postrender!, + _get_or_add_points!, + _stp_float, + _collect_mesh_control_points!, + _used_group_names, + sizeandgrading, + to_primitives, + clear_mesh_control_points!, + finalize_size_fields!, + set_gmsh_option, + dimgroupdict, + dimtags, + hasgroup, + remove_group!, + reindex_physical_groups! +import ..SolidModels: gmsh_meshsize +import DeviceLayout +import DeviceLayout: + AbstractCoordinateSystem, + AbstractPolygon, + CurvilinearPolygon, + CurvilinearRegion, + LineSegment, + Meta, + Point, + Paths, + getx, + gety, + points, + flatten, + elements, + element_metadata, + coordinatetype, + onenanometer, + layer +import DeviceLayout.Paths: bspline_approximation, pathlength +import Unitful: ustrip, Length, @u_str, ° +import SpatialIndexing +import SpatialIndexing: RTree + +export render_conformal!, ConformalRenderContext, add_conformal_loop! + +""" + ConformalRenderContext(; vertex_merge_atol=2e-3, center_merge_atol=POINT_MERGE_ATOL) + +Per-render cache and settings for a `render_conformal!` call. + + - `vertex_merge_atol` (µm): tolerance for merging polygon-vertex OCC points. + Default `2e-3` µm = 2 nm, chosen to absorb Clipper integer-grid + float-drift + divergence between the two sides of a shared boundary (~1.5 nm observed) while + staying 500× below typical minimum feature size (1 µm). + - `center_merge_atol` (µm): tolerance for merging arc centers and BSpline control + points. Kept strict (`POINT_MERGE_ATOL` = 1e-9 µm = 1 pm) because relaxing here + would corrupt curve geometry. + - `curve_cache`: exact dedup by `(type, geometry_key…)`. Same request → same tag. + - `endpoint_curve_index`: `(min_pt, max_pt) → tag`, registered by arcs and + splines. Enforces the "prefer curve" invariant. + - `stats`: telemetry (hits, misses, arcs, splines, chord fallbacks). +""" +mutable struct ConformalRenderContext + vertex_merge_atol::Float64 + center_merge_atol::Float64 + curve_cache::Dict{Tuple, Int} + endpoint_curve_index::Dict{Tuple{Int, Int}, Int} + stats::Dict{Symbol, Int} +end + +ConformalRenderContext(; + vertex_merge_atol::Float64=2e-3, + center_merge_atol::Float64=POINT_MERGE_ATOL +) = ConformalRenderContext( + vertex_merge_atol, + center_merge_atol, + Dict{Tuple, Int}(), + Dict{Tuple{Int, Int}, Int}(), + Dict{Symbol, Int}( + :hits => 0, + :misses => 0, + :arcs => 0, + :splines => 0, + :chord_fallbacks => 0 + ) +) + +# ─── Point merge ───────────────────────────────────────────────────────────── + +# Vertex-precision (relaxed) point insert. Used for polygon vertices where the +# two sides of a shared boundary may differ by ~1.5 nm. +function _cached_point_relaxed!( + k, + ctx::ConformalRenderContext, + x::Float64, + y::Float64, + z::Float64, + points_tree +) + points_tree === nothing && return k.add_point(x, y, z) + return _get_or_add_point!(k, x, y, z, points_tree; atol=ctx.vertex_merge_atol) +end + +# Strict point insert. Used for arc centers and BSpline control points where +# any merge would corrupt geometry. +function _cached_point_strict!( + k, + ctx::ConformalRenderContext, + x::Float64, + y::Float64, + z::Float64, + points_tree +) + points_tree === nothing && return k.add_point(x, y, z) + return _get_or_add_point!(k, x, y, z, points_tree; atol=ctx.center_merge_atol) +end + +# ─── Edge/curve cache ──────────────────────────────────────────────────────── + +# Add a line, dedup on unordered endpoint pair. +function _cached_add_line!(k, ctx::ConformalRenderContext, p1::Integer, p2::Integer) + p1 == p2 && error("degenerate edge: p1 == p2 == $p1") + lo, hi = minmax(p1, p2) + key = (:line, lo, hi) + existing = get(ctx.curve_cache, key, nothing) + if existing !== nothing + ctx.stats[:hits] += 1 + return p1 < p2 ? existing : -existing + end + # Prefer-curve: a curve already spans these endpoints → reuse it. + existing_curve = get(ctx.endpoint_curve_index, (lo, hi), nothing) + if existing_curve !== nothing + ctx.stats[:hits] += 1 + return p1 < p2 ? existing_curve : -existing_curve + end + ctx.stats[:misses] += 1 + tag = k.addLine(p1, p2) + ctx.curve_cache[key] = p1 < p2 ? tag : -tag + return tag +end + +# Add a circle arc. Registers in the endpoint index so subsequent line requests +# on the same endpoints reuse this arc (curve wins). +function _cached_add_arc!( + k, + ctx::ConformalRenderContext, + p1::Integer, + center::Integer, + p2::Integer +) + lo, hi = minmax(p1, p2) + # Exact arc (center in key): the same Turn requested again → same tag. + key = (:arc, lo, center, hi) + existing = get(ctx.curve_cache, key, nothing) + if existing !== nothing + ctx.stats[:hits] += 1 + return p1 < p2 ? existing : -existing + end + # Any curve already on these endpoints → reuse (handles float-jittered + # center of the coordinated Turn applied to the other side). + existing_curve = get(ctx.endpoint_curve_index, (lo, hi), nothing) + if existing_curve !== nothing + ctx.stats[:hits] += 1 + return p1 < p2 ? existing_curve : -existing_curve + end + # Conformality backstop: a chord was already created on these endpoints + # (the other side rendered this boundary as a line because it did not + # recover the arc). Reuse the chord so both faces share one tag; accuracy + # is lost in this one-sided cell only. + existing_line = get(ctx.curve_cache, (:line, lo, hi), nothing) + if existing_line !== nothing + ctx.stats[:hits] += 1 + return p1 < p2 ? existing_line : -existing_line + end + ctx.stats[:misses] += 1 + ctx.stats[:arcs] += 1 + tag = k.add_circle_arc(p1, center, p2, -1) + signed_tag = p1 < p2 ? tag : -tag + ctx.curve_cache[key] = signed_tag + ctx.endpoint_curve_index[(lo, hi)] = signed_tag + return tag +end + +# Add an interpolating BSpline. Deduped by exact control-net; also unified with +# its reversal. +function _cached_add_spline!( + k, + ctx::ConformalRenderContext, + pts::Vector{<:Integer}, + tangents +) + key = (:bspline, pts...) + existing = get(ctx.curve_cache, key, nothing) + if existing !== nothing + ctx.stats[:hits] += 1 + return existing + end + rkey = (:bspline, reverse(pts)...) + existing_r = get(ctx.curve_cache, rkey, nothing) + if existing_r !== nothing + ctx.stats[:hits] += 1 + return -existing_r + end + ctx.stats[:misses] += 1 + ctx.stats[:splines] += 1 + tag = k.addSpline(pts, -1, tangents) + ctx.curve_cache[key] = tag + return tag +end + +# ─── Primitive-to-OCC entity dispatch ──────────────────────────────────────── + +# CurvilinearPolygon → CurvilinearRegion path +_add_conformal!( + ctx::ConformalRenderContext, + x::CurvilinearPolygon, + m::Meta, + k; + zmap=(_) -> zero(coordinatetype(x)), + points_tree=nothing, + kwargs... +) = _add_conformal!( + ctx, + CurvilinearRegion(x), + m, + k; + zmap=zmap, + points_tree=points_tree, + kwargs... +) + +# CurvilinearRegion: single add_plane_surface call with hole loops (PATCH 1). +# Stock DL creates outer surface, then per-hole surface, then k.cut() each hole. +# The multi-loop form is one OCC call and preserves shared points naturally. +function _add_conformal!( + ctx::ConformalRenderContext, + surf::CurvilinearRegion{T}, + m::Meta, + k::OpenCascade; + zmap=(_) -> zero(T), + points_tree=nothing, + atol=onenanometer(T), + kwargs... +) where {T} + z = zmap(m) + outer_loop = _add_conformal_loop!(ctx, surf.exterior, k, z; points_tree, atol) + hole_loops = _add_conformal_loop!.(Ref(ctx), surf.holes, k, z; points_tree, atol) + surftag = k.add_plane_surface([outer_loop; hole_loops...]) + return (Int32(2), surftag) +end + +# Plain polygon path (rectilinear). +function _add_conformal!( + ctx::ConformalRenderContext, + poly::AbstractPolygon{T}, + m::Meta, + k::OpenCascade; + zmap=(_) -> zero(T), + points_tree=nothing, + atol=onenanometer(T), + kwargs... +) where {T} + z = zmap(m) + loop = + _add_conformal_loop!(ctx, CurvilinearPolygon(points(poly)), k, z; points_tree, atol) + surf = k.add_plane_surface([loop]) + return (Int32(2), surf) +end + +# Line segment path (1D entity). +function _add_conformal!( + ctx::ConformalRenderContext, + line::LineSegment{T}, + m::Meta, + k::OpenCascade; + zmap=(_) -> zero(T), + points_tree=nothing, + atol=onenanometer(T), + kwargs... +) where {T} + z = zmap(m) + p0 = _cached_point_relaxed!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(line.p0))), + Float64(ustrip(STP_UNIT, gety(line.p0))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) + p1 = _cached_point_relaxed!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(line.p1))), + Float64(ustrip(STP_UNIT, gety(line.p1))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) + linetag = _cached_add_line!(k, ctx, p0, p1) + return (Int32(1), linetag) +end + +# Broadcast dispatcher — top-level entry from render_conformal!'s metadata loop. +_add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k; kwargs...) = + [_add_conformal!(ctx, el, m, k; kwargs...) for el in els] + +_add_conformal!(ctx::ConformalRenderContext, el, m::Meta, k::GmshNative; kwargs...) = + error("ConformalRender is only implemented for OpenCascade kernel; got GmshNative") + +# ─── Curve loop assembly ───────────────────────────────────────────────────── + +# 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. +""" + add_conformal_loop!(ctx::ConformalRenderContext, cl::CurvilinearPolygon, + k::OpenCascade, z; points_tree=nothing, atol=onenanometer(...)) + +Build an OCC curve loop for `cl` using the conformal edge/curve cache. + +This is the public seam for callers that build OCC geometry themselves (rather +than using [`render_conformal!`](@ref)'s orchestrator). Typical usage: + +```julia +ctx = ConformalRenderContext() +points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) +for region in regions + outer = add_conformal_loop!(ctx, region.exterior, k, z; points_tree) + holes = [add_conformal_loop!(ctx, h, k, z; points_tree) for h in region.holes] + k.add_plane_surface([outer; holes...]) +end +``` + +Adjacent regions that share a boundary curve will resolve to the same OCC edge +tag via the cache, producing conformal geometry without `_fragment_and_map!`. +""" +function add_conformal_loop!( + ctx::ConformalRenderContext, + cl::CurvilinearPolygon, + k::OpenCascade, + z; + points_tree=nothing, + atol=onenanometer(coordinatetype(cl)) +) + return _add_conformal_loop!(ctx, cl, k, z; points_tree, atol) +end + +function _add_conformal_loop!( + ctx::ConformalRenderContext, + cl::CurvilinearPolygon, + k::OpenCascade, + z; + points_tree=nothing, + atol=onenanometer(coordinatetype(cl)) +) + poly_pts = points(cl) + pts = [ + _cached_point_relaxed!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(p))), + Float64(ustrip(STP_UNIT, gety(p))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) for p in poly_pts + ] + n = length(pts) + curve_set = Set(cl.curve_start_idx) + curves_out = Int32[] + for i = 1:n + j = mod1(i + 1, n) + if i in curve_set + curve_idx = findfirst(isequal(i), cl.curve_start_idx) + endpoints = (pts[i], pts[j]) + result = _add_conformal_curve!( + ctx, + endpoints, + cl.curves[curve_idx], + k, + z, + points_tree; + atol + ) + if result isa AbstractVector + append!(curves_out, result) + else + push!(curves_out, result) + end + else + # Drop zero-length edges that collapse when adjacent contour vertices + # merge at the relaxed tolerance. The near-duplicate pair is + # identical on both sides of a shared boundary, so both faces drop + # the same edge → still conformal. + pts[i] == pts[j] && continue + push!(curves_out, _cached_add_line!(k, ctx, pts[i], pts[j])) + end + end + return k.add_curve_loop(curves_out) +end + +# ─── Curve dispatch (arcs, BSplines, offsets) ──────────────────────────────── + +# PATCH 4a: exact circular arc, cached, with strict-tolerance center. +function _add_conformal_curve!( + ctx::ConformalRenderContext, + endpoints, + seg::Paths.Turn, + k::OpenCascade, + z, + points_tree; + kwargs... +) + center_pt = + seg.p0 + + Point(-seg.r * sign(seg.α) * sin(seg.α0), seg.r * sign(seg.α) * cos(seg.α0)) + cen = _cached_point_strict!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(center_pt))), + Float64(ustrip(STP_UNIT, gety(center_pt))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) + + if abs(seg.α) >= 180° + n_180 = abs(seg.α) / 180° + n_arcs = ceil(n_180) == n_180 ? Int(n_180 + 1) : Int(ceil(n_180)) + arclengths = range(zero(pathlength(seg)), pathlength(seg), length=n_arcs + 1) + middle_pts = seg.(arclengths[(begin + 1):(end - 1)]) + middle_tags = [ + _cached_point_strict!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(mp))), + Float64(ustrip(STP_UNIT, gety(mp))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) for mp in middle_pts + ] + tags = [endpoints[1]; middle_tags; endpoints[2]] + return [ + _cached_add_arc!(k, ctx, tags[i], cen, tags[i + 1]) for i = 1:(length(tags) - 1) + ] + end + + try + return _cached_add_arc!(k, ctx, endpoints[1], cen, endpoints[2]) + catch e + if e isa ErrorException && contains(e.msg, "Could not create circle arc") + ctx.stats[:chord_fallbacks] += 1 + return _cached_add_line!(k, ctx, endpoints[1], endpoints[2]) + end + rethrow() + end +end + +# PATCH 4b: exact interpolating BSpline, cached, with strict-tolerance +# intermediate control points. +function _add_conformal_curve!( + ctx::ConformalRenderContext, + endpoints, + seg::Paths.BSpline, + k::OpenCascade, + z, + points_tree; + kwargs... +) + midpts = [ + _cached_point_strict!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(p))), + Float64(ustrip(STP_UNIT, gety(p))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) for p in seg.p[2:(end - 1)] + ] + pts = [endpoints[1], midpts..., endpoints[2]] + tangents = [ + ustrip(STP_UNIT, seg.t0.x), + ustrip(STP_UNIT, seg.t0.y), + 0.0, + ustrip(STP_UNIT, seg.t1.x), + ustrip(STP_UNIT, seg.t1.y), + 0.0 + ] + return _cached_add_spline!(k, ctx, pts, tangents) +end + +# PATCH 4c: offset segments — constant offset of a Turn is still a circular +# arc (exact); general offset (BSpline or variable) is approximated by a +# BSpline chain with join points at the RELAXED tolerance to unify sub-splines +# produced from opposite traversal directions. +function _add_conformal_curve!( + ctx::ConformalRenderContext, + endpoints, + seg::Paths.OffsetSegment, + k::OpenCascade, + z, + points_tree; + kwargs... +) + base = seg.seg + off = seg.offset + # Constant offset of a Turn: still a circular arc, exact. + if base isa Paths.Turn && off isa DeviceLayout.Coordinate + off_turn = Paths.Turn( + base.α, + base.r - sign(base.α) * off, + base.p0 + Point(-sin(base.α0), cos(base.α0)) * off, + base.α0 + ) + return _add_conformal_curve!(ctx, endpoints, off_turn, k, z, points_tree; kwargs...) + end + # General case (offset BSpline / variable offset). `bspline_approximation` + # is NOT direction-symmetric: calling it on `seg` and on `Paths.reverse(seg)` + # produces ulp-level different join coordinates on the SAME geometric curve. + # The RELAXED merge unifies them; the strict merge does not. + atol_local = onenanometer(coordinatetype(Paths.p0(seg))) + approx = bspline_approximation(seg; atol=atol_local) + newstarts = DeviceLayout.p0.(approx.segments)[2:end] + newpts = [ + _cached_point_relaxed!( + k, + ctx, + Float64(ustrip(STP_UNIT, getx(p))), + Float64(ustrip(STP_UNIT, gety(p))), + Float64(ustrip(STP_UNIT, z)), + points_tree + ) for p in newstarts + ] + starts = [endpoints[1], newpts...] + stops = [newpts..., endpoints[2]] + tags = Int32[] + for (ep, sub) in zip([[s, e] for (s, e) in zip(starts, stops)], approx.segments) + t = _add_conformal_curve!(ctx, ep, sub, k, z, points_tree; kwargs...) + if t isa AbstractVector + append!(tags, t) + else + push!(tags, t) + end + end + return tags +end + +# Fallback: any segment type not specialized above (e.g. Straight) is handled +# by DL's stock `_add_curve!` — those types don't benefit from caching (they're +# already exact-and-cheap in stock DL). +_add_conformal_curve!( + ctx::ConformalRenderContext, + endpoints, + seg::Paths.Segment, + k::OpenCascade, + z, + points_tree; + kwargs... +) = _add_curve!(endpoints, seg, k, z; kwargs...) + +# ─── Public entry point ────────────────────────────────────────────────────── + +""" + render_conformal!(sm::SolidModel, cs::AbstractCoordinateSystem; + context=ConformalRenderContext(), + map_meta=layer, postrender_ops=[], retained_physical_groups=[], + zmap=(_)->zero(T), gmsh_options=..., skip_postrender=false, + auto_union=false, skip_unused_layers=false, kwargs...) + +Render `cs` into `sm` using the ConformalRender strategy. Same semantics as +[`render!`](@ref) but without the post-render `_fragment_and_map!` pass. + +The `context::ConformalRenderContext` holds the edge/curve cache and merge +tolerances; pass an explicit context if you need custom tolerances or want to +inspect cache statistics after the render. + +Not supported on `GmshNative` kernel. +""" +function render_conformal!( + sm::SolidModel, + cs::AbstractCoordinateSystem{T}; + context::ConformalRenderContext=ConformalRenderContext(), + map_meta=layer, + postrender_ops=[], + retained_physical_groups=[], + zmap=(_) -> zero(T), + gmsh_options=Dict{String, Union{String, Int, Float64}}(), + skip_postrender::Bool=false, + auto_union::Bool=false, + skip_unused_layers::Bool=false, + kwargs... +) where {T} + kernel(sm) isa OpenCascade || error( + "render_conformal! is only implemented for OpenCascade kernel; " * + "got $(typeof(kernel(sm))). Use render! instead." + ) + gmsh.model.set_current(SolidModels.name(sm)) + set_gmsh_option(gmsh_options) + + flat = flatten(cs) + clear_mesh_control_points!() + points_tree = RTree{Float64, 3}(Int32) + + used_names = if skip_unused_layers + _used_group_names(postrender_ops, retained_physical_groups) + else + nothing + end + + for meta in unique(element_metadata(flat)) + mapped_name = map_meta(meta) + isnothing(mapped_name) && continue + if !isnothing(used_names) && + string(mapped_name) ∉ used_names && + string(layer(meta)) ∉ used_names + continue + end + idx = (element_metadata(flat) .== meta) + els = to_primitives.(sm, elements(flat)[idx]; kwargs...) + meshsizes = sizeandgrading.(elements(flat)[idx]; kwargs...) + + group_dimtags_unflattened = _add_conformal!( + context, + els, + meta, + kernel(sm); + zmap=zmap, + points_tree=points_tree, + kwargs... + ) + + group_dimtags = reduce(vcat, group_dimtags_unflattened, init=Tuple{Int32, Int32}[]) + for dim in unique(first.(group_dimtags)) + if hasgroup(sm, mapped_name, dim) + append!(group_dimtags, dimtags(sm[mapped_name, dim])) + end + end + sm[mapped_name] = group_dimtags + + z_of_meta = _stp_float(zmap(meta)) + for (prims, (h, α)) in zip(els, meshsizes) + _collect_mesh_control_points!(prims, h, α, z_of_meta) + end + end + + _synchronize!(sm) + finalize_size_fields!() + _synchronize!(sm) + skip_postrender && return nothing + + if auto_union + auto_union_ops = Tuple[] + for groupname in collect(keys(dimgroupdict(sm, 2))) + push!(auto_union_ops, (groupname, SolidModels.union_geom!, (groupname, 2))) + end + _postrender!(sm, auto_union_ops) + _synchronize!(sm) + end + _postrender!(sm, postrender_ops) + _synchronize!(sm) + + # Key difference vs render!: NO _fragment_and_map! pass. The conformal cache + # already deduplicates shared edges during render, and _fragment_and_map! + # would touch OCC entities the cache assumes are stable. If your + # postrender_ops introduce overlapping entities that need reconciliation, + # use render! instead. + + gmsh.model.mesh.setSizeCallback(gmsh_meshsize) + + if !isempty(retained_physical_groups) + for d = 0:3 + retain_groups = getindex.(filter(x -> x[2] == d, retained_physical_groups), 1) + all_groups = keys(dimgroupdict(sm, d)) + for k in setdiff(all_groups, retain_groups) + remove_group!(sm[k, d], remove_entities=false) + end + end + reindex_physical_groups!(sm) + end + + return _synchronize!(sm) +end + +end # module ConformalRender diff --git a/src/solidmodels/solidmodels.jl b/src/solidmodels/solidmodels.jl index 0481626c9..e97b3dffa 100644 --- a/src/solidmodels/solidmodels.jl +++ b/src/solidmodels/solidmodels.jl @@ -445,5 +445,9 @@ end include("render.jl") include("postrender.jl") +include("conformal/conformal.jl") + +using .ConformalRender: render_conformal!, ConformalRenderContext, add_conformal_loop! +export render_conformal!, ConformalRenderContext, add_conformal_loop! end # module diff --git a/test/test_conformal_render.jl b/test/test_conformal_render.jl new file mode 100644 index 000000000..45f143f4a --- /dev/null +++ b/test/test_conformal_render.jl @@ -0,0 +1,110 @@ +@testitem "ConformalRender smoke" setup = [CommonTestSetup] begin + using DeviceLayout: + Point, + Rectangle, + centered, + Polygon, + points, + coordinatetype, + onenanometer, + ClippedPolygon, + difference2d + using DeviceLayout.Polygons: Rounded + using DeviceLayout.Curvilinear: CurvilinearPolygon, CurvilinearRegion + using DeviceLayout.SolidModels: + SolidModel, + ConformalRenderContext, + add_conformal_loop!, + render_conformal!, + kernel, + gmsh, + hasgroup + import DeviceLayout.SolidModels + + @testset "ConformalRenderContext construction" begin + ctx = ConformalRenderContext() + @test ctx.vertex_merge_atol == 2e-3 + @test ctx.center_merge_atol == SolidModels.POINT_MERGE_ATOL + @test isempty(ctx.curve_cache) + @test isempty(ctx.endpoint_curve_index) + @test ctx.stats[:hits] == 0 + @test ctx.stats[:misses] == 0 + + # Custom tolerances + ctx2 = ConformalRenderContext(; vertex_merge_atol=5e-3) + @test ctx2.vertex_merge_atol == 5e-3 + end + + @testset "add_conformal_loop! dedupes shared edges" begin + # Two rectangles sharing an edge — the shared edge should resolve to + # the same OCC tag in both loops, proving the cache works. + sm = SolidModel("conformal_share_edge"; overwrite=true) + k = kernel(sm) + ctx = ConformalRenderContext() + import SpatialIndexing + points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) + + # Left rect: (0,0) → (10,0) → (10,10) → (0,10) + left = CurvilinearPolygon( + Point{typeof(1.0μm)}[ + Point(0.0μm, 0.0μm), + Point(10.0μm, 0.0μm), + Point(10.0μm, 10.0μm), + Point(0.0μm, 10.0μm) + ] + ) + # Right rect: (10,0) → (20,0) → (20,10) → (10,10) — shared edge is x=10 side + right = CurvilinearPolygon( + Point{typeof(1.0μm)}[ + Point(10.0μm, 0.0μm), + Point(20.0μm, 0.0μm), + Point(20.0μm, 10.0μm), + Point(10.0μm, 10.0μm) + ] + ) + + loop1 = add_conformal_loop!(ctx, left, k, 0.0μm; points_tree) + loop2 = add_conformal_loop!(ctx, right, k, 0.0μm; points_tree) + + # After the second loop, we should see at least one cache hit — the + # shared edge (10,0)→(10,10) should reuse the tag from the first loop. + @test ctx.stats[:hits] >= 1 + @test loop1 != loop2 # they are different loops + + gmsh.finalize() + end + + @testset "render_conformal! renders same geometry as render!" begin + # Simple CS with two adjacent rectangles; verify both render! and + # render_conformal! produce a valid SolidModel with the same number + # of surface entities. + cs = CoordinateSystem("adjacent", nm) + place!(cs, Rectangle(Point(0.0μm, 0.0μm), Point(10.0μm, 10.0μm)), :l1) + place!(cs, Rectangle(Point(10.0μm, 0.0μm), Point(20.0μm, 10.0μm)), :l1) + + sm_stock = SolidModel("stock"; overwrite=true) + render!(sm_stock, cs) + n_surf_stock = length(gmsh.model.occ.getEntities(2)) + + sm_conformal = SolidModel("conformal"; overwrite=true) + render_conformal!(sm_conformal, cs) + n_surf_conformal = length(gmsh.model.occ.getEntities(2)) + + # Both should produce two surfaces + @test n_surf_stock >= 2 + @test n_surf_conformal >= 2 + # Both models have the expected physical group + @test hasgroup(sm_stock, "l1", 2) + @test hasgroup(sm_conformal, "l1", 2) + + gmsh.finalize() + end + + @testset "render_conformal! rejects GmshNative kernel" begin + sm_native = SolidModel("native", SolidModels.GmshNative(); overwrite=true) + cs = CoordinateSystem("dummy", nm) + place!(cs, Rectangle(Point(0.0μm, 0.0μm), Point(1.0μm, 1.0μm)), :l1) + @test_throws ErrorException render_conformal!(sm_native, cs) + gmsh.finalize() + end +end From a740f37e391eb90b59771547bb25c54a9a31c087 Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Wed, 15 Jul 2026 17:04:09 +0000 Subject: [PATCH 2/6] ConformalRender: fix Aqua ambiguity + add fragment_backstop kwarg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/solidmodels/conformal/conformal.jl | 61 +++++++++++++++++++++----- test/test_conformal_render.jl | 19 ++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/solidmodels/conformal/conformal.jl b/src/solidmodels/conformal/conformal.jl index 38cf2c72c..d50954381 100644 --- a/src/solidmodels/conformal/conformal.jl +++ b/src/solidmodels/conformal/conformal.jl @@ -46,6 +46,29 @@ geometry). If your postrender operations create new overlapping entities, use - **Per-render cache**: the cache lives on a `ConformalRenderContext` struct passed through calls. There is no global mutable state; nested/parallel renders are safe. + +# Preconditions + +For the cache to produce correct geometry: + + 1. **Shared endpoints ⟹ shared curve geometry.** If two faces share a boundary, + the CurvilinearPolygon points on both sides must resolve to the same OCC + point tags AND the curves spanning those tags must be the same geometric + curve. Callers can meet this by having both faces reference the same + `Paths.Segment` object (the "coordinated recovery" pattern from + `union2d_curved` where both operands are matched against the same + boolean-recovery ProvenanceRun), or by an upstream mutual-noding pass. + 2. **No distinct co-endpoint edges.** The prefer-curve rule fuses curve + requests on shared endpoints into a single OCC tag. If your geometry + genuinely has two distinct edges spanning the same two points (say, a + figure-8 pinch or a self-crossing offset curve), the cache will wrongly + merge them. + 3. **Relaxed vertex merge is safe iff features > 500× the merge tolerance.** + The default 2 nm merge is 500× smaller than a 1 µm minimum feature. If + you have features ~10× smaller, use `ConformalRenderContext(; vertex_merge_atol=…)` to tighten. + +When any of these are uncertain, `render_conformal!(..., fragment_backstop=true)` +runs the stock 3-pass `_fragment_and_map!` after postrender as a safety net. """ module ConformalRender @@ -62,6 +85,7 @@ using ..SolidModels: _add_curve!, _get_or_add_point!, _postrender!, + _fragment_and_map!, _get_or_add_points!, _stp_float, _collect_mesh_control_points!, @@ -360,12 +384,11 @@ function _add_conformal!( end # Broadcast dispatcher — top-level entry from render_conformal!'s metadata loop. +# `render_conformal!` guards `kernel(sm) isa OpenCascade` at entry so we don't +# need a per-primitive GmshNative rejection method here. _add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k; kwargs...) = [_add_conformal!(ctx, el, m, k; kwargs...) for el in els] -_add_conformal!(ctx::ConformalRenderContext, el, m::Meta, k::GmshNative; kwargs...) = - error("ConformalRender is only implemented for OpenCascade kernel; got GmshNative") - # ─── Curve loop assembly ───────────────────────────────────────────────────── # PATCH 3 without the (disabled) short-edge batching from DTP. The batching was @@ -622,15 +645,24 @@ _add_conformal_curve!( context=ConformalRenderContext(), map_meta=layer, postrender_ops=[], retained_physical_groups=[], zmap=(_)->zero(T), gmsh_options=..., skip_postrender=false, - auto_union=false, skip_unused_layers=false, kwargs...) + auto_union=false, skip_unused_layers=false, + fragment_backstop=false, kwargs...) Render `cs` into `sm` using the ConformalRender strategy. Same semantics as -[`render!`](@ref) but without the post-render `_fragment_and_map!` pass. +[`render!`](@ref) but without the post-render `_fragment_and_map!` pass by +default. The `context::ConformalRenderContext` holds the edge/curve cache and merge tolerances; pass an explicit context if you need custom tolerances or want to inspect cache statistics after the render. +`fragment_backstop=true` runs the stock 3-pass `_fragment_and_map!` sequence +after postrender operations. Faces already resolved to a single OCC entity +by the cache pass through as no-ops, so this is safe to combine with +cache-resolved regions. Enable when the input geometry may not fully meet +the "shared endpoints ⟹ shared curve" precondition, or when `postrender_ops` +introduce overlapping entities that need reconciliation. + Not supported on `GmshNative` kernel. """ function render_conformal!( @@ -645,6 +677,7 @@ function render_conformal!( skip_postrender::Bool=false, auto_union::Bool=false, skip_unused_layers::Bool=false, + fragment_backstop::Bool=false, kwargs... ) where {T} kernel(sm) isa OpenCascade || error( @@ -716,11 +749,19 @@ function render_conformal!( _postrender!(sm, postrender_ops) _synchronize!(sm) - # Key difference vs render!: NO _fragment_and_map! pass. The conformal cache - # already deduplicates shared edges during render, and _fragment_and_map! - # would touch OCC entities the cache assumes are stable. If your - # postrender_ops introduce overlapping entities that need reconciliation, - # use render! instead. + # By default, no `_fragment_and_map!` pass: the conformal cache already + # deduplicates shared edges during render. But if the input geometry + # doesn't fully meet the cache's preconditions (see "Failure modes for the + # 'prefer curve' invariant" in the module docstring), or if `postrender_ops` + # introduce overlapping entities that need reconciliation, users can opt + # into the stock 3-pass fragment as a safety net via `fragment_backstop=true`. + # Faces already resolved to a single OCC entity by the cache pass through + # fragment as no-ops, so this is compatible with cache-resolved regions. + if fragment_backstop + _fragment_and_map!(sm, [0, 1]) + _fragment_and_map!(sm, [1, 2]) + _fragment_and_map!(sm, [2, 3]) + end gmsh.model.mesh.setSizeCallback(gmsh_meshsize) diff --git a/test/test_conformal_render.jl b/test/test_conformal_render.jl index 45f143f4a..75f7c69fe 100644 --- a/test/test_conformal_render.jl +++ b/test/test_conformal_render.jl @@ -100,6 +100,25 @@ gmsh.finalize() end + @testset "render_conformal! fragment_backstop kwarg" begin + cs = CoordinateSystem("bs", nm) + place!(cs, Rectangle(Point(0.0μm, 0.0μm), Point(10.0μm, 10.0μm)), :l1) + place!(cs, Rectangle(Point(10.0μm, 0.0μm), Point(20.0μm, 10.0μm)), :l1) + + # Default: no backstop + sm1 = SolidModel("bs_default"; overwrite=true) + render_conformal!(sm1, cs) + @test hasgroup(sm1, "l1", 2) + + # With backstop: same result (backstop runs as no-op when the cache + # already produced conformal geometry). + sm2 = SolidModel("bs_on"; overwrite=true) + render_conformal!(sm2, cs; fragment_backstop=true) + @test hasgroup(sm2, "l1", 2) + + gmsh.finalize() + end + @testset "render_conformal! rejects GmshNative kernel" begin sm_native = SolidModel("native", SolidModels.GmshNative(); overwrite=true) cs = CoordinateSystem("dummy", nm) From c449c4c53167fe9a82e7ffead7b37498c9b2c0aa Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Wed, 15 Jul 2026 19:14:08 +0000 Subject: [PATCH 3/6] ConformalRender: expand test coverage for curve primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- test/test_conformal_render.jl | 134 +++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/test/test_conformal_render.jl b/test/test_conformal_render.jl index 75f7c69fe..b713365af 100644 --- a/test/test_conformal_render.jl +++ b/test/test_conformal_render.jl @@ -8,7 +8,8 @@ coordinatetype, onenanometer, ClippedPolygon, - difference2d + difference2d, + Paths using DeviceLayout.Polygons: Rounded using DeviceLayout.Curvilinear: CurvilinearPolygon, CurvilinearRegion using DeviceLayout.SolidModels: @@ -126,4 +127,135 @@ @test_throws ErrorException render_conformal!(sm_native, cs) gmsh.finalize() end + + @testset "add_conformal_loop! with Paths.Turn (short arc)" begin + # Direct exercise of _add_conformal_curve!(Paths.Turn) — a 90° arc. + sm = SolidModel("turn_short"; overwrite=true) + k = kernel(sm) + ctx = ConformalRenderContext() + import SpatialIndexing + points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) + + R = 100.0μm + pp = [ + Point(0.0μm, 0.0μm), + Point(R, 0.0μm), + Point(0.0μm, R) + ] + turn = Paths.Turn(90°, R, α0=90°, p0=pp[2]) + cp = CurvilinearPolygon(pp, [turn], [2]) + loop = add_conformal_loop!(ctx, cp, k, 0.0μm; points_tree) + @test loop isa Integer + gmsh.finalize() + end + + @testset "add_conformal_loop! with Paths.Turn (large arc)" begin + # Turn with |α| >= 180° triggers the multi-segment arc path. + sm = SolidModel("turn_large"; overwrite=true) + k = kernel(sm) + ctx = ConformalRenderContext() + import SpatialIndexing + points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) + + R = 50.0μm + pp = [ + Point(0.0μm, 0.0μm), + Point(R, 0.0μm), + Point(-R, 0.0μm) + ] + turn = Paths.Turn(180°, R, α0=90°, p0=pp[2]) + cp = CurvilinearPolygon(pp, [turn], [2]) + loop = add_conformal_loop!(ctx, cp, k, 0.0μm; points_tree) + @test loop isa Integer + gmsh.finalize() + end + + @testset "add_conformal_loop! with Paths.BSpline" begin + # Exercise _add_conformal_curve!(Paths.BSpline). + sm = SolidModel("bspline"; overwrite=true) + k = kernel(sm) + ctx = ConformalRenderContext() + import SpatialIndexing + points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) + + pp = [ + Point(0.0μm, 0.0μm), + Point(100.0μm, 0.0μm), + Point(100.0μm, 100.0μm), + Point(0.0μm, 100.0μm) + ] + spline_pts = [pp[2], Point(150.0μm, 50.0μm), pp[3]] + t0 = Point(1.0μm, 0.0μm) + t1 = Point(-1.0μm, 0.0μm) + seg = Paths.BSpline(spline_pts, t0, t1) + cp = CurvilinearPolygon(pp, [seg], [2]) + loop = add_conformal_loop!(ctx, cp, k, 0.0μm; points_tree) + @test loop isa Integer + gmsh.finalize() + end + + @testset "render_conformal! with CurvilinearRegion (rounded rect)" begin + # A Rounded(Rectangle) renders to a CurvilinearRegion — this exercises + # _add_conformal!(CurvilinearRegion) and CurvilinearRegion's + # exterior+holes assembly path. + cs = CoordinateSystem("rounded", nm) + rect = Rectangle(Point(0.0μm, 0.0μm), Point(50.0μm, 30.0μm)) + place!(cs, Rounded(5.0μm)(rect), :l1) + + sm = SolidModel("cvr"; overwrite=true) + render_conformal!(sm, cs) + @test hasgroup(sm, "l1", 2) + # A rounded rectangle should produce a single surface with curved edges. + @test length(gmsh.model.occ.getEntities(2)) >= 1 + gmsh.finalize() + end + + @testset "render_conformal! with ClippedPolygon (holes)" begin + # Exercise CurvilinearRegion's `holes` path via a ClippedPolygon. + cs = CoordinateSystem("holed", nm) + outer = Rectangle(Point(0.0μm, 0.0μm), Point(100.0μm, 100.0μm)) + inner = Rectangle(Point(30.0μm, 30.0μm), Point(70.0μm, 70.0μm)) + clipped = difference2d(outer, inner) + place!(cs, clipped, :l1) + + sm = SolidModel("holed"; overwrite=true) + render_conformal!(sm, cs) + @test hasgroup(sm, "l1", 2) + gmsh.finalize() + end + + @testset "render_conformal! preserves shared vertex identity" begin + # Two adjacent rectangles → the shared edge should resolve to a single + # OCC edge tag. If dedup works, the total edge count is < 8 (would be + # 8 if the shared edge duplicated). + cs = CoordinateSystem("shared", nm) + place!(cs, Rectangle(Point(0.0μm, 0.0μm), Point(10.0μm, 10.0μm)), :l1) + place!(cs, Rectangle(Point(10.0μm, 0.0μm), Point(20.0μm, 10.0μm)), :l1) + + ctx = ConformalRenderContext() + sm = SolidModel("shared"; overwrite=true) + render_conformal!(sm, cs; context=ctx) + # Each rect contributes 4 edges; a duplicated shared edge would give 8. + # With dedup, the shared edge is 1, so total = 7. + @test length(gmsh.model.occ.getEntities(1)) == 7 + # The cache should have registered at least one hit for the shared edge. + @test ctx.stats[:hits] >= 1 + gmsh.finalize() + end + + @testset "render_conformal! zmap positions surfaces at nonzero z" begin + # Exercise the zmap kwarg on render_conformal!. + cs = CoordinateSystem("zmap", nm) + place!(cs, Rectangle(Point(0.0μm, 0.0μm), Point(10.0μm, 10.0μm)), :l1) + + sm = SolidModel("zmap"; overwrite=true) + z_target = 5.0μm + render_conformal!(sm, cs; zmap=(_) -> z_target) + @test hasgroup(sm, "l1", 2) + # Bounding-box z should reflect the zmap. + _, _, zmin, _, _, zmax = gmsh.model.occ.getBoundingBox(2, gmsh.model.occ.getEntities(2)[1][2]) + @test isapprox(zmin, ustrip(SolidModels.STP_UNIT, z_target); atol=1e-6) + @test isapprox(zmax, ustrip(SolidModels.STP_UNIT, z_target); atol=1e-6) + gmsh.finalize() + end end From e5bca8b043851d314692f1085b920ef485cfc525 Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Wed, 15 Jul 2026 21:25:32 +0000 Subject: [PATCH 4/6] ConformalRender: apply JuliaFormatter to test file Line-wrap cosmetics only; no behavior changes. --- test/test_conformal_render.jl | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/test/test_conformal_render.jl b/test/test_conformal_render.jl index b713365af..de171b741 100644 --- a/test/test_conformal_render.jl +++ b/test/test_conformal_render.jl @@ -137,11 +137,7 @@ points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) R = 100.0μm - pp = [ - Point(0.0μm, 0.0μm), - Point(R, 0.0μm), - Point(0.0μm, R) - ] + pp = [Point(0.0μm, 0.0μm), Point(R, 0.0μm), Point(0.0μm, R)] turn = Paths.Turn(90°, R, α0=90°, p0=pp[2]) cp = CurvilinearPolygon(pp, [turn], [2]) loop = add_conformal_loop!(ctx, cp, k, 0.0μm; points_tree) @@ -158,11 +154,7 @@ points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) R = 50.0μm - pp = [ - Point(0.0μm, 0.0μm), - Point(R, 0.0μm), - Point(-R, 0.0μm) - ] + pp = [Point(0.0μm, 0.0μm), Point(R, 0.0μm), Point(-R, 0.0μm)] turn = Paths.Turn(180°, R, α0=90°, p0=pp[2]) cp = CurvilinearPolygon(pp, [turn], [2]) loop = add_conformal_loop!(ctx, cp, k, 0.0μm; points_tree) @@ -253,7 +245,8 @@ render_conformal!(sm, cs; zmap=(_) -> z_target) @test hasgroup(sm, "l1", 2) # Bounding-box z should reflect the zmap. - _, _, zmin, _, _, zmax = gmsh.model.occ.getBoundingBox(2, gmsh.model.occ.getEntities(2)[1][2]) + _, _, zmin, _, _, zmax = + gmsh.model.occ.getBoundingBox(2, gmsh.model.occ.getEntities(2)[1][2]) @test isapprox(zmin, ustrip(SolidModels.STP_UNIT, z_target); atol=1e-6) @test isapprox(zmax, ustrip(SolidModels.STP_UNIT, z_target); atol=1e-6) gmsh.finalize() From aef9bf609e6450019c1c6d9c096576c68b87c22c Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Wed, 15 Jul 2026 22:26:49 +0000 Subject: [PATCH 5/6] ConformalRender: extend prefer-curve invariant to splines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- src/solidmodels/conformal/conformal.jl | 17 +++++++- test/test_conformal_render.jl | 54 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/solidmodels/conformal/conformal.jl b/src/solidmodels/conformal/conformal.jl index d50954381..9a01d306c 100644 --- a/src/solidmodels/conformal/conformal.jl +++ b/src/solidmodels/conformal/conformal.jl @@ -265,14 +265,18 @@ function _cached_add_arc!( return tag end -# Add an interpolating BSpline. Deduped by exact control-net; also unified with -# its reversal. +# Add an interpolating BSpline. Deduped by exact control-net; unified with its +# reversal; and participates in the prefer-curve invariant via +# `endpoint_curve_index` — a later request for a line (or arc) on the same +# endpoints will reuse this spline instead of creating a duplicate edge. function _cached_add_spline!( k, ctx::ConformalRenderContext, pts::Vector{<:Integer}, tangents ) + p1, p2 = pts[1], pts[end] + lo, hi = minmax(p1, p2) key = (:bspline, pts...) existing = get(ctx.curve_cache, key, nothing) if existing !== nothing @@ -285,10 +289,19 @@ function _cached_add_spline!( ctx.stats[:hits] += 1 return -existing_r end + # Any curve already on these endpoints → reuse. Handles a spline requested + # after an arc (or the reverse) on the same shared boundary. + existing_curve = get(ctx.endpoint_curve_index, (lo, hi), nothing) + if existing_curve !== nothing + ctx.stats[:hits] += 1 + return p1 < p2 ? existing_curve : -existing_curve + end ctx.stats[:misses] += 1 ctx.stats[:splines] += 1 tag = k.addSpline(pts, -1, tangents) + signed_tag = p1 < p2 ? tag : -tag ctx.curve_cache[key] = tag + ctx.endpoint_curve_index[(lo, hi)] = signed_tag return tag end diff --git a/test/test_conformal_render.jl b/test/test_conformal_render.jl index de171b741..246e05125 100644 --- a/test/test_conformal_render.jl +++ b/test/test_conformal_render.jl @@ -251,4 +251,58 @@ @test isapprox(zmax, ustrip(SolidModels.STP_UNIT, z_target); atol=1e-6) gmsh.finalize() end + + @testset "prefer-curve invariant: arc then line on shared endpoints" begin + # After an arc on endpoints (a, b), a line request on the same endpoints + # must reuse the arc tag (up to sign), not create a duplicate straight + # edge — otherwise a shared boundary between an arc-side and a + # line-side face is non-conformal. + sm = SolidModel("prefer_curve_arc_line"; overwrite=true) + k = kernel(sm) + ctx = ConformalRenderContext() + import SpatialIndexing + points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) + + R = 100.0μm + pp = [Point(0.0μm, 0.0μm), Point(R, 0.0μm), Point(0.0μm, R)] + turn = Paths.Turn(90°, R, α0=90°, p0=pp[2]) + cp_arc = CurvilinearPolygon(pp, [turn], [2]) + add_conformal_loop!(ctx, cp_arc, k, 0.0μm; points_tree) + + # Straight-edge polygon that shares the (pp[2], pp[3]) endpoints. It + # would ordinarily be a chord, but the cache should return the arc tag. + hits_before = ctx.stats[:hits] + line_cp = CurvilinearPolygon(pp) # no curve — pure line loop + add_conformal_loop!(ctx, line_cp, k, 0.0μm; points_tree) + @test ctx.stats[:hits] > hits_before # arc was reused for a line request + gmsh.finalize() + end + + @testset "prefer-curve invariant: spline then line on shared endpoints" begin + sm = SolidModel("prefer_curve_spline_line"; overwrite=true) + k = kernel(sm) + ctx = ConformalRenderContext() + import SpatialIndexing + points_tree = SpatialIndexing.RTree{Float64, 3}(Int32) + + pp = [ + Point(0.0μm, 0.0μm), + Point(100.0μm, 0.0μm), + Point(100.0μm, 100.0μm), + Point(0.0μm, 100.0μm) + ] + spline_pts = [pp[2], Point(150.0μm, 50.0μm), pp[3]] + seg = Paths.BSpline(spline_pts, Point(1.0μm, 0.0μm), Point(-1.0μm, 0.0μm)) + cp_spline = CurvilinearPolygon(pp, [seg], [2]) + add_conformal_loop!(ctx, cp_spline, k, 0.0μm; points_tree) + + hits_before = ctx.stats[:hits] + line_cp = CurvilinearPolygon(pp) # straight (pp[2], pp[3]) + add_conformal_loop!(ctx, line_cp, k, 0.0μm; points_tree) + # If the spline registered itself in endpoint_curve_index, the later + # line request hits — otherwise a duplicate straight edge is created + # and the shared boundary is non-conformal. + @test ctx.stats[:hits] > hits_before + gmsh.finalize() + end end From 4fcd5edb3ac70bc6593e5b273310fb8a3ff22e92 Mon Sep 17 00:00:00 2001 From: Bright Ye Date: Wed, 15 Jul 2026 22:29:12 +0000 Subject: [PATCH 6/6] ConformalRender: rewrite section-header comments to describe intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/solidmodels/conformal/conformal.jl | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/solidmodels/conformal/conformal.jl b/src/solidmodels/conformal/conformal.jl index 9a01d306c..2831845db 100644 --- a/src/solidmodels/conformal/conformal.jl +++ b/src/solidmodels/conformal/conformal.jl @@ -326,9 +326,10 @@ _add_conformal!( kwargs... ) -# CurvilinearRegion: single add_plane_surface call with hole loops (PATCH 1). -# Stock DL creates outer surface, then per-hole surface, then k.cut() each hole. -# The multi-loop form is one OCC call and preserves shared points naturally. +# CurvilinearRegion: single add_plane_surface call with hole loops. Stock +# render! creates the outer surface, then a surface per hole, then k.cut()s +# each hole. The multi-loop form is one OCC call and preserves shared points +# naturally, which is what we need for conformal rendering. function _add_conformal!( ctx::ConformalRenderContext, surf::CurvilinearRegion{T}, @@ -404,9 +405,6 @@ _add_conformal!(ctx::ConformalRenderContext, els::AbstractVector, m::Meta, k; kw # ─── Curve loop assembly ───────────────────────────────────────────────────── -# 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. """ add_conformal_loop!(ctx::ConformalRenderContext, cl::CurvilinearPolygon, k::OpenCascade, z; points_tree=nothing, atol=onenanometer(...)) @@ -495,7 +493,8 @@ end # ─── Curve dispatch (arcs, BSplines, offsets) ──────────────────────────────── -# PATCH 4a: exact circular arc, cached, with strict-tolerance center. +# Circular arc: exact, cached by (endpoints, center) with strict-tolerance +# center dedup so a Turn traversed from both sides collapses to one OCC entity. function _add_conformal_curve!( ctx::ConformalRenderContext, endpoints, @@ -549,8 +548,9 @@ function _add_conformal_curve!( end end -# PATCH 4b: exact interpolating BSpline, cached, with strict-tolerance -# intermediate control points. +# Interpolating BSpline: exact, cached by control-net; strict tolerance on +# intermediate control points so a spline traversed from both sides collapses +# to one OCC entity. function _add_conformal_curve!( ctx::ConformalRenderContext, endpoints, @@ -582,10 +582,10 @@ function _add_conformal_curve!( return _cached_add_spline!(k, ctx, pts, tangents) end -# PATCH 4c: offset segments — constant offset of a Turn is still a circular -# arc (exact); general offset (BSpline or variable) is approximated by a -# BSpline chain with join points at the RELAXED tolerance to unify sub-splines -# produced from opposite traversal directions. +# Offset segment: a constant offset of a Turn is still a circular arc (exact); +# general offset (variable, or offset of a BSpline) is approximated by a +# BSpline chain with join points at the RELAXED tolerance so sub-splines +# produced from opposite traversal directions unify. function _add_conformal_curve!( ctx::ConformalRenderContext, endpoints,