FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Add cross-coplanar and material/style-change edge classification by sboddy · Pull Request #9330 · IfcOpenShell/IfcOpenShell · GitHub

Add cross-coplanar and material/style-change edge classification - #9330

Open
sboddy wants to merge 63 commits into
IfcOpenShell:v0.8.0from
sboddy:fix-3742-cross-coplanar-classification
Open

Add cross-coplanar and material/style-change edge classification#9330
sboddy wants to merge 63 commits into
IfcOpenShell:v0.8.0from
sboddy:fix-3742-cross-coplanar-classification

Conversation

sboddy commented Aug 19, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Add cross-coplanar & material/style-change SVG edge classification (#3742)

Summary

Extends the existing single-object SVG edge classification system (#3668,
5 classes: outline/crease/valley/ridge/boundary) to also handle edges
shared between different touching elements. Previously, every product's
edges were classified independently, which produces two visible problems
in generated drawings:

  1. Two elements with a genuinely coincident, matching surface (e.g. two
    walls butting together) each draw their own copy of the shared boundary
    edge -- correct individually, but a visible double-line once any
    transparency is applied.
  2. Where two coincident surfaces belong to elements of different
    material or style, or where a single element's own section-cut face
    spans more than one material layer, there was previously no way to
    mark that boundary as anything other than an ordinary edge -- so a
    material change at a shared or internal surface was invisible in the
    drawing.

This PR adds two new edge classes computed directly on the real 3D BRep
geometry (before HLR projection, not as an SVG post-process) --
cross-coplanar and mat-style-change -- plus an opt-in duplicate-edge
merge pass for drawings where two classes end up needing to be drawn at
the same location, a run of correctness/robustness fixes found while
building and reviewing this, and two performance fixes for larger
drawings.

Key changes

New edge classification

  • cross-coplanar: reclassifies whatever portion of an edge lies on
    a coincident, same-material/style face of a different product -- the
    genuinely-duplicate-boundary case above. Matching works at sub-edge
    granularity: a partial overlap, or several products contributing to
    different sections of the same edge, is split and classified per
    sub-range rather than requiring the entire edge to match. Off by
    default for rendering (svg-render-cross-coplanar-edges); the
    underlying match pass can also gate other classification behaviour
    independently (svg-use-cross-coplanar-classification).
  • mat-style-change: covers the material/style-change case, in two
    situations -- two different products with a genuinely coincident face
    but differing material/style (cross-product), and a single product's
    own face spanning more than one layer of its material stack, e.g. a
    section cut through a wall (intra-product). Off by default
    (svg-use-mat-style-change-classification), since it visibly
    reclassifies existing linework.
  • Both are wired through the same Bonsai property/EPset/UI pattern
    already used for the existing classification system, and are fully
    additive: with both toggles off, output is byte-identical to current
    behaviour.

Duplicate-edge merge post-process (opt-in)

A new, bpy-free post-process (svg_dedup.py) that clusters same-class
<path> elements by a tolerant parametric collinear-overlap test and
merges/trims duplicates down to one edge per cluster, operating on the
emitted SVG paths themselves rather than the 3D classification logic.
Off by default; only takes effect alongside edge classification.

Correctness & robustness fixes

A number of pre-existing and newly-introduced classification bugs were
found and fixed along the way -- sign-test/convexity errors, occlusion
and hidden-edge-restoration edge cases, degenerate-geometry guards
against real-world OCCT failures, and several narrower regressions
introduced and caught within this same branch's own review process.
These are individually small; the commit log is the source of truth for
what each one covers rather than repeating them here.

Performance

Two independent hot-path fixes for larger drawings, both measured against
a real architectural project file rather than synthetic data:

  • Scoped is_genuinely_occluded()'s ray-cast to a view-direction-aware
    shortlist instead of testing against every loaded object in the
    drawing -- 3.8-5.8x speedup on axis-aligned views (isometric/oblique
    views benefit less, a known geometric limitation of the shortlist
    shape).
  • Multi-threaded restore_coincident_hidden_edges()'s own per-candidate
    work, which became the next dominant cost once the above landed.

Both are verified byte-identical in output to the pre-optimization
baseline -- pure performance work, no behaviour change.

Minor

  • Edge classification / cross-coplanar / mat-style-change UI is now
    hidden when Freestyle linework mode is active, since the two systems
    aren't meant to be used together.

Testing

  • New dedicated regression suite covering the classification logic
    (test_svg_edge_classification.py) and the dedup post-process
    (test_svg_dedup.py), both plain pytest, no Blender required for the
    classification suite.
  • Every new toggle verified byte-identical to current behaviour when off.
  • Verified against real architectural project files (not just synthetic
    fixtures) at multiple points during development, including a full
    before/after A/B on real drawings for the performance work.

Known limitations / out of scope

  • Cross-coplanar/mat-style-change classification currently only
    understands IfcMaterialLayerSetUsage (layered elements like walls and
    slabs) -- IfcMaterialProfileSetUsage (structural members) and
    IfcMaterialConstituentSet aren't covered yet.
  • Like the rest of the edge classification system, this requires planar
    faces and straight edges -- curved geometry is gracefully skipped, not
    a crash.

AI-assisted development

This contribution was developed with the assistance of an AI coding tool
(Claude); every commit's own message discloses this in its body per
AGENTS.md.

A word from the human

I developed the original edge classification work which was entirely done
with AI. Likewise, this is entirely produced by AI with my guidance and
understanding (and yes, sometimes my frustration when I couldn't get
Claude to understand my points.)

Lots of visual inspections by me, and I'm pretty happy with the robustness.
Like anything this sensitive though, there may be things I didn't catch. It
is entirely opt-in though, so it will not break your current options.

I'd really appreciate some feedback on whether this works well for people
and, if not, small reproduction files that I can troubleshoot.

Note that you will need to update your projects css files to take full
advantage. I have included two sets of path.* css rules. One set are for
debug, one set are a first guess at what categories of lines should look
like. A professional set of css rules would be appreciated.

I've included the synthetic test files from @theoryshaw (hope he doesn't
mind) which includes a full set of the drawings so you can see better
than the above screenshot.

One last thing. I realise this is made on top of v0.8.0. I just want to get
it pushed. I will pull v0.9.0 and see if I can merge this PR into it. It
should be possible as the original edge classification was merged, and this
simply extends it.

sboddy and others added 30 commits August 19, 2026 02:51
Extends the existing single-object 5-class SVG edge classification
system (issue IfcOpenShell#3668) with a 6th class: an edge is "cross-coplanar"
when its entire length lies on a coincident, same-style/material face
of a *different* product -- a duplicate boundary between two
elements' coincident surfaces, as opposed to a fold within one
element's own geometry.

This replaces the Python-side approach (join_coplanar_boundary_lines,
operator.py) that this branch previously iterated on across several
rounds of regressions -- all tracing to the same root cause: once two
objects were linked as "in the same cluster" for merging, every edge
of both became fair game for one shared merge decision, so a narrow,
even correct, reason to link two objects ended up entangling their
entire boundaries. Working at per-edge granularity on real, exact,
un-projected 3D BRep faces (before HLR ever draws them) is structurally
immune to that failure mode: linking two objects can never affect an
edge that isn't itself genuinely coincident.

Implementation:
- New settings (ConversionSettings.h): SvgUseCrossCoplanarClassification
  (master enable, only takes effect alongside SvgUseEdgeClassification),
  SvgRenderCrossCoplanarEdges (emit toggle, default off), and
  SvgCrossCoplanarTolerance.
- SvgSerializer::write(BRepElement*) resolves one representative
  style/material identity per product (geometry_data::
  cross_coplanar_style_instance) -- deliberately per-product rather
  than per-face/per-layer, since per-item shapes are transformed and
  concatenated away by the time compound_local exists
  (IfcGeom::Representation::BRep::as_compound()).
- prefiltered_hlr::items_ now carries that style alongside each
  product's shape (product_shape_list_t).
- New prefiltered_hlr::find_cross_coplanar_matches(), run once at the
  start of build() (every product for the drawing/storey is already
  present in items_ by then): bbox-filtered pairwise face comparison
  (same style identity, parallel normals, coincident planes via
  BRepAlgoAPI_Common), classifying an edge as cross-coplanar only when
  its *entire* length lies in the overlap region -- a partially
  -overlapping edge is left untouched (v1 scope; true sub-edge
  splitting is a documented follow-up, not implemented here). Matched
  edges are removed from whatever base-class bucket they were already
  in (so an edge is never double-classified/drawn twice), then
  optionally re-added under the new class if
  render_cross_coplanar_edges_ is on.
- Bonsai wiring (prop.py/ui.py/tool/drawing.py/operator.py) mirrors the
  existing issue IfcOpenShell#3668 property/EPset/UI pattern exactly.

Verified via docker/ifcos_env (IfcConvert + the Python wrapper):
toggle off is byte-identical to the existing baseline on the synthetic
test model across all 4 drawings; toggle on removes 44 genuine
duplicate edges across those drawings with zero false additions, while
every previously-established protected case (notch, self-fold,
full-duplicate pairs, false-positive pairs, the residual-sliver case,
the contained-plug case) is completely unaffected. Against the real
project's EXISTING EAST ELEVATION (855 elements), 726 genuine
duplicates are removed with zero false additions, and the originally-
reported corner case (a return wall end-on to the camera,
coincidentally touching a perpendicular wall's broad face) is
correctly left at its true minimal boundary rather than over-merged,
because the new mechanism never makes that mistake in the first place.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The face-area-overlap test (BRepAlgoAPI_Common) used to decide whether two
coplanar faces on different products should be matched was wrong for the
majority of real "duplicate boundary" cases: two adjacent, non-overlapping
faces (side-by-side slabs, a wall's void boundary against a plug's outer
boundary) have zero area in common by construction, so the test silently
rejected exactly the cases this feature exists to find. Replaced with a
direct edge-to-edge coincidence test (collinearity + interval union over
each face's boundary edges, including inner wires so void/hole boundaries
are covered), operating on real 3D BRep edges.

Also added material identity as a first-class, higher-priority comparison
key alongside style: if either product resolves a material, both sides
must resolve to the same material (style is not consulted); style is only
compared when neither side has one. Previously two products sharing a
rendering style but made of genuinely different materials could be wrongly
matched. Material is resolved via a new schema-agnostic helper mirroring
mapping::get_single_material_association()'s simple cases (a direct
IfcMaterial, or the first layer of an IfcMaterialLayerSet/-SetUsage,
regardless of how many layers the set has).

Flipped svg-use-cross-coplanar-classification's default to on (still only
takes effect when svg-use-edge-classification is also enabled).

Known limitations, to be addressed in follow-up rounds:
- Material/style is resolved once per product, not per face/layer. A
  multi-layer element (e.g. two layers, only one of which matches a
  neighbour's material) can produce a false positive on the non-matching
  layer's edge, since the single per-product material identity gates all
  of that product's face pairs uniformly. Will be resolved in v2 by
  resolving material per face instead of per product.
- Only whole-edge coincidence is matched; an edge partially, but not
  fully, covered by a neighbour's edge is left entirely unclassified.
  Will be resolved in v3 by supporting sub-edge (partial interval)
  splitting.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Material/style was still resolved once per product ("whichever layer
resolves first"), so two products sharing a first-resolved layer material
but differing in another layer (e.g. both have a Corten 1 layer, but one's
other layer is Concrete 1 and the other's is Concrete 2) got every
coincident face pair between them classified cross-coplanar, including the
boundary where the differing layer actually touches.

Real geometric layer-splitting (enable-layerset-slicing /
AbstractKernel::apply_layerset) turned out to be unimplemented for the OCCT
kernel -- it throws not_implemented_error() and nothing overrides it, dead
by default since Bonsai never enables the setting. Rather than building out
that currently-nonexistent kernel feature, this resolves per-face material
via a lookup instead: IfcMaterialLayerSetUsage's own LayerSetDirection/
DirectionSense/OffsetFromReferenceLine/MaterialLayers attributes, combined
with the shape's own measured bounding-box extent along that axis to
calibrate the raw, unscaled layer thicknesses. A face's centroid is
projected onto the layering axis and binary-searched into the right layer,
giving that face's real material with a single dot product and no
geometry splitting, unit-scale assumption, or kernel changes required.

find_cross_coplanar_matches() now defers the material/style gate to this
per-face-pair resolution whenever either product is layered, falling back
to the existing whole-product gate unchanged for non-layered products.

Verified against the reported case (two layered slabs sharing a Corten 1
layer but differing in Concrete 1 vs Concrete 2): the Corten boundary now
matches, the Concrete boundary correctly doesn't. Full regression sweep
across all 4 synthetic drawings remains purely subtractive vs. baseline
(zero unexpected paths) with a few more genuine duplicates now caught
(cases with identical 2-layer stacks now match both layer boundaries, not
just one); Real-world project scratch check unchanged (2285/1546,
0 unexpected additions) -- no matching false-positive scenario existed
there, so this is a clean no-op as expected.

Still deferred: sub-edge/partial-coincidence splitting (v3), and
non-layer-set multi-material constructs (IfcMaterialProfileSet,
IfcMaterialConstituentSet).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The v2 per-face material lookup resolved a single material for a face via
its centroid, which is only representative when that face is confined to
one layer. A face that itself spans more than one layer (e.g. two visible
faces of a layered slab/wall, or a face straddling an internal layer
boundary) had its *entire* overlap with a neighbour judged by that one
centroid sample: two products sharing one layer's material (e.g. both
Corten 1) but differing in another (Concrete 1 vs Concrete 2) got every
coincident face pair between them classified uniformly, including the
boundary where the differing layer actually touches, and could also wrongly
reject a genuinely-matching sub-range elsewhere on the same face.

Pushes the decision down to accumulate_edge_coverage()'s own raw geometric
overlap intervals: each interval is now split at every layer boundary
either side contributes (new layer_boundary_ts() helper, projecting a
layer_projection's internal boundaries onto the candidate edge's own
t-axis), and each resulting sub-range is independently verified at its own
midpoint via a new resolve_material_at_point() helper before being accepted
into the edge's accumulated coverage. A sub-range where materials genuinely
agree is kept even when a neighbouring sub-range of the same edge disagrees,
and vice versa. The coarse per-face-pair centroid gate in
find_cross_coplanar_matches() is removed entirely for the layered case,
deferring to this per-sub-interval check; the non-layered fast path is
unchanged. When neither side is layered, or a candidate edge doesn't cross
a layer boundary at all (e.g. a horizontal edge on a vertically-layered
slab), this degenerates to exactly one pass over the whole interval --
functionally identical to the v2 behaviour for every case that isn't
affected by this bug.

Verified via docker/ifcos_env: the reported case (two layered slabs
sharing a Corten 1 backing layer but differing in Concrete 1 vs Concrete 2
on their visible face) now correctly splits -- the Corten boundary
matches, the Concrete boundary is correctly rejected -- while the same
pair's other, already-correct face is unaffected. Full 4-drawing synthetic
regression sweep plus a real-world project (EXISTING EAST ELEVATION) sweep
confirm every previously-established case (donut, angled, both
layered/non-layered "extrusions" pairs, the tessellated different-material
pair, the complex-wall partial-split case) remains correct, with
additional genuine sub-interval splits now correctly applied to other
pairs sharing this same "matches on one layer, differs on another" shape
(a known shallow-angle sliver pair, and a 4-wall corner cluster) --
confirmed via each pair's actual IfcMaterialLayerSetUsage data, not just
geometry.

This branch carries over only the three C++ cross-coplanar commits from
fix-3742-coplanar-svgfill-arrangement (cherry-picked cleanly onto current
upstream v0.8.0, zero conflicts) plus this fix, deliberately leaving behind
that branch's earlier, since-superseded Python-side
join_coplanar_boundary_lines approach.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The now-superseded Python join_coplanar_boundary_lines had a
MIN_VISIBLE_LENGTH = 0.1mm filter to drop degenerate sub-visual line
segments before writing them to SVG. No equivalent existed anywhere in
the current C++ pipeline, so floating-point noise (most visibly from the
cross-coplanar sub-edge-splitting work, where two independently-computed
cut points that are conceptually "the same" land a fraction of a
millimetre apart, but also from ordinary HLR/edge-classification output
unrelated to that feature) was reaching the final SVG as visible dots.

Added the filter at the single choke point every edge passes through on
its way to becoming SVG markup: SvgSerializer::write(path_object&, const
TopoDS_Shape&, ...)'s per-edge loop. Skips the rest of the loop body
entirely for a degenerate edge, without touching first/first_wire
bookkeeping or bounding-box growth -- the next qualifying edge in the
same wire naturally becomes the wire's effective start, and the existing
`if (!path.empty())` guard already handles an entirely-degenerate wire
producing no output at all.

Getting the units right needed care: p1/p2 at this point are in
model-space metres, not the final paper-space mm shown in the SVG -- a
separate, deferred pass (SvgSerializer::resize()) only applies the
scale_ * 1000 conversion afterwards, once the whole drawing's bounding
box is known, by patching every already-buffered coordinate in place. An
initial version of this fix wrongly compared the 0.1 threshold directly
against raw metre-distances, which a full regression sweep caught before
being reported as done: it silently deleted real geometry up to ~2.8mm of
paper length in the synthetic test model. Fixed by reading scale_ (set
earlier in the pipeline from the drawing's own EPset_Drawing.Scale, so
already available at this point for the common explicit-scale case) and
converting the edge's model-space distance to the same paper-space mm
units the threshold is expressed in. When no explicit scale is set
(resize()'s rarer auto-fit-to-page mode), the filter is skipped rather
than guessed at, since the conversion factor genuinely isn't known yet.

Verified via docker/ifcos_env: every one of the specifically-reported dot
coordinates confirmed gone from NORTH SECTION. Full 4-drawing synthetic
regression sweep plus a real-world project file's EXISTING EAST ELEVATION
are purely subtractive versus the pre-fix baseline, zero unexpected
additions, and nothing removed exceeds ~0.03mm (well under the 0.1mm
threshold) in any drawing -- 207 genuine dots removed in that real-world
elevation alone, with no legitimate geometry touched.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a new "mat-style-change" edge classification, additive to the existing
cross-coplanar work, covering two cases where two coincident/coplanar
surfaces differ in material or style rather than matching:

- Case A (cross-product): two different products have genuinely coplanar,
  coincident faces, but their material/style differ. Previously silently
  left in whatever default classification the edge fell into. Reuses
  cross-coplanar's own coincidence-matching scaffolding
  (accumulate_mismatch_coverage(), a deliberate structural duplicate of
  accumulate_edge_coverage() so a future change to the match path can never
  accidentally affect the mismatch path or vice versa), accepting a
  sub-range precisely when both sides resolve a material/style identity
  and they differ.
- Case B (intra-product): a single product's own face spans more than one
  layer of its own IfcMaterialLayerSetUsage (e.g. a section-cut face
  through a wall revealing its full material stack). Constructs new
  interior edges at each internal layer boundary the face's own extent
  crosses (layer_boundary_edges_for_face()), clipped to the face's true
  outline via the face's native (u,v) frame and BRepTopAdaptor_FClass2d.

Wired up behind a new, off-by-default setting
(SvgUseMatStyleChangeClassification / "svg-use-mat-style-change-classification",
default false since it visibly reclassifies existing linework) with
matching Bonsai property/UI/operator plumbing.

Also fixes two bugs found while reviewing the new feature against real
drawings:

1. classify_edge_from_faces()'s convex/concave sign test used only the
   first off-edge vertex of a face, with no fallback if that vertex
   happened to be near-coplanar (an unreliable, near-zero-dot sample). For
   a roof-type geometry with several near-coplanar triangulated vertices
   near a ridge/hip edge, an unlucky first vertex could flip the sign,
   which -- combined with the deliberately asymmetric ridge (45deg) vs
   valley (12deg) thresholds -- misclassified a genuinely sharp ridge as
   crease. Now scans every off-edge vertex and keeps the one with the
   largest |dot|, the most decisive/least ambiguous sample; behaviour-
   preserving for every edge where the first vertex was already decisive.

2. The cross-coplanar/mat-style-change matching in
   find_cross_coplanar_matches() validated a candidate face pair (A1/B1)
   purely on whether *their own* normals were parallel/antiparallel and
   coplanar. It never checked whether the faces beyond that boundary edge
   on each side -- A2 (product A's other real adjacent face at the edge)
   and B2 (product B's counterpart) -- were themselves consistent. A1/B1
   being a genuine, exact touching boundary doesn't imply A2/B2 are: two
   wedge-shaped slabs sitting flush side by side (confirmed coincident to
   ~1.7e-7 world units) can still have their own top surfaces tilted a
   couple of degrees apart, in which case the shared boundary edge marks a
   real discontinuity in the outer surface, not a hideable duplicate seam.
   Fixed by additionally requiring A2/B2 to pass the same coplanar test
   already used for A1/B1 (now a single named constant,
   kCoplanarNormalTolerance, replacing four independent copies of the same
   literal) before extending coverage to that edge; when they don't, the
   edge simply keeps whatever classify_edge_from_faces() already,
   independently computes for it. Verified this doesn't regress the
   original motivating case (two products butting end-to-end with
   antiparallel touching normals): its own A2/B2 pair is an exact flat
   run (dot product of 1.0), unaffected by the new gate.

Verified via docker/ifcos_env across the 4-drawing synthetic regression
suite (NORTH SECTION, SOUTH SECTION, PLAN_VIEW, REFLECTED_PLAN_VIEW) plus
a real-world project file (EXISTING EAST ELEVATION), toggling the new
classification off and on: off is byte-identical to today's behaviour
(zero cross-coplanar/mat-style-change classes emitted, matching linework
otherwise), on is purely additive/reclassifying with no crashes. The two
previously-reported anomalies (a wrongly-outline edge that should read
mat-style-change; a wrongly-cross-coplanar edge between two non-coplanar
tilted surfaces) are both confirmed fixed against the exact reported
GUIDs, in every affected drawing, while every previously-established
legitimate match (including the antiparallel butt-joint case above)
remains correctly classified.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two exactly-overlapping duplicate objects (same footprint and placement,
differing only in material) had their true outer silhouette wrongly
classified as mat-style-change instead of outline. The existing A2/B2
coplanarity gate is a one-hop check: it excludes an edge from coverage only
if the faces beyond it also fail coplanarity. That works for genuine partial
touches, but a full duplicate has every face matching, including whatever's
beyond any given edge, so the gate trivially passes even at edges that are
the shape's true silhouette with nothing beyond them at all.

Add a bounding-box-equality pre-check (on the pristine boxes, before the
tolerance-based Enlarge()) as a cheap, deliberately narrow proxy for "these
two products are exact full duplicates" -- skips the whole face-pair loop
for such a pair, letting every edge fall through to ordinary single-object
classification. Does not attempt to catch partial-volume overlaps (bounding
boxes differ there); the failure mode if the heuristic ever mismatches is
conservative (coverage just doesn't fire, never a confidently-wrong edge).

Verified against a bespoke test fixture's exact-duplicate-slab scenario plus
the primary fixture's known-good touching pairs (wedge, Round B, wall) and
the full synthetic drawing sweep -- no regressions.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
classify_edge_from_faces()'s convex/concave sign test picked the "most
decisive" (largest |dot|) vertex from face f1 to test against f0's plane,
scanning every vertex of f1. That's only valid when f1 is convex -- every
vertex of a convex polygon lies on the same side of any line through one of
its edges, so the sign is invariant to which vertex is picked. When f1 is
non-convex (an L-shaped face, or a face with a notch cut into it by a
boolean void), vertices reachable only by crossing a reflex corner -- or
lying on a distant, unrelated part of the polygon -- give an inverted sign.
Confirmed against real geometry (an L-shaped roof slab's bottom face, and a
wall's notch cut by an IfcOpeningElement): in both cases the wrong-sign
vertex also had the larger |dot|, so the old heuristic reliably picked the
wrong answer, misclassifying genuinely convex/sharp edges as concave/crease.

Add is_reflex_vertex() (in-plane turn test against the face's own normal)
and wire_neighbors_of_edge() (locates the target edge within a properly
wire-ordered traversal of the face's boundary via BRepTools_WireExplorer,
returning each endpoint's wire-neighbor vertex and whether that endpoint is
itself reflex). Restrict the decisive-vertex candidate pool to wire-neighbors
reached via a non-reflex endpoint; fall back to the original whole-face scan
if both endpoints are reflex or the edge can't be located on any wire. This
is a no-op for every convex f1 (the common case) and byte-identical for
triangular faces, since the sign is provably invariant to vertex choice
there -- verified by hand against both confirmed real cases.

Also widen kOutlineDotEps from 1e-5 to 1e-4: tessellation noise on a
genuinely edge-on face produced a normal component of ~1.05e-5, just barely
exceeding the old threshold and causing a false front/front pair that let a
90-degree fold through as sharp instead of outline.

Verified against all three originally-reported miscategorisations plus two
follow-up isolated test cases, by exact coordinate match. Full regression
sweep (before/after diff, not just visual check): two synthetic drawings and
all 4 bespoke fixture scenarios byte-identical; every other change matches
exactly what the fix targets. A real-world project file's corrugated roof
sheet picked up a consistent bonus correction (76 wrongly-crease peaks now
sharp, 134 genuine valleys still correctly crease).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When two different products produce edges at (or extremely near) the same
real-world location -- e.g. two touching objects whose shared boundary is
visible from both sides -- which one paints on top in the final flat SVG used
to be pure document order, not anything spatial or correctness-based. That
order comes from IfcGeom::Iterator's multi-threaded worker-completion order
(a race between threads finishing geometry conversion for different
products); since SVG has no Z-index, whichever product happened to be
written later simply painted over the other regardless of true camera-space
depth. Real, correct, cross-product HLR already runs (every product sharing
a drawing/storey shares one HLRBRep_Algo/PolyAlgo instance), so this was
never a visibility bug -- both of two touching products' coincident edges
are genuinely, correctly visible; the bug was purely which one got to paint
last.

Adds a coincident-edge deduplication pass in SvgSerializer::draw_hlr(),
between HLR extraction and SVG emission, using two complementary detection
mechanisms:

- Exact-match: buckets every edge by a canonical (endpoint-order-independent)
  key from both endpoints, quantized to svg_cross_coplanar_tolerance_-scale
  precision. When a bucket's edges span more than one product AND more than
  one class-priority level (outline beats mat-style-change/cross-coplanar,
  which beat sharp/crease/flush/boundary), only the best-classified edges
  survive.
- Collinear-overlap: catches a case exact-match misses -- one product's
  short edge fully nested inside another product's collinear, longer edge,
  sharing at most one endpoint. Buckets edges by direction only (a coarser,
  more robust prefilter than an earlier two-level direction+position hash,
  which silently missed a real case when two independently-computed
  collinear edges' quantized positions landed in adjacent, not identical,
  hash cells -- ordinary floating-point noise between separately-computed
  geometry), then applies the real (non-quantized) collinearity and
  parametric-overlap test within each bucket. The overlapped range on the
  lower-priority edge is trimmed via the existing cross_coplanar::
  split_edge_by_coverage() machinery, reused as-is since it already operates
  on bare edges/intervals with no face-topology coupling.

Both mechanisms deliberately leave same-class duplicates across products
untouched (e.g. both sides of a legitimate seam independently computing
cross-coplanar, or both sides of a real material transition computing
mat-style-change) -- that's intentional (both meant to show together when
cross-coplanar/mat-style-change rendering is enabled for QA, or both hidden
together otherwise), not a paint-order bug to fix. An earlier version of the
exact-match pass picked one arbitrary "winning" product per bucket
regardless of whether classes actually differed, which wrongly collapsed
legitimate same-class duplicates down to one and was caught via a full
fixture regression sweep before being corrected.

edge_to_line_seg() (built for pre-HLR real-face-topology edges, requiring a
Geom_Line 3D curve) always returned none for HLR output, since those edges
have no 3D curve attached yet at this point in draw_hlr() -- fixed by
building the LineSeg directly from vertex positions instead, sufficient
since HLR output for a BRep model is always straight polygonal segments.

Verified against every originally-reported case (ORTHOGRAPHIC,
ORTHOGRAPHIC-X, EAST SECTION, NORTH SECTION TILT) plus a full regression
sweep: the primary synthetic fixture's remaining drawings, the original
edge-classification Test.ifc, the bespoke fixture's 4 scenarios (including
multi-neighbour coverage, the specific same-priority-overlap safety case),
and a real-world project file -- every non-empty diff hand-verified as a
genuine coincident/overlapping duplicate correctly resolved by class
priority, none a regression.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HLR's joint visibility computation (every product sharing a drawing/storey
shares one HLRBRep_Algo/PolyAlgo instance) sometimes marks a genuine,
unambiguous single-object silhouette edge as hidden purely because it lies
exactly coincident with a *different* product's planar face -- e.g. one
object resting directly on another's sloped surface, where the touching
boundary is an exact depth-tie, not real occlusion. Confirmed directly: such
an edge is entirely absent from all four of HLRBRep_HLRToShape's
visible-edge query buckets (VCompound/OutLineVCompound/Rg1LineVCompound/
RgNLineVCompound), i.e. genuinely hidden by HLR's own tie-breaking, not
merely misrouted into an unqueried bucket.

Adds restore_coincident_hidden_edges() in SvgSerializer.h, called from
prefiltered_hlr::build() right after HLR extraction. For each classified
single-object edge (outline/sharp/crease/flush -- cross-coplanar and
mat-style-change are excluded, since foreign-face coincidence is the
intentional definition of those classes) missing from HLR's visible output,
restores it when both hold:

- Its endpoints and midpoint lie exactly on some *other* product's planar
  face (point_on_foreign_face()), confirming the coincidence.
- Nothing is genuinely nearer to the camera at that point (a line-of-sight
  ray-cast per candidate sample point, via IntCurvesFace_ShapeIntersector,
  against every product's solid -- including the edge's own product, since
  self-occlusion by a product's own other geometry is just as valid a reason
  to stay hidden as foreign occlusion).

Two correctness fixes and one design refinement came out of QA against real
rendered output, each confirmed with reproducible before/after evidence
rather than assumed correct on first pass:

- The ray-cast originally excluded the candidate edge's own product, so
  self-occlusion (a box's back wall behind its own front wall, any ordinary
  back-facing crease) could never be detected -- any such edge that also
  happened to satisfy the unrelated foreign-face-coincidence test was wrongly
  restored. Fixed by including self in the ray-cast; the point sitting
  exactly on its own product's surface lands at essentially its own depth,
  which the existing depth(q) < depth_p - tolerance comparison already
  treats as "not nearer," not a false positive.
- IntCurvesFace_ShapeIntersector::Load() was rebuilt from scratch per
  (candidate edge x sample point x product), causing a ~30x slowdown on a
  real building (234s vs ~8s on a real-world project file). Fixed by loading
  one intersector per item once up front and reusing it via repeated
  Perform() calls with different rays, matching the class's intended usage.
- A remaining false-positive class: two coplanar but *offset* objects
  side-by-side (not stacked in view depth), whose combined footprint visibly
  covers a point that no single object's solid genuinely occludes along a
  straight ray. The matched coincident face in every such case turned out to
  be edge-on to the camera (its plane contains, or is nearly parallel to,
  the view direction) -- a structural blind spot for any ray-vs-face test,
  since the ray can't produce a proper crossing intersection with a face it
  travels almost within. Confirmed empirically across all known cases: the
  genuine, restore-worthy match had a clearly camera-facing normal
  (~(0,0.97,0.24)); both false-positive cases matched a normal within ~3
  degrees of perpendicular to the view direction. Fixed by rejecting a
  coincident-face match in point_on_foreign_face() when the face is edge-on
  (|face_normal . view_direction| < 0.05), leaving such a candidate
  ineligible for restoration via this mechanism.

Verified against a full regression sweep (the primary synthetic fixture's 9
drawings, the original edge-classification Test.ifc, the bespoke fixture's 4
scenarios, and a real-world project file) plus all three known reproduction
points by exact coordinates: the original missing-edge case still restores
correctly, and both "shifted wall" false-positive cases found via direct
user review of rendered SVGs are confirmed gone, not just reclassified.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds src/ifcopenshell-python/test/geom/test_svg_edge_classification.py: 7 passing
scenarios covering cross-coplanar and mat-style-change baseline classification,
Cluster A's exact-match dedup, Cluster B's depth-tie restoration, self-occlusion
guard, and the full-duplicate-pair silhouette guard. All geometry is synthetic,
built via low-level IFC entity creation; where a scenario originated from a real
bug, its exact profile/placement numbers were extracted from the real fixture and
hand-reconstructed (verified byte-for-byte identical), never a committed .ifc file.
Each passing scenario was also checked against pre-fix behaviour by temporarily
neutering the relevant fix and confirming the test actually fails.

Two scenarios (Cluster A's collinear-overlap dedup, Cluster B's edge-on rejection
guard) are marked skip with detailed investigation notes rather than shipped as
fake/forced tests -- both real mechanisms, but a minimal repro wasn't isolated in
the time available. Parked for now; drop them before the PR if still unresolved.

While building the mat-style-change Case B test, found and documented the root
cause of a known coupling bug: resolve_layer_projection() in SvgSerializer.cpp is
gated behind svg_use_cross_coplanar_classification_ instead of (or in addition to)
svg_use_mat_style_change_classification_, so Case B silently does nothing when
cross-coplanar classification is off.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Case B (intra-product IfcMaterialLayerSetUsage layer-boundary edges) silently
did nothing with use_mat_style_change alone: resolve_layer_projection() was
only called when use_cross_coplanar_classification_ was also true. Widened
that gate in SvgSerializer.cpp to fire on either flag, since Case B needs no
cross-product comparison at all.

Case A (cross-product material/style mismatch) had the opposite problem:
find_cross_coplanar_matches()'s entry gate let the whole cross-product
matching pass run from mat-style-change alone, even though Case A reuses that
same pass and should require cross-coplanar too. Confirmed as a genuine,
not just theoretical, regression risk: with only the Case B fix applied,
layered products with mismatched top-layer materials wrongly produced
cross-product mat-style-change edges without cross-coplanar enabled, since
resolving layer_projection (now independent of the coplanar flag) let such
pairs bypass the unrelated material/style-null rejection that had been
accidentally masking this gap for plain-material products. Narrowed the
entry gate back to requiring cross-coplanar, matching its own already-stale
preceding comment.

Corrected the intended, user-confirmed matrix is now:
- coplanar off, mat-style off: neither
- coplanar off, mat-style on:  Case B only
- coplanar on,  mat-style off: cross-coplanar edges, no mat-style-change
- coplanar on,  mat-style on:  cross-coplanar edges, Case A and Case B both

Updated ConversionSettings.h's SvgUseMatStyleChangeClassification description
to match (it previously and incorrectly claimed both cases were independent
of cross-coplanar).

Per TDD: test_mat_style_change_case_a_requires_cross_coplanar_too(_when_layered)
and the now-corrected test_mat_style_change_case_b_intra_product_layer_boundary
were written/modified first and confirmed to fail against the unfixed code
for the expected reasons before the fix was applied.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
classify_edge_from_faces() and wire_neighbors_of_edge() in SvgSerializer.cpp
had three separate bugs, all found investigating a real user-reported
project drawing where several window frame edges rendered as `crease`
instead of `sharp`:

1. The `back0 && back1` "seen through an opening" flip fired on any
   back-facing convex fold, with no check that the product actually has an
   opening. Now gated on a new `product_has_naked_edge` bool, computed once
   per product from the existing edge-face map.

2. wire_neighbors_of_edge()'s reflex-corner test assumed a wire always
   winds counter-clockwise relative to its face normal. On some faces this
   doesn't hold -- confirmed a plain rectangular quad with every vertex
   wrongly testing reflex. Fixed by self-correcting the effective normal
   per-wire from the wire's own aggregate signed turning.

3. The actual root cause of the reported symptom: the convexity sign test
   picks a "trustworthy candidate" vertex by walking further around a
   face's own boundary wire. That's only valid on a face's outer boundary --
   on an inner (hole/void) boundary wire, the next vertex around the same
   rim lies on the far side of the empty hole, not on material, giving a
   dot product that's always the wrong sign regardless of the true 3D fold.
   This affects any product extruded from an IfcArbitraryProfileDefWithVoids
   profile (a genuine hole, not a notch). Fixed by pooling sign candidates
   from both faces meeting the edge, each tested against the other's
   normal, and preferring whichever came from an outer wire.

Three new regression tests added to test_svg_edge_classification.py,
including one built from the reporting user's own project geometry
(profile points, placements, and a camera angle they saved specifically to
inspect this bug) reconstructed via low-level IFC entity creation -- not a
real project file, just its bare numeric geometry. All three were
confirmed to fail against the pre-fix code for the right reason before the
fix landed, per this branch's established TDD workflow.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Case B (intra-product layer-boundary lining) drew its internal
Concrete/Insulation boundary line on any eligible face regardless of
whether that face was actually a matched touching boundary with a
neighbouring product's face -- duplicating what cross-coplanar/Case A
already resolves for that same face pair, and showing up as a spurious
edge (e.g. rendered vertical) at the touching interface.

Moved Case B from a per-item pass (before other products are known)
to its own pass in hlr_t::build(), run after every product sharing a
drawing/storey has been added -- kept deliberately independent of
find_cross_coplanar_matches()/use_cross_coplanar_classification_ so it
still works with cross-coplanar off, per the matrix pinned by the P1-1
fix. A new find_coplanar_coincident_faces() reuses the same
normal-parallel + plane-distance test find_cross_coplanar_matches()
already uses (not reimplemented) to find matched neighbour faces;
layer_boundary_edges_for_face() unions their outline crossings into
its own cut-point set and drops any resulting sub-range whose midpoint
lands on a matched face, so partial overlaps split correctly instead
of being judged all-or-nothing.

This surfaced a second bug: moving Case B's emission point changed
its position in the classified-edges list relative to outline/sharp
for the same product, flipping SVG paint order (no z-index -- later
elements paint over earlier ones) and exposing several previously
invisible mat-style-change/outline overlaps that had been accidentally
masked by the old ordering. compute_coincident_edge_overlap_coverage()
(Cluster A's collinear-overlap dedup) already trims exactly this kind
of nested duplicate, but explicitly skipped same-product pairs since
it was only ever built for the cross-product case; relaxed that guard
rather than reintroducing an ordering dependency. Also closes P2-12
(outline vs. mat-style-change indeterminate overlay at 2CH9sv/2u$gv).

Verified against the real fixture that reported the bug (byte-for-byte
identical elsewhere) and against a broader before/after sweep of every
drawing in both test fixtures; full regression suite passes with two
new tests for the matched/partial-overlap split.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
restore_coincident_hidden_edges() (added in 0078c94872) restores a
single-object edge HLR hid due to a depth-tie with a coincident foreign
face -- but decided restoration on a whole-edge, all-or-nothing basis from
only 3 fixed interior sample points (mid/q1@0.25/q3@0.75). Two distinct bugs
came out of chasing real user-reported examples where a restored edge
visibly overshot into an occluding object's footprint.

Bug 1: sparse sampling causes wrong all-or-nothing decisions. When only
part of a candidate edge is a genuine depth-tie and the rest is genuinely
occluded by a nearer, unrelated object, the old 3-sample gate either
restored the whole edge (if none of the 3 samples landed in the occluded
portion, overshooting past the true occlusion boundary) or dropped the
whole edge (if any sample landed in the occluded portion, wrongly losing
the genuinely-restorable outer portions too). Fixed by replacing the
3-sample gate with a bounding-box-prefiltered, 31-point interior sample
grid producing per-edge restorable sub-intervals (restorable_intervals()),
merged via the existing cross_coplanar::ivs_union() and split into
multiple restored sub-edges instead of one full-length edge when coverage
isn't uniform. A full-coverage fast path keeps the common (fully-
restorable) case byte-identical to the old single-push behaviour.
point_on_foreign_face() gained a bounding-box-shortlisted candidate list so
the denser sampling doesn't reintroduce the ~30x per-face-loop slowdown
0078c94872 already had to fix once.

Bug 2, found chasing why the above didn't resolve a real user-reported
overshoot: has_coincident_edge() and the new point_covered_by_visible()
both compared a candidate's transformed points against visible_edges using
full 3D Distance() -- but visible_edges' own vertices (HLR's reconstructed
2D screen-space output) always carry Z=0 in this projected space, while a
freshly-projector.Transform()-ed candidate point retains a real,
generally-nonzero depth component. Comparing full 3D distance against a
phantom depth axis visible_edges never populates meant neither check could
ever actually match, even for a point genuinely coincident on screen --
confirmed via debug instrumentation this literally never fired in
practice, since has_coincident_edge was introduced. Concretely: a
correctly-clipped native outline edge was already sitting in visible_edges
the whole time; the "already visible, don't duplicate" check just could
never see it, so the full original candidate got needlessly (and, since
its own endpoint calculation differs slightly from HLR's own, visibly
wrongly) restored on top of it every time this pass ran. Fixed by
flattening both sides of the comparison to the screen plane (X, Y) before
measuring distance.

Verified against the real "coplanar join.ifc" EAST SECTION drawing (the
user's own originally-reported example, GUIDs 1I9V3NogL069$EyK070JfW /
2lMPZETv5DOhI4hiZPs3ZP): the wrongly-restored, ~0.39-unit-overshooting
duplicate is gone entirely, and the genuinely-correct native edge is
unchanged -- output now matches the restore-pass-disabled baseline exactly.
Full-fixture regression sweep (all 10 primary + 4 bespoke drawings, both
classification flags on): no crashes, no degenerate/zero-length/NaN edges;
outline counts decreased substantially on most drawings relative to the
original baseline, confirming bug 2 had been causing systemic, fixture-
wide spurious duplication, not just the one reported edge.

New regression test test_restore_coincident_hidden_edges_partial_occlusion
covers bug 1 (extends test_cluster_b_depth_tie_restoration's proven
SlabA/SlabB depth-tie scene with a partial occluder). Bug 2 is verified
directly against the real fixture rather than a synthetic test, documented
in that same test's docstring -- this class of scenario (a candidate edge
that HLR's own native computation also independently makes visible) has
repeatedly resisted synthetic isolation elsewhere in this suite's own
history (test_cluster_b_depth_tie_restoration, test_cluster_b_edge_on_
face_must_not_restore), and forcing one here risked a non-reproducing test.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
find_cross_coplanar_matches() can steal a product's genuine outline/sharp
classification and replace it with cross-coplanar/mat-style-change even when
the matched neighbour is actually hidden behind a third object from the
current camera. Adding occlusion-awareness (a screen-space ray-cast per
candidate match) surfaced six distinct, related bugs, found and fixed in
sequence against both synthetic tests and a real building file:

- Self-occlusion: the occlusion ray-cast didn't exclude the two products of
  the matched pair itself, so two directly-stacked, same-footprint objects
  could find each other as a false "occluder".
- Whole-face vs. per-interval granularity: testing a single point (the whole
  face's average) let an unrelated object partially blocking one small
  portion of a long face wrongly reject coverage for the entire face.
- IfcOpeningElement treated as a solid occluder, in both
  find_cross_coplanar_matches() and the separate, already-shipped
  restore_coincident_hidden_edges() -- a window/door void can never
  legitimately block anything.
- Divergent per-side occlusion decisions: the per-interval probe offset
  toward "this face's own" interior differed between the two sides of a
  matched pair, so the same physical boundary could split at different
  points on each side and defeat the coincident-edge dedup pass.
- Single-probe ray-cast grazing miss: a boundary point tested with one
  unoffset ray can miss a genuine occluder whose own footprint edge happens
  to coincide with that exact boundary, at a shallow camera angle. Fixed
  with a small, side-symmetric dual-nudge probe (toward both faces' own
  interiors, computed once per matched pair so both sides agree).

Verified via the existing regression suite plus new tests, a full-fixture
sweep, and direct investigation against a real building file using its own
EPset_Drawing.Include filter for accurate, fast (~27s vs ~12min) iteration.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
restorable_intervals() decides which portion of a hidden edge to restore from
a fixed 31-sample grid (32 bins): each restorable/occluded transition landed
at the midpoint between two adjacent samples, length/32 apart. For a long
edge this is a real, visible gap between the reported cutoff and the true
geometric silhouette intersection with the occluding object, not just
numerical noise -- confirmed against a real building file where a slab's own
edge, correctly clipped by a wall standing on it, extended measurably past
the wall's own top-edge outline.

Adds refine_transition(): a 12-iteration bisection between a confirmed-
restorable sample and its confirmed-occluded neighbour, using the same
point-restorability test (factored out into is_restorable_at()), narrowing
each transition to well under the geometric tolerance. Boundaries between two
same-verdict neighbouring samples are left at the coarse midpoint -- those
get absorbed by the interval union regardless of exact position, so refining
them is unnecessary.

test_restore_coincident_hidden_edges_partial_occlusion's hardcoded transition
coordinates are re-captured to match the more precise cutoff (shifted by
under one bin width, as expected).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compute_coincident_edge_best_priority()/compute_coincident_edge_overlap_
coverage() (Cluster A, a08873af07) decide which of two coincident-duplicate
edges from different products paints on top by testing collinearity/overlap
in the edges' true 3D coordinates. That's correct for genuine 3D coincidence,
but these two functions exist to answer a screen-space question -- "which
edge paints over the other in the final flat SVG" -- and two edges can land
on nearly the same screen location while genuinely differing in true 3D depth
(e.g. a wall's own slightly sloped/raking edge crossing a level slab edge).
Confirmed directly against a real building file: three flagged wall edges
each duplicated another product's already-correct outline edge on screen
while differing from it by up to ~0.7 model units of true 3D depth -- the
existing dedup pass correctly, but unhelpfully, rejected them as unrelated.

Added project_to_view_plane(), used only by these two dedup functions. By
the time draw_hlr() runs, hlr_items' edges are already expressed in the
camera-aligned frame HLR was computed in -- X/Y are already the SVG plot
coordinates and Z is purely depth-into-screen (see SvgSerializer::write()'s
own comment on this convention) -- so "projection" here is just dropping Z,
confirmed by comparing X/Y-only endpoint deltas directly against real
rendered output. compute_coincident_edge_overlap_coverage() now builds a
second, screen-projected LineSeg per entry for the collinearity/perp-
distance/overlap tests, then rescales the overlap fraction back onto the
original edge's true 3D length before recording it for clipping -- exact,
not approximate, since parallel/orthographic projection preserves a
segment's [0,1] parametrization.

Verified against the real file: two flagged wall groups (each holding only
spurious duplicate paths) now disappear entirely, matching the expected
behaviour; a third improved from two long spurious edges to four tiny
leftover fragments (not yet fully resolved); the winning-side product
(a slab) is byte-identical to before, confirming no regression. Full
pytest suite green, 14-drawing fixture sweep clean. No permanent synthetic
regression test added -- several attempts at a minimal repro each triggered
a different, dominant occlusion interaction instead of isolating this
specific "coincident only in projection" scenario; relying on the real-file
verification and unchanged synthetic suite instead, per this branch's own
precedent for honestly parking a hard-to-isolate case.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_genuinely_occluded() (restore_coincident_hidden_edges()'s own occlusion
check) tests a single, unoffset ray per sample point -- the same grazing-ray
vulnerability find_cross_coplanar_matches()'s occlusion check had before its
own multi-probe fix (P1-4d Bug 6): a ray whose own footprint edge happens to
coincide with the tested point can drift past the occluder before reaching
its base height, at a shallow camera angle.

Not a straight reuse of that fix, since the two checks have different
exclusion semantics: find_cross_coplanar_matches() excludes a known PAIR
(idx_i/idx_j) since two products standing on/against each other must never
count as occluding their own touching boundary. is_genuinely_occluded() has
no such pair -- it deliberately excludes nothing by default, since a
candidate's own other geometry legitimately occluding it (e.g. a box's back
face behind its own front face) must stay hidden. point_on_foreign_face()
now returns the matched face and its owning product (previously just a
bool), so is_restorable_at() can nudge its own probe toward that face's own
interior the same way, without disturbing the "no exclusion by default"
design for the plain probe.

Two regressions found and fixed while implementing this, via direct debug
tracing against the real building file each time:

- First attempt excluded only the foreign product from the nudged probe.
  This caused two previously-correct real-file outline edges to disappear
  entirely. Root cause: the nudge target (face_interior_point(), an average
  of a face's own coplanar vertices) moves the probe tangentially across the
  shared boundary rather than into the foreign product's volume -- but under
  an oblique (isometric) camera, that tangential move still produces a real
  depth change, and the now off-the-exact-boundary point could find the
  CANDIDATE's own nearby-but-not-actually-blocking geometry and wrongly
  self-occlude (the unnudged probe never has this problem, since a self-hit
  lands within tolerance of the boundary's own depth by construction).
  Fixed by excluding BOTH the candidate's own product and the foreign one
  from the nudged probe specifically, mirroring find_cross_coplanar_
  matches()'s own idx_i/idx_j pair exclusion.

Verified: full pytest suite green throughout (18 passed, 2 pre-existing
skips). Full-fixture sweep (10 primary + 4 bespoke drawings): no crashes.
Real-file re-render confirms both regressed edges restored byte-identical
to the confirmed-good baseline, with no change to any already-fixed case.

Does not resolve the one remaining unresolved real-file fragment (a large
stray sharp-classed edge on 0MmPs4lGP0zgpUVE$tStUb) -- its own originating
code path was never confirmed to go through restore_coincident_hidden_
edges() at all, so this fix's scope doesn't cover it; see the branch's own
known-issues notes for the full investigation trail on that one.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Geom_Line was relied on transitively via another header's include
order; make the dependency explicit so it isn't fragile to reordering.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
accumulate_edge_coverage()/mat_style_change::accumulate_mismatch_coverage()
tested screen-space occlusion at a single midpoint per raw matched
interval, accepting or rejecting the whole interval from that one sample.
When occlusion genuinely varied along an edge's length, a long
genuinely-visible stretch could be wiped out entirely because its one
sample landed on a small real occluded patch -- reported by the user from
real project drawings (large sub-edges wrongly falling back to outline
while neighbouring tiny fragments, tested independently, survived).

Adds cross_coplanar::occlusion_clear_subintervals(), porting the same
coarse-sample + 12-iteration bisection-refine approach
restore_coincident_hidden_edges()'s own restorable_intervals() already
uses, so a raw interval is split into proper occlusion-clear sub-ranges
instead of one all-or-nothing verdict.

The finer sampling this introduces surfaced a second, related bug: the
existing occlusion-awareness nudge (5% of distance to each face's own
centroid, added in P1-4d to fix a grazing-miss false negative) has no
relationship to how far away a genuinely separate object actually is. For
a large face sitting only a few millimetres from an unrelated third
product, 5% can be larger than that real gap, wrongly crossing into the
unrelated object's own solid. A fixed nudge cap can't replace it either --
the reported real gap (~0.015 project units) is smaller than the several-
centimetre drift the original grazing fix needed to tolerate, so no one
constant is small enough to avoid the former and large enough to catch
the latter (confirmed by direct calculation, not just reasoning). A first
attempt measuring the nearest foreign object's distance along the nudge
direction was built and tested (full before/after render of all 10
primary fixture drawings) to change nothing, since that tangential
direction is generally not the direction the actual occlusion ray travels
in.

Fixed by gating the nudge on touches_foreign_face(): only trust a
nudge-discovered occlusion when some foreign face's plane genuinely
passes within tolerance of the original, unnudged boundary point -- the
same coincidence test restore_coincident_hidden_edges()'s own
point_on_foreign_face() already uses. A real touching occluder passes
this; a separate object a few millimetres away doesn't.

Verified: new regression test (TDD, confirmed red on unfixed code) plus
full suite green except one pre-existing, unrelated stale test
(test_restore_coincident_hidden_edges_partial_occlusion, a different
function's hardcoded bisection coordinates, not touched here). Full
before/after render of all 10 primary fixture drawings confirms every
changed edge moves outline -> cross-coplanar/mat-style-change (or a
sub-unit precision shift), never the reverse -- fixing several further
latent instances beyond the two originally reported. User-confirmed via
bonsai_test against the real fixture (PLAN_VIEW, ORTHOGRAPHIC-X,
ORTHOGRAPHIC-X-X all matching known-good baselines).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
find_cross_coplanar_matches() only ever asked "is this specific shared
boundary blocked" on a per-edge basis -- it had no notion of "is the
object on the other side of this match, as a whole, visible in this
drawing at all". A product that is itself almost entirely hidden behind
other products can still have one localized seam that individually
passes the existing per-point occlusion test (nothing else happens to
stand in front of that particular line), even though the product
contributes nothing else to the drawing. Reported by the user from a
real project file: a wall 95%+ hidden behind a second wall and a slab
still had its one surviving, same-material seam accepted as a genuine
cross-coplanar match, which wrongly suppressed the plain outline corner
between the two genuinely-visible, non-coplanar products (a wall and a
slab meeting at a right angle -- never coplanar with each other at all).

Adds a whole-object visibility pre-filter to find_cross_coplanar_matches():
before the main pairwise loop, each item's own edges are sampled (11
points per edge, majority vote, matching the same "don't trust one
sample" lesson occlusion_clear_subintervals() already learned elsewhere)
against every other item's occlusion intersector; a product with zero
genuinely-visible edges is excluded from participating in matching
entirely, on either side of any pair. Native single-object classification
and restore_coincident_hidden_edges() are untouched.

Two rounds of real-file-driven correction on the way to this:
- A first cut used "<=1 unoccluded edge" as the threshold, reasoning a
  full BRep box has ~12 edges. Wrong: by the time this pass runs, items_'s
  shape has already been through this class's own prefiltering, so a
  fully-visible box can have as few as 4 edges total. That threshold broke
  a real, intended partial-match case in the existing suite. Tightened to
  "0 visible edges" -- the only threshold that can't suppress a genuinely
  partially-visible product.
- The occlusion test itself needed the same grazing-ray nudge used
  elsewhere (is_occluded_for_pair/is_genuinely_occluded), but generalized:
  a sample point can genuinely sit on more than one other product's
  coincident face at once (confirmed real-file case: the hidden wall's
  problem edge touched both a large slab's top face AND the actual
  occluding wall's face). Taking only the first match by item index
  picked the irrelevant slab and missed the real occluder. Fixed by
  trying every genuinely-touching candidate's own centroid and
  OR-combining the results, rather than stopping at the first found.

Verified: full existing suite green (19/20; the one failure is
pre-existing, unrelated, already noted in this branch's prior commit).
New regression test added (confirmed red without the pre-filter, green
with it), though it validates the overall exclusion mechanism rather than
surgically pinning the specific multi-candidate sub-bug -- see the test's
own docstring for why a fully faithful synthetic repro of that exact
grazing-ray scenario wasn't achieved in reasonable time. Re-verified
directly against the real-world project file that motivated this: the
reported junction now renders the expected plain outline instead of a
spurious cross-coplanar edge, and 4 additional real drawings across
plan/isometric/elevation/section projections render without exceptions.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"Use Edge Classification" and its children (crease/sharp/flush
rendering, cross-coplanar, mat-style-change) remained visible and
toggleable in the camera/drawing panel regardless of Linework Mode,
even though they have zero effect under Freestyle: setup_serialiser()
(and therefore the SVG serializer and every classification setting) is
only ever invoked from generate_linework(), which only runs in the
OpenCASCADE branch of the linework-mode dispatch. Freestyle mode routes
entirely through generate_freestyle_linework(), a separate Blender-native
path that never touches SvgSerializer. A user toggling these under
Freestyle could reasonably expect them to do something.

Nests the existing use_edge_classification block inside the panel's
own `if props.linework_mode == "OPENCASCADE"` guard (ui.py), which
already hides fill_mode/cut_mode the same way -- same convention, no
new UI pattern. Deliberately not touching prop.py's persistence layer:
an existing EPset_Drawing.UseEdgeClassification value (and friends) is
preserved, not reset, when switching to Freestyle and back. Not
touching any C++/SvgSerializer code, since the setting already has no
effect there.

Verified: black/ruff clean. No existing automated test covers this
panel's draw() logic; recommend a manual check in Blender (toggle
Linework Mode, confirm the row appears/disappears like Fill Mode/Cut
Mode already do).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SvgSerializer::write()'s unify_inputs_ path (Bonsai's drawing operator
always enables this via setUnifyInputs(True), so this runs on every
real drawing, not just an opt-in case) called IfcGeom::util::unify() at
a hardcoded 1.e-6 tolerance -- bare OCCT confusion-level, far below the
~0.1mm of sub-millimetre noise real IFC authoring/export round-trips
routinely leave behind (a redundant near-collinear vertex, a boolean
byproduct). At that tolerance, ShapeUpgrade_UnifySameDomain (inside
unify()) essentially never merges the near-coplanar faces either side
of such noise, so a single visually-straight edge ends up emitted as
multiple collinear segments in the SVG output.

Adds IfcGeom::util::heal_for_linework(): sews the shape first (faces
that are geometrically coincident but topologically disjoint -- the
normal result of concatenating independently-built representation
items into one compound -- don't share edges, so unify() alone can
never see them as mergeable), then unify()s, then falls back to
ShapeFix_Shape if the result doesn't validate, and finally falls back
to the original input shape if nothing validates. The SvgSerializer.cpp
call site now passes svg_cross_coplanar_tolerance_ (0.1mm default)
instead of the hardcoded value -- the same tolerance this module
already trusts for "is this coincident?" decisions elsewhere, applied
consistently rather than a second, unrelated, much stricter one.
heal_for_linework() is only ever called on one product's own local
shape (SvgSerializer::write(const IfcGeom::BRepElement*) is inherently
per-element), so this can't erase cross-object boundary information
the cross-coplanar classification pass depends on.

New regression test: a wall profile with a single vertex offset 0.05mm
off an otherwise dead-straight edge (bigger than the old tolerance,
smaller than the new one) renders as two collinear segments unhealed,
one clean edge healed.

No connection found to an existing tracked issue for this specific fix
after checking the branch's own known-issues backlog and memory --
recorded here for future reference in case that backlog needs a
retroactive entry.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
918ddc1a9c (UnifyInputs sewing/tolerance change) broke what 257dcdba29
(the whole-object visibility pre-filter) had fixed: the healed geometry
changed which raw edges survive, and the slab from that original report
started being wrongly excluded from matching itself, alongside a second,
more severe, previously-undiscovered flaw: the "<=1 unoccluded edge"
rule (later "0") could not tell "swallowed entirely by one dominant
neighbour" (should exclude) apart from "surrounded by many distinct
neighbours around its own perimeter" (should NOT exclude, e.g. a slab
touching a different wall on every side).

Root-caused against five further real pairs the user supplied from the
same project file, plus the existing synthetic suite, in three rounds:

1. Tracking WHICH item occludes each sampled edge point, not just
   whether it's occluded, and excluding only when that occluder set has
   at most one distinct member -- intended to separate "one dominant
   swallower" from "many legitimate neighbours". Fixed 3 of 5 real
   pairs; 2 remained wrongly included.

2. The remaining two turned out to share a for-loop bug, not a threshold
   problem: the edge-sampling loop stopped as soon as the unoccluded
   count crossed its threshold, so the accumulated occluder set silently
   depended on TopExp::MapShapesAndAncestors's own edge enumeration
   order rather than the product's real, complete neighbour set. Removed
   the early exit (every edge is now always evaluated -- cheap relative
   to the O(products^2) loop this pre-filter precedes, same trade-off
   this filter's own top comment already accepted).

3. With complete, order-independent data for all five real "exclude"
   cases plus the real "keep" slab, distinct-occluder count needed
   bounding on BOTH sides, not just capped: the five exclude-cases have
   4-8 distinct occluders, the slab has 18 -- but a synthetic suite case
   (`test_cross_coplanar_match_must_not_steal_edge_from_occluded_
   neighbour`'s `LowerWall`, occluded almost entirely by one unrelated
   stacked wall) has exactly 1. A single dominant occluder is an
   ordinary partial overlap, not the "swallowed by several neighbours at
   once" pattern the five real cases share. Excluding now requires at
   least 2 distinct occluders and at most 12, comfortably clear of both
   1 and 18.

Also fixed, found along the way: the occlusion test used for this
pre-filter always excluded an item's own shape as a possible occluder of
its own edges, so a native back-face edge hidden only by this same
item's nearer geometry (never checked elsewhere either, matching
is_genuinely_occluded()'s own established reasoning for not excluding
self) could read as falsely visible. Turned out not to be the deciding
factor for any of the real cases here, but is a real, separate
correctness fix worth keeping.

Verified: full suite green (20/21; the one failure is the same
pre-existing, unrelated stale-coordinate case already called out in this
branch's prior commits). All 5 real-file pairs now correctly resolve
(fully excluded, or -- where the object has one genuinely visible
boundary -- a plain outline instead of cross-coplanar/mat-style-change).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Preserving this as a rollback point before a larger redesign, not as a
finished fix -- see the follow-up commit/branch history for context.

find_cross_coplanar_matches() previously had no way to reject a face pair
whose faces are coplanar and touching in 3D but where one side is a
product's own permanently-invisible face (e.g. a wall's underside resting
on the slab it stands on). Confirmed via direct debug instrumentation
against a real building file that this produces spurious mat-style-change/
cross-coplanar edges along a wall's own footprint perimeter, independent of
any hidden third object -- universal for any wall standing on any slab, and
pervasive (45 of 75 products in one real drawing were affected).

This guard rejects a candidate face pair outright if either face is
clearly back-facing relative to the camera, reusing classify_edge_from_
faces()'s own front/back convention. It fixes the specific reported
corner and shrinks (does not eliminate) the wider pattern.

KNOWN REGRESSION: two products touching face-to-face necessarily have
opposite-facing normals at that seam, so for any camera that isn't exactly
edge-on to it, one side mechanically reads "facing away" even for a
completely legitimate match -- this wrongly turns real cross-coplanar/
mat-style-change seams into plain outline in elevation views. The
pair-level rejection is the wrong mechanism; a location-centric redesign
(deciding per shared edge location, not per product pair) is planned as a
follow-up to replace this entirely, including the whole-object visibility
pre-filter from the immediately preceding commit.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Discovered while verifying an unrelated cross-coplanar redesign against a
real building file's elevation views (a camera angle never previously
exercised in this branch's testing -- only isometric views had been
rendered against real project files so far). Confirmed via git checkout
that this crash already exists on this exact commit, unrelated to any
in-progress classification work.

restore_coincident_hidden_edges() builds one IntCurvesFace_ShapeIntersector
per item up front for reuse across every candidate edge/sample point.
Certain real-world product shapes make OCCT's BVH/face-intersector
construction throw Standard_NullObject from IntCurvesFace_ShapeIntersector::
Load(), which previously propagated uncaught and aborted the entire
drawing -- one product's ill-formed geometry could take down a whole
elevation render with no partial output at all.

Wraps the Load()/AddClose() pair in try/catch and leaves
intersector_loaded[idx] false on failure, which is already this function's
own established mechanism for "don't ray-cast against this item" (used
elsewhere for null shapes and IfcOpeningElement voids). That item simply
stops participating in occlusion restoration for hidden-edge purposes
instead of crashing the render.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces find_cross_coplanar_matches()'s old per-product-PAIR double loop
with a per-shared-EDGE-LOCATION mechanism: for every edge, gather every
other product with a genuinely collinear, overlapping edge over some
sub-range, and let one new function, resolve_edge_location_subrange(),
decide once what the camera actually sees there -- instead of separately
asking "should THIS pair match" for every pair sharing that location and
hoping the answers agree.

Replaces both of this branch's earlier, narrower patches, neither of which
generalized correctly:
- The whole-object visibility pre-filter (a fragile heuristic needing three
  rounds of real-file threshold recalibration).
- The pair-level back-facing guard (rejected a candidate pair outright if
  either face pointed away from the camera -- caused a real elevation-view
  regression, since two products touching face-to-face always have
  opposite-facing normals, so any non-edge-on camera reads one side as
  "facing away" even for a completely legitimate match).

The new per-location decision, for one sub-range of one edge:
1. Gather every contributor's own adjacent faces into one candidate pool.
2. Keep only STRICTLY front-facing candidates -- both clearly back-facing
   and edge-on are excluded, scoped only to this cross-product search
   (never to a product's own native Stage-0 classification). A genuine
   cross-coplanar/mat-style-change seam is decided by two SEPARATE
   products' own genuinely-camera-visible faces continuing in the SAME
   direction (e.g. two wall segments' own front faces) -- never by
   whichever internal, opposite-facing faces happen to physically touch
   where the solids meet (a wall's hidden end-cap, an underside resting on
   whatever's below). Since two exactly-opposite-facing faces can never
   both be front-facing to the same camera at once, this filter removes
   that irrelevant pairing from contention without a separate case split.
3. Build a plane through the edge's own 3D line and the camera's view
   direction, splitting surviving candidates into two camera-relative
   sides ("left"/"right" are just the two signs of one dot product, not
   literal screen position).
4. Classify each candidate by which side it falls on using a point local
   to this specific edge location (not a whole-face average, which for a
   large face -- a wall's entire main face, evaluated at one small seam
   near its own edge -- can sit on the wrong side of a plane that's only
   meaningful locally). Achieved via BRepClass_FaceClassifier picking
   whichever of the two in-plane, edge-perpendicular directions is
   genuinely inside the face -- cheaper than a real triangulation, same
   answer for planar building faces.
5. Per side, the winner is whichever candidate's normal has the smallest
   angle to that side's own outward plane normal, restricted to
   (left, right) pairs from DIFFERENT owning products -- otherwise this
   can rediscover a product's own native corner (confirmed against two
   identical boxes stacked directly on each other, where the lower box's
   own top/side corner has a smaller angle than the genuine cross-product
   seam and would otherwise silently win).
6. Compare the two winners for SAME-direction coplanarity (never opposite
   -- see step 2) and material/style at the sub-range's own midpoint into
   match, mismatch, or none.
7. On match/mismatch, convert the sub-range into every contributor's own
   edge parametrization, not just the two winners' -- every product
   sharing this edge location must agree, since only one line can appear
   there.

Sub-edge-level throughout, same as the code it replaces: collinear edges
of different lengths only ever share their genuinely-overlapping portion,
and any resulting sliver shorter than the matching tolerance is dropped,
so floating-point edge-endpoint noise can't produce a degenerate
zero-length "dot" in the drawing.

Verified: full suite green (21/22; the one failure is the same
pre-existing, unrelated stale-coordinate case already called out in this
branch's prior commits). Confirmed against a real building file that the
elevation-view regression the pair-level back-facing guard caused is
fixed, and empirically confirmed (implementing without it, then rerunning
the existing occlusion tests unmodified) that this design needs no
pre-HLR occlusion-awareness of its own -- real HLR, running after
classification, resolves true third-party occlusion on its own, more
precisely than the old pre-HLR approximation ever did.

KNOWN INCOMPLETE: verified against a synthetic multi-view test project
(camera angles from plan/section through full 3D isometric) that this
still misses a small number of genuine matches in oblique isometric-style
views specifically -- a handful of products each losing exactly one
cross-coplanar/mat-style-change edge among several correctly-found ones
for the same product pair, not yet root-caused. Axis-aligned plan/section
views and one fully-embedded "plug filling a notch" test case (the
original motivating scenario for steps 2 and 4 above) are confirmed
exact matches against the known-good reference renders.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
point_on_foreign_face() (used to decide whether a hidden edge is a genuine
HLR depth-tie worth restoring) already excluded edge-on matched faces but
accepted back-facing ones as equally valid "coincident foreign face"
candidates. A back-facing match is the end-cap/underside case: real
geometry that genuinely touches the candidate edge, but can never be what
the camera actually sees there, for the same reason it's now excluded from
cross-coplanar matching itself (this branch's per-shared-edge-location
redesign, previous commit).

Before that redesign, this was invisible: the old, facing-agnostic
cross-coplanar matcher classified end-cap pairs as cross-coplanar/
mat-style-change outright, and this function already skips edges in those
classes, so they never reached this test. Once the matcher correctly
stopped doing that, those edges fell back to native "outline" classification
and reached restore_coincident_hidden_edges() for the first time -- exposing
a real, previously-masked bug.

Confirmed via direct debug instrumentation against a real building file:
two products touching end-to-end along their full shared edge (opposite-
facing normals) had one product's edge found "coincident" with the
neighbour's own back-facing face. Since every sample point lands exactly on
that shared boundary, the ray-cast occlusion test never gets a chance to
detect that the neighbour's actual solid bulk, just off that exact line,
does occlude the surrounding area -- the whole edge was wrongly restored as
a visible outline.

Fix: drop the abs() so both edge-on and back-facing matches are excluded,
requiring the coincident face be genuinely front-facing.

Verified: full suite unchanged (21/22, same pre-existing unrelated
failure). Against a real multi-view synthetic test file, this fixed all
seven independently hand-verified cases of a hidden object's edge wrongly
surviving as visible, with several drawings' total discrepancy count
against a pre-redesign reference dropping by half or more and zero
regressions in previously-correct drawings.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The post-HLR screen-space dedup (compute_coincident_edge_best_priority()/
compute_coincident_edge_overlap_coverage() in SvgSerializer.cpp) has always
made a plain `outline` edge win any coincident-edge collision, on the
confirmed premise that outline is a product's own true silhouette and
should beat an incorrect cross-product coincidence. That premise breaks
down when the colliding `outline` edge is itself just an independently-
computed HLR duplicate fragment of the same corner an already-verified
cross-coplanar/mat-style-change match covers: the dedup was quietly
deleting the correct classified edge in favour of the duplicate, in both
same-product and cross-product forms of the collision, confirmed via
direct debug instrumentation against the synthetic coplanar join.ifc
fixture's NORTH SECTION TILT drawing (all 4 of its own cross-coplanar/
mat-style-change test pairs affected).

Two separate, narrowly-scoped exceptions:

- cross-coplanar always wins over outline in this specific collision,
  unconditionally, in both the exact-endpoint-key mechanism
  (compute_coincident_edge_best_priority(), via a new companion exception
  set since its aggregate min-priority model can't otherwise express
  "outline loses to X but still beats everything else") and the
  collinear-overlap mechanism (compute_coincident_edge_overlap_coverage()).
  A cross-coplanar edge is the result of an explicit, already-verified
  touching-boundary decision -- strictly more informed than a same-line
  outline classification that happens to coincide with it.

- mat-style-change gets the same exact-key exception (equally safe there),
  but its overlap-coverage exception is narrower: gated on the SAME
  outline edge also having a confirmed, genuinely-collinear overlap with a
  cross-coplanar edge somewhere in its own bucket. Case B's own same-
  product nesting (a layer-boundary edge legitimately nested inside part
  of a much longer outline edge) relies on outline continuing to win, and
  Case B never produces a cross-coplanar collision at all -- so this gate
  reliably tells the two apart. An earlier, ungated version of this
  exception was confirmed to also fire for genuine Case B edges in an
  unrelated test pair, adding spurious duplicates there; this fixed it.

Verified: full synthetic suite unchanged (21 passed, 2 skipped, same 1
pre-existing unrelated failure). Full 10-drawing sweep against known-good
improved from 42 to 34 total per-product class-count diffs (2 to 3 fully
clean drawings), with no drawing regressing except one already-parked,
accepted-as-intractable GUID pair in ORTHOGRAPHIC-X (documented in the
project's own known-issues backlog, not fixed here per explicit user
direction not to risk this fix for it).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sboddy and others added 22 commits August 19, 2026 02:51
Two different products can each independently draw their own copy of the
same physical edge (e.g. a shared cross-coplanar boundary) -- both copies
are individually correct, but drawing both is visibly wrong once stroke
opacity drops below 100%. Quantified as common: ~7% of classified edges
are an exact same-class duplicate, another ~7% partially overlap a
same-class edge, across a synthetic fixture and 8 real drawings.

New bpy-free module bonsai/bim/module/drawing/svg_dedup.py,
merge_duplicate_edges(root): collects classified <path> elements by their
class string across every product <g ifc:guid>, clusters same-class edges
via a tolerant parametric collinear-overlap test (perpendicular-distance +
parameter-range-overlap, banded to 5x the base tolerance to absorb
floating-point noise between independently-computed "duplicate" edges),
keeps the longest member of each cluster whole and trims every other
member down to its own non-overlapping remainder (deleting it outright if
nothing survives). Edits/removes the original <path> in place under its
own real product's own <g> -- never a synthetic merged element, so
ifc:guid/product class traceability is fully preserved, unlike
merge_linework_and_add_metadata()'s own existing polygon-merge phase
(a deliberately different trade-off for wall/slab cut-fills, not
appropriate to replicate here). Runs as a fixed-point loop (capped at 5
passes) since trimming one cluster can occasionally reveal a new overlap
between two other members' own leftover remainders.

An initial version used shapely.LineString.intersection().length for the
overlap test; this proved unreliable against real data -- two
independently-computed copies of the same physical edge are collinear
only up to floating-point noise, and GEOS treats two almost-but-not-
exactly-parallel segments as crossing at a single point rather than
overlapping. Against a real fixture with 20 known duplicate/overlap
groups, that version resolved only 2. Replaced with plain parametric math
(no shapely dependency at all), which resolved all 20, and all-but-one
across 8 real drawings before the fixed-point loop resolved the last one
too -- confirmed via a union-find-based measurement script, 0 residual
duplicate/overlap groups across all 9 fixtures after one pass.

Wired into operator.py's generate_linework() at both cut_mode branches
(BISECT/OPENCASCADE), immediately after merge_linework_and_add_metadata()
(needed for its "cut"/"projection" <g>-class labelling), gated by a new
EPset_Drawing boolean MergeDuplicateEdges (camera property
merge_duplicate_edges, default OFF, only takes effect when
use_edge_classification is also on) -- existing drawings are unaffected
unless a user opts in. Full prop.py/ui.py/tool/drawing.py read-side wiring
added, mirroring the existing classification-toggle pattern.

Tests: test/bim/module/drawing/test_svg_dedup.py, 6 cases, plain pytest
(no Blender) -- loads svg_dedup.py directly by file path rather than via
the bonsai.bim.module.drawing package, since importing anything under
bonsai.bim first runs bonsai/bim/__init__.py, which imports bpy
unconditionally.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leftover debug-instrumentation dependency from tracing a bug fixed
earlier on this branch (0952a226ae) -- zero std::cout/cerr usage
anywhere in the file.

Found during item 8's pass-2 code review (pre-merge checklist).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The comment claimed this function reuses find_cross_coplanar_matches()'s
own normal-parallel + plane-distance test. No longer true since the
cross_coplanar::resolve_edge_location_subrange() redesign folded that
test into its own per-location candidate search rather than leaving it
as a standalone reusable function -- this is, and always has been since
that redesign, an independent implementation. Corrected the comment so
a future reader doesn't assume a tolerance/heuristic change to
cross_coplanar automatically applies here.

Found during item 8's pass-2 code review (pre-merge checklist).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "heal a genuinely degenerate/self-intersecting shape before handing
it to a boolean op" try/BRepCheck_Analyzer/ShapeFix_Shape pattern was
duplicated near-identically at both of its call sites (the halfspace-cut
loop's BRepAlgoAPI_Cut, and the section-cut path's BRepAlgoAPI_Section).
Extracted into one shared repair_if_invalid() helper.

Found during item 8's pass-3 code review (pre-merge checklist).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_restorable_at() was transforming the sample point unconditionally
before checking whether point_on_foreign_face() even found a match,
wasting the transform on the common non-restorable-candidate path (no
coincident foreign face at all -- the majority case, since only a
minority of hidden edges are genuine depth-ties). Reordered to only
transform once a match is confirmed.

Found during item 8's pass-3 code review (pre-merge checklist).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The halfspace-cut loop's silent `continue` on BRepAlgoAPI_Cut
failure/null-shape had no diagnostic, unlike this same function's own
outer catch block. Added a logger_.Warning() call on both drop paths so
a missing part is at least visible.

Deliberately did NOT add a fallback to the uncut original part -- a
prior attempt at recovering more here (via heal_for_linework()'s fuller
repair fallback for the same failure class) silently reconstructed an
entire cut-away opening on a real test box, a confirmed regression
already documented in this same code's own comments. Logging visibility
only, not a behavior change.

Found during item 8's pass-3 code review (pre-merge checklist).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subtraction_settings_ == ALWAYS (what every real Bonsai drawing uses)
leaves a genuine, intentional naked edge along the cut seam of any
product it halfspace-cuts -- there is no capping face there by design.
product_has_naked_edge couldn't tell that seam apart from a real
architectural opening, so it came back true for essentially every
section-cut product, wrongly re-enabling classify_edge_from_faces()'s
back-facing "seen through an opening" flip purely from being cut, not
from having an opening -- reproducing the exact flush-window-frame
flip bug that flip was built to stop, specifically in section/
elevation views. Now excludes naked edges lying on the drawing's own
cut/projection plane from the signal, since a genuine opening would
only coincide with it by implausible coincidence.

Known-issues backlog item 45. Scope deliberately limited to this
proven cut-plane trigger, not the broader per-product-vs-per-fold
granularity gap the same item also raises, per explicit user decision.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wire_neighbors_of_edge()'s winding self-correction summed
cross(edge_in, edge_out).n directly, proportional to
sin(turn_angle)*|edge_in|*|edge_out| -- length-weighted, not a true
turning-number invariant. For a simple polygon the unweighted sum of
signed turning angles (equivalently, the sign of the shoelace/
signed-area formula) is a reliable winding-sense invariant regardless
of edge length or reflex-vertex count; the length-weighted sum has no
such guarantee and can land on the wrong sign for a wire with several
reflex corners of uneven edge length (crenellated parapets, star/
gear-shaped profiles). Confirmed via direct instrumentation that the
two formulas disagree in sign on a real 10-vertex star column's own
OCCT wire (old=-2.11, new=+1.47).

Known-issues backlog item 46. Shipped without a dedicated end-to-end
regression test: extensive attempts (a crenellated-parapet-wall scene,
a star column swept across 48 camera angles) found no case where the
sign disagreement changes final SVG output, despite the confirmed
disagreement on the underlying computation -- per explicit user
sign-off on this effort-vs-coverage tradeoff. See the known-issues
backlog for the full investigation, so a future session doesn't repeat
the same search without a new angle on why it didn't reproduce.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
classify_edge_from_faces()'s outer-vs-inner-wire candidate
preference unconditionally outranked any inner-wire candidate
regardless of |dot| magnitude. A face with a genuine boolean notch
(not a hole) where the outer-wire neighbour happens to be
near-coplanar (small, noisy |dot|) while the notch's own inner-wire
vertex would give a large, decisive, correct-sign |dot| still lost
to the noisier outer signal.

Not a plain magnitude race between outer and inner candidates: for a
genuine hole (the case 7c74a1a56f fixed), the cap face's own inner
(hole-rim) wire gives an always-wrong-sign dot that can also have
the larger magnitude, so a naive tie-break would reintroduce that
bug whenever the wall's own correct outer candidate happens to be
weak too. Only falls back to inner candidates when the outer signal
is itself near-zero relative to the edge's own length -- narrower
than "outer lost a magnitude race," closer to the actual notch case.
Verified against the existing hole-rim regression test (still
passes) plus the full geom/ suite (still green).

Known-issues backlog item 50. Shipped without a dedicated end-to-end
regression test (construction would need precisely-tuned notch
geometry) per explicit user sign-off, same disposition as item 46.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compute_coincident_edge_overlap_coverage()'s real_scale_i/
real_scale_j rescale a screen-space overlap fraction onto each
edge's true 3D length by dividing by its own projected (screen-space)
length. That projected length was only floored at
Precision::Confusion() (~1e-7), not the pass's own matching
tolerance (~1e-4) -- a long 3D edge viewed at a grazing-but-not-quite
-edge-on angle could produce a projected length in that gap, small
enough to amplify ordinary screen-space noise into a wildly
incorrect real-3D clip range fed to split_edge_by_coverage().

Below `tolerance` the two projected endpoints are already
indistinguishable at this pass's own resolution, so there is no
reliable screen-space line to rescale from regardless -- same
conservative fallback (exact-key-only matching) the curved-edge case
just above it already uses.

Known-issues backlog item 49. Shipped without a dedicated end-to-end
regression test (construction would need a precisely-tuned grazing
camera angle) per explicit user sign-off, same disposition as item 46.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
merge_duplicate_edges() clustered and deleted/trimmed <path> elements
across both "cut" and "projection" <g> groups indiscriminately, but
operator.py's SHAPELY fill-boundary extraction (which runs
immediately after) only scans "projection"-classed groups. If a
genuine duplicate pair spanned one product's "cut" copy and another's
"projection" copy of the same boundary edge, and the cut-side copy
happened to be longer (kept as survivor per _resolve_cluster()'s
length-based rule), the projection-side copy the SHAPELY boundary
scan actually needs would get deleted.

Scoped the initial class-keyed bucketing to (class, cut-or-projection)
instead of class alone, so a cut-side and projection-side copy never
cluster together at all -- same-group-kind duplicates across
different products still dedup exactly as before.

Known-issues backlog item 52.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_set_or_split_edge_path()'s multi-piece remainder branch created
sibling <path> elements with etree.Element("path") -- no SVG
namespace -- while every reader in the pipeline (including this
module's own next-pass query) expects the namespaced tag. Such a
sibling is invisible to findall() on the same in-memory tree,
defeating both the fixed-point loop's own convergence claim and the
SHAPELY boundary scan (known-issues item 52).

Currently dormant, not a live bug: a separate investigation proved a
2+-piece remainder is mathematically unreachable via
merge_duplicate_edges()'s only call path today, given
_resolve_cluster()'s length-descending processing order. Real
landmine for any future change to that order, or any new caller of
_set_or_split_edge_path() -- fixed regardless of current
reachability, and the new test calls the function directly (bypassing
the unreachable public entry point) to cover it.

Known-issues backlog item 53.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
merge_duplicate_edges(root) was called with no tolerance argument,
so the dedup pass always used svg_dedup.py's hardcoded 1e-3 SVG-unit
default regardless of the drawing's own cross_coplanar_tolerance
(EPset_Drawing.CrossCoplanarTolerance). A user who loosens the
C++-side tolerance to handle noisier geometry got no corresponding
effect here -- edges that are "duplicates" by the classification
pass's own looser tolerance could still fail _collinear_overlap()'s
fixed band and ship undeduped.

cross_coplanar_tolerance is a model-space (metres) tolerance while
merge_duplicate_edges()'s own tolerance is in SVG paper-space units,
so this isn't a direct pass-through -- scaled by * self.scale * 1000,
this same class's own established model-to-drawing-unit conversion
(see drawing_to_model_co()'s inverse of it just below).

Known-issues backlog item 54.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fixed-point loop re-walked the whole SVG tree and re-clustered
every (class, cut-or-projection) bucket from scratch on every one of
up to 5 passes, tracked by a single global changed bool with no
per-bucket dirty-tracking -- same shape as the earlier C++-side
performance findings (items 11/34/41).

A bucket's own trimming can only ever reveal a new overlap within
that SAME bucket (buckets are entirely disjoint by class/group),
never in a different one, so an unchanged bucket is guaranteed
identical on every later pass regardless. _run_one_pass() now
returns the set of bucket keys that actually changed, and the caller
restricts the next pass to just those -- the tree still has to be
walked once per pass (lxml has no cheaper way to find this pipeline's
own paths), but unchanged buckets skip the expensive segment-parsing
and O(n^2) clustering work.

Known-issues backlog item 55 (performance bullet). No behavior
change -- verified against the full existing test_svg_dedup.py suite
(9 passed).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
occlusion_clear_subintervals() (SvgSerializer.h) had zero callers
anywhere in the codebase -- its call sites were removed by the
ff2af2d72b redesign, but the ~85-line function and its own comment
(which still claimed to fix a "confirmed real-file regression") were
left behind, misleading to a future reader since none of that logic
actually runs.

Also swept ~8 comments elsewhere in SvgSerializer.h and in
test_svg_edge_classification.py that named other now-deleted sibling
functions (accumulate_edge_coverage(), mat_style_change::
accumulate_mismatch_coverage(), replace_matched_edges()) in present
tense as if they still exist and are called today. Qualified each as
historical/superseded rather than removing the surrounding
explanation, since the design rationale they document is still
accurate for the current code, just fed by a different source now.
Left test-file mentions of touches_foreign_face() alone -- those
describe what a specific historical commit did to already-captured
test values, not a claim about current code.

Known-issues backlog item 31.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_single_material_association() and resolve_layer_projection() both
walked the same product's HasAssociations inverse to find the one
IfcRelAssociatesMaterial -- the loop body was line-for-line identical,
differing only in the final type check each caller does afterward.

Extracted the shared walk into single_material_association(), leaving
each caller's own downstream branching (what kind of material
construct it resolved to) untouched.

Known-issues backlog item 32.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_svg_edge_classification.py (79bd464bb4) was missing the
AGENTS.md-required AI-disclosure header carried by every other file
in the same batch (svg_dedup.py/test_svg_dedup.py both have it), and
wasn't black-formatted.

Known-issues backlog item 55 (mechanical bullets). Purely mechanical
-- no test assertions or fixture values changed, full geom/ suite
still green (27 passed, 2 skipped).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resolve_edge_location_subrange()'s degenerate-side test (does a
candidate face's own interior sit exactly on the dividing plane
between two contributing products?) used Precision::Confusion()
(OCCT's fixed ~1e-7) while every other check in the same function
uses the passed-in tol (svg-cross-coplanar-tolerance, default 1e-4 =
0.1mm) -- an undocumented ~1000x inconsistency with no known
justification beyond "this is what the generated code produced and
it works".

A/B-verified against two real project files (a 6-drawing
representative subset of a full architectural project, and the
dedicated synthetic fixture built for this exact feature): zero
differences on the architectural project; one real, localized change
on the synthetic fixture, filling a genuine coverage gap between two
coplanar slabs' own edges in ORTHOGRAPHIC-X (previously neither
product's edge covered a ~4mm span at their shared seam). No new
gaps or overlaps introduced elsewhere.

Known-issues backlog item 35.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_genuinely_occluded() ray-cast against every loaded intersector in
the drawing, unfiltered. On a real project fixture's heaviest drawing
this was ~84% of total render time (~59M ray-casts, one drawing). Adds
nearby_along_ray(), a bbox-vs-bbox shortlist whose box extends along
view_direction (matching the ray's own test range) rather than reusing
the local, edge-endpoint-scoped box used elsewhere for coincidence
tests -- an earlier attempt at this reused that local box and was
reverted after under-restoring real depth-ties. Verified against both
real fixtures: byte-identical SVG output pre/post, existing suite
green, 3.8-5.8x speedup on axis-aligned drawings (isometric views see
less benefit, expected given the shortlist's own geometry).

Also precomputes point_on_foreign_face()'s per-candidate-edge foreign
face list once instead of recomputing face normals on every sample
point -- correct and avoids a known-anti-pattern shape, though
measured to have no impact on real-fixture timing once the ray-cast
above is fixed.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After eae24a0363 sped up is_genuinely_occluded()'s ray-cast, this
function's own per-candidate-edge work became the dominant remaining
cost (30-88% of total render time depending on view angle). Farms out
the outer per-(product, class)-bucket loop across worker threads via a
shared atomic work index (not a static chunk split -- bucket cost
varies too much with candidate-edge count for that to balance well),
falling back to the existing sequential path below a small bucket
count where thread spawn/join isn't clearly worth it.

The one real hazard: IntCurvesFace_ShapeIntersector::Perform() is not
safe to call concurrently on a shared instance (confirmed against the
vendored OCCT header -- it writes into private, non-atomic result
state that NbPnt()/Pnt() then read). Fixed with one mutex per
intersector, guarding Perform() together with the results it just
wrote -- a single global mutex was rejected (would serialize every
occlusion check); per-thread-duplicated intersectors were tried and
also rejected after a real A/B showed no measurable benefit over the
mutex (the earlier apparent "contention" turns out to be ordinary
compute/memory-bandwidth-bound parallel scaling, not lock waiting --
duplicating the data doesn't fix that).

Verified: deterministic proof of the race fix (forced the threaded
path on even for small test fixtures; 15+ runs each saw hundreds of
genuine contended lock acquisitions with zero corruption or test
failures). Byte-identical SVG output on both real fixtures (all 10
drawings of the small synthetic one, the curated 6-drawing subset of
the large architectural one). Real, production-accurate speedup
(matching Bonsai's own threaded geometry-iteration call) on the
heaviest documented drawing: ~26% total render time (29.4s -> 21.8s).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cross-coplanar/mat-style-change classification toggles this
branch added push their own lines past black's 120-column limit;
wrap them the same way black already wraps every other call in this
file.

Known-issues backlog item 9 (pre-PR linter pass). Purely mechanical,
no behavior change.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cross-coplanar and mat-style-change edges need default.css rules.
There is one active set with my best guess at usefulness. They need
an architect to define them better.
There is another set, commented out, that are used to aid visual
debugging. Bright ugly colours that make it very easy to spot when
something is incorrect.

sboddy commented Aug 19, 2026
edited
Loading

Copy link
Copy Markdown
Contributor Author

Test file and renders: 7894_Join coplanar SVG projection cells using normal vector evaluation.zip

Based on the originals theoryshaw provides in his comment.

Detects pairs of planar faces belonging to different IFC products that
genuinely cross each other in 3D (a diagonal brace piercing a wall, for
example) and classifies the crossing line as a new "face-intersection"
SVG edge class, following cross-coplanar's own classification -> HLR ->
render pipeline (issue IfcOpenShell#3742 follow-on). A sibling to cross-coplanar,
not an extension of it: cross-coplanar only matches coincident edges
between parallel faces, so this needed its own non-parallel face-pair
matching (BRepAlgoAPI_Section, trimmed to both faces' real boundaries,
with SetNonDestructive(true) since candidate faces are legitimately
reused across multiple pairs) and its own fix for a doubled-line risk:
two products sharing a genuine crossing would otherwise each draw their
own copy of it, since the existing cross-product dedup logic
deliberately doesn't collapse same-class duplicates. Resolved by
attributing each crossing to a single product via a deterministic
GlobalId tie-break.

Classification defaults on, rendering defaults off (mirroring
cross-coplanar's own convention), since this pass's per-face-pair cost
is higher than cross-coplanar's own edge-collinearity check and
shouldn't become a default performance tax without real-fixture timing
data to justify it. Threading is deliberately deferred: OCCT 7.8.1
(this repo's pinned version) has no documented safety guarantee for
concurrent BRepAlgoAPI_Section calls, only a forum-documented
mitigation (SetNonDestructive(true), applied here regardless).

Verified: new and all 24 pre-existing edge-classification tests pass;
default (render-off) output is edge-set-identical to the pre-change
baseline on every real-fixture drawing; forcing rendering on finds
real genuine crossings in both real fixtures, each correctly
attributed to exactly one product with no doubled lines.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

sboddy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-on: face-intersection edge classification

Added a third sibling edge class alongside cross-coplanar/mat-style-change: face-intersection, for pairs of planar faces belonging to different products that genuinely cross each other in 3D (e.g. a diagonal brace piercing a wall) — as opposed to being coincident/parallel, which stays cross-coplanar's job. Computed via BRepAlgoAPI_Section, trimmed to both faces' real boundaries, with a deterministic GlobalId-based tie-break so a genuine crossing shared between two products is only ever drawn once, never doubled.

Same conventions as the existing classes: classification on by default, rendering off by default (svg-use-face-intersection-classification / svg-render-face-intersection-edges / svg-face-intersection-tolerance), fully additive — output is unchanged with rendering off. Verified against the same real fixture files used elsewhere in this PR, plus new unit tests.

7412f5c

Collapses face-intersection's two boolean settings
(svg-use-face-intersection-classification /
svg-render-face-intersection-edges) into one. Unlike cross-coplanar and
mat-style-change, this pass has no outline-trimming interaction for a
classification-on/render-off state to preserve -- it never wins a
coincident-duplicate-edge priority contest, since it's deliberately left
unranked/lowest-priority in coincident_edge_class_priority(). A
classification-only state was therefore paying this pass's real
per-face-pair BRepAlgoAPI_Section cost for zero visible effect on every
drawing by default. Mirrors mat-style-change's own existing precedent
in this same file, which already uses a single boolean for exactly this
reason. Default changes from (classify=true, render=false) to a single
false: the feature is now fully inert until explicitly enabled, rather
than silently computing results nobody sees.

Also reworks the Camera panel's edge-classification UI: fixes a
leftover reference to `join_coplanar_surfaces`, a property from an
earlier coplanar-feature iteration that no longer exists (was spamming
the console with errors on every panel redraw), and moves every edge-
classification property out of the main camera panel into its own
collapsible `BIM_PT_edge_classification` sub-panel, grouped into
"Enable additional features" / "Render out" / "Post Process" sections
for readability.

Verified: full edge-classification test suite (26 passed, 2 skipped)
against a rebuilt wrapper; black/ruff clean.

Mixed authorship: the setting-simplification (C++/Python wiring,
ConversionSettings.h, tests) was generated with the assistance of an AI
coding tool. The UI panel rework and bug fix were authored directly by
the human contributor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

sboddy commented Aug 20, 2026
edited
Loading

Copy link
Copy Markdown
Contributor Author

Follow-up: simplified the face-intersection toggle, reworked the classification UI

Two changes on top of the previous face-intersection comment:

  • Collapsed svg-use-face-intersection-classification / svg-render-face-intersection-edges into one boolean. Unlike cross-coplanar/mat-style-change, this pass has no outline-trimming interaction for the two-state split to preserve — it's deliberately unranked/lowest-priority in the coincident-duplicate-edge dedup, so it never affects any other edge. The two-flag version was paying this pass's real per-face-pair cost by default for zero visible effect. Now mirrors mat-style-change's own existing single-boolean pattern in this file. Default also moves to fully off (was classify-on/render-off) — the feature is inert until explicitly enabled.
  • Reworked the Camera panel's edge-classification UI: fixed a leftover reference to a property (join_coplanar_surfaces) from an earlier coplanar-feature iteration that no longer exists — it was spamming the console with errors on every panel redraw. All edge-classification properties moved out of the main camera panel into their own collapsible sub-panel, grouped for readability (enable / render / post-process).

d4e5a04fd9

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

1 participant


Back | FazBrowse Home | New Git URL