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

Coplanar surface boundary lines via a real 2D planar arrangement (alt. to #7908) by sboddy · Pull Request #8895 · IfcOpenShell/IfcOpenShell · GitHub

Coplanar surface boundary lines via a real 2D planar arrangement (alt. to #7908) - #8895

Closed
sboddy wants to merge 8 commits into
IfcOpenShell:v0.8.0from
sboddy:fix-3742-coplanar-svgfill-arrangement
Closed

Coplanar surface boundary lines via a real 2D planar arrangement (alt. to #7908)#8895
sboddy wants to merge 8 commits into
IfcOpenShell:v0.8.0from
sboddy:fix-3742-coplanar-svgfill-arrangement

Conversation

sboddy commented Jul 23, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

PR description (draft, not yet opened as "ready" — known issues remain, see bottom)

Branch: fix-3742-coplanar-svgfill-arrangement
Fork: https://github.com/sboddy/IfcOpenShell/tree/fix-3742-coplanar-svgfill-arrangement
Base: IfcOpenShell/IfcOpenShell v0.8.0 (built directly on current upstream tip,
not on top of theoryshaw's fix-3742-coplanar-bonsai-projection_2 / PR
#7908 — see "Why a
new approach" below).
Status: five commits, pushed to my fork; opening as draft, not
requesting review yet — this description itself documents known issues that
still need fixing before it's ready.


Summary

This is a from-scratch alternative to #7908 (and my own earlier follow-up
branch, fix-3742-coplanar-bonsai-projection_2_sjb, 8 commits deep into
patching that PR's approach). Same goal: remove_coplanar_boundary_lines
should remove duplicate SVG boundary lines between adjacent/contained
same-material IFC elements, so coplanar surfaces of the same material render
as one seamless area instead of showing a visible internal line at the join.

The earlier branch used pairwise 2D line-interval heuristics: classify
each pair of elements (contained/adjacent/same-surface) using 3D geometry,
then decide which SVG line segments are duplicates using bbox/interval
arithmetic on the already-projected 2D lines. After 8 rounds of fixing
concrete counterexamples found by re-testing against a real repro model, a
review concluded this approach cannot reach full correctness, only
approach it
: a genuine internal seam and a true external perimeter edge can
produce identical 2D line data — the two cases only differ in whether
material genuinely continues past that line in 3D, which the 2D
post-processing layer never actually checks. Each round fixed the concrete
cases found so far without closing the general problem; the next test pass
against the same model kept finding new counterexamples.

Why a new approach

Rather than add a ninth heuristic, this branch replaces the whole mechanism
with a real 2D planar arrangement, built once per connected cluster of
mutually coplanar, same-material, same-style elements, using
ifcopenshell.ifcopenshell_wrapper's svgfill library — the same C++
library operator.py's fill_mode == "SVGFILL" branch already uses
elsewhere in this file to merge coplanar cells for hatching/fill output. That
existing code's own comment says the technique "enable[s] coplanar surfaces
from different elements to be joined" — it just wasn't wired into the
line-removal path this feature needs.

An arrangement edge is only dissolved (removed) when both cells it
separates are genuinely owned by the cluster. A true external edge always
has an unowned (background) cell on its far side — open air, a different
element, or nothing — so it structurally cannot be merged away, regardless
of what the 2D line data happens to look like. This resolves the exact
ambiguity that defeated the pairwise-heuristic approach, by construction
rather than by enumerating cases.

Because this is a structurally different mechanism (one arrangement per
cluster, not per-pair heuristics with a 10-round fixed-point iteration), it's
built as an independent branch off current upstream v0.8.0 rather than as
another commit on top of the old approach — the two are not compatible
patches of the same code.

What's implemented

  • New join_coplanar_surfaces toggle (prop.py), alongside the existing
    generate_material_layers — same BoolProperty + EPset-persistence
    pattern. (Also had to add EPset persistence to generate_material_layers
    itself, and restore both from EPset_Drawing in import_camera_props
    (tool/drawing.py) — without it, a fresh camera datablock recreated during
    create_drawing silently resets the toggle to its Python default before
    the drawing is ever generated. The prior branch hit and fixed this exact
    bug for its own toggle; it applies here too.)
  • New join_coplanar_boundary_lines method (operator.py), wired into the
    same two cut_mode branches (BISECT/OPENCASCADE) the old approach used,
    gated on both toggles being on:
    1. Build a same-material-coplanar graph across all elements in a
      projection group (reusing are_coplanar_and_adjacent's 3D classification
      — AABB, shared-vertex/edge proximity, camera-aligned dominant-normal
      coplanarity, shared-plane check, depth-range check — verbatim; it was
      never the buggy part). Take connected components as clusters — this
      replaces the old approach's per-round _consumed/_pristine_original
      bookkeeping for multi-neighbour coordination with something that's
      correct by construction.
    2. For each cluster with 2+ members, feed every member's current raw line
      segments into one ifcopenshell_wrapper planar arrangement
      (context(EXACT_CONSTRUCTIONS, 1e-3), .build()).
    3. Determine which member(s) each resulting face belongs to by checking,
      for each of the face's own boundary edges, whether that edge's line/
      interval overlaps any member's original segments there. (Earlier
      attempt used point-in-polygon against each member's own linework
      polygonized independently — this failed twice: exact contains()/
      touches() misses degenerate sliver faces where two independently-
      generated, nearly-but-not-quite-parallel copies of "the same" edge cross
      at a shallow angle; and shapely.polygonize() routinely fails to close
      a complex multi-void element's own linework into a single polygon at
      all — which was silently skipping the most important, most complex test
      case entirely. The edge/interval approach needs neither.)
    4. An edge dissolves only if both adjacent faces have at least one
      owner from the cluster (either owner, doesn't matter which). Kept
      otherwise.
    5. context.merge() the dissolved edges, re-write() for final geometry,
      then attribute each surviving boundary edge back to whichever original
      member(s) drew a segment on that same line/interval (so results still
      land in each element's own <g> group — needed for move_elements_to_top's
      BringToFront class lookup and other per-GUID consumers elsewhere in
      this file).
    6. A same-group interval-merge safety-net pass cleans up any remaining
      near-duplicate slivers left in a single group after attribution.

Verification

Headless (Blender 4.5.3 debug build, dedicated test profile) against the same
repro model used throughout the original investigation (coplanar join.ifc,
linked from the PR #7908 comment):

  • All 4 drawings (PLAN_VIEW, NORTH SECTION, SOUTH SECTION,
    REFLECTED_PLAN_VIEW) generate cleanly — no crashes, no skipped clusters.
  • NORTH SECTION ("A COMPLICATED WALL WITH COMPLICATED VOIDS AND INFILLS" —
    the actual motivating test case, a 15-element assembly): total remaining
    paths across the whole assembly drops from 108 (raw, undeduped) to 15.
    The old branch's best-ever result, after 8 commits, was 43 remaining paths
    with several known, unresolved residual lines.
  • REFLECTED_PLAN_VIEW: the specific bug that triggered this whole
    redesign — a contained plug (3taVdKrmD45ehPoNLdDmdt) flush with its
    containing wall's (3_TyYp2_X03PtH$QzJHWjh) own exterior face, which the
    old pairwise-heuristic approach deleted as a false duplicate — is confirmed
    correctly preserved, while genuine interior seams elsewhere in the same
    5-element cluster correctly vanish entirely.
  • PLAN_VIEW and SOUTH SECTION: plausible 16-23% total path-count reductions,
    nothing alarming; every flagged "coverage reduction" (checked via a
    reusable line-coverage-diff script) traced back to a legitimate, fully- or
    partially-intentional interior seam removal between correctly-clustered
    same-material pairs, not a regression.

Follow-up round: ownership fixes, materialless-style merging, residual slivers

Further testing against NORTH SECTION (the 15-element assembly) surfaced
three more issues, addressed in two follow-up commits on top of the initial
arrangement rewrite:

  1. Concave notch/void wrongly merged away, and internal self-folds wrongly
    merged.
    The original "either owner, doesn't matter which" edge-dissolve
    rule (see step 4 above) turned out to be too permissive in two specific
    cases: a concave notch in one element's own silhouette (one side of the
    edge is genuinely empty, e.g. 1eW6OACmXD1hWJLDeB6erW's void with
    2x0$XNlFH6FxlvS7eYJttx fitted into it — the hole outline was being
    deleted), and an internal fold/T-junction between two of a single
    element's own non-coplanar facets (a self-fold line was being deleted as
    if it were a duplicate). Fixed (6869b186f8) by replacing the single
    "either owner" rule with hybrid per-member ownership: prefer a
    point-in-polygon test against each member's own closed linework
    (shapely.polygonize(), with a small distance tolerance for degenerate
    slivers) when that member's own geometry actually closes into a polygon;
    fall back to the original edge/interval-overlap test only for members
    whose linework doesn't close on its own (e.g. a contained element whose
    host never independently redraws its boundary). An edge is now only
    dissolved when the two faces it separates do not share a single common
    owner that owns both sides exclusively — this distinguishes "one shared
    owner, one genuinely different/absent owner" (notch, self-fold — keep the
    edge) from "two elements both genuinely present on both sides" (true
    duplicate — dissolve it), without breaking the contained-element case that
    an earlier, stricter attempt at this same fix (requiring two distinct
    members to draw the shared edge) regressed.
  2. Materialless same-style elements weren't clustering. Elements with no
    material assignment but a shared style were being treated as unrelated,
    so genuinely coplanar/adjacent faces between them kept a visible seam.
    Fixed in the same commit: the cluster-graph pairwise match now also
    admits a pair when both sides have no material key but do share a style
    key, alongside the pre-existing material-key-match condition.
  3. Missing fold lines between non-orthogonal visible faces (7 reported
    GUIDs).
    This was originally suspected to be a gap in the C++ HLR
    projection itself — investigated by extending
    hlr_calc::extract() in SvgSerializer.h to combine the Rg1LineVCompound/
    RgNLineVCompound (smooth/sewn edge) HLR buckets alongside the
    already-combined sharp-edge buckets, built and tested against a real
    headless run. That hypothesis was disproven — the rebuilt wrapper
    produced byte-for-byte identical SVG output to the unpatched wrapper, so
    the change was reverted rather than left in as an unverified, ineffective
    patch. Re-analysis (prompted by direct review of the intermediate SVG
    output) found that the fold-line fix from item 1 above had already
    resolved most of the reported GUIDs; the two that still looked
    incomplete when checked in isolation turned out to be a false
    positive
    : each element's own group correctly stops short of the shared
    boundary because the adjoining element's own group draws that segment
    instead — exactly the intended result of eliminating a duplicate line
    between two coplanar faces, not a missing line. The one genuine residual
    was a much smaller case: 3BAoBuh$b1TfOXxgvPg4$x /
    1j_tfYuaT7vPr1$CI4kvBs's shared boundary crosses at a shallow, slightly
    tapering angle (a ~3e-5 unit taper over its length rather than being
    perfectly parallel), which made the planar arrangement produce a small
    knot of degenerate sliver faces at the crossing instead of one clean
    merged edge, leaving a 0.124-unit leftover line on one side. Fixed
    (6c68268f80) with a scoped cleanup pass, added to the existing
    same-group interval-merge safety-net: drop any resulting same-key
    interval shorter than RESIDUAL_SLIVER_LENGTH (0.5 units — comfortably
    above the observed artifact, comfortably below this model's smallest
    genuine line, ~2.83 units) only if another cluster member's own
    geometry demonstrably overlaps that same span, so it can't touch a
    short segment that's a real, single-owner feature.

Verification for this round: full 4-drawing regression sweep after each
commit (notch, self-fold, both original full-duplicate pairs, the two
"false positive" pairs, the REFLECTED_PLAN_VIEW true-exterior-edge case,
materialless-style clustering, and the coverage_check.py line-coverage
diff across all 4 drawings) — no regressions found from either commit.

Follow-up round 2: false same-singleton veto at 3-way wall corners

Re-testing the 15-element NORTH SECTION assembly after the round above
surfaced a further regression: several small plug/wall elements kept an
unwanted leftover line where a genuine duplicate boundary should have
dissolved (1ejyJRvtzF78cPVFhTJgKN, 1$k4ji92jF785NlbFUg_kn,
3nnrFqIDPDIhtn97tgu$mm each carried a single stray path; 0MmPs4lGP0zgpUVE$tStUb,
3_TyYp2_X03PtH$QzJHWjh, 0gbhn0X9z7BBLNF58AuXfq, 0ZA0gbiYP4yeaGvSMlNATl
each had one or two unwanted paths mixed in with correct ones).

Root cause: at a corner where three coplanar members meet (an "L"-shaped
run of small wall/plug elements), independently-projected copies of the
same corner vertex land a few hundred-thousandths of a unit apart. The
arrangement turns this into a razor-thin sliver face right at the corner,
genuinely co-owned by the member with a closing polygon there and by
a fallback-only neighbour across the real duplicate edge. Since
authoritative_owners_of only ever reflects the side with a closing
polygon, face_authoritative_owners for that sliver collapsed to a
single member on both sides of what was actually a genuine external
duplicate — indistinguishable, to the same-singleton veto from the
previous round, from a true self-fold.

Fixed (455d699afc) by comparing the full face_owners (authoritative +
fallback) instead of just face_authoritative_owners in the veto: a true
self-fold/notch has no other cluster member touching either face at all
(fallback included), so it's still protected; the corner-sliver case now
correctly reveals the second, fallback-only member as a co-owner and
merges. One-line change, deliberately not a further rewrite of the
ownership/fallback machinery itself, given the last two rounds of
regressions on this exact veto both came from broader changes to it.

Verified via a standalone reproduction of the affected clusters (confirming
the mechanism before touching any code) followed by a full 4-drawing
regression sweep: all 7 originally-reported GUIDs' unwanted lines are gone
(plus one more analogous instance found independently in PLAN_VIEW), and
every previously-fixed case (notch, self-fold, full-duplicate pairs,
contained plug, residual-sliver cleanup, materialless-style clustering)
remains correct. coverage_check.py across all 4 drawings showed losses
on exactly the line-keys matching the reported bug and nothing else; the
one case where a GUID's own path count shifted (0lAcaRBlH1SwCNj5KGpKgF
in PLAN_VIEW, 6→0 paths) was confirmed to be the pre-existing "attribution
is greedy/first-match" cosmetic limitation (see Known issues below) — the
identical geometry now renders via a neighbouring GUID's group instead,
not a real loss.


Major addition: C++ "cross-coplanar" edge classification (issue #3742), alongside the Python approach

Every regression across the three rounds above traced to the same root cause:
once two elements are linked as "in the same cluster" for merging, every edge
of both becomes fair game for one shared merge decision — a narrow, even
correct, reason to link two elements ends up entangling their entire
boundaries (the notch/self-fold veto, then the corner-sliver veto, are two
different symptoms of this one structural issue). Working from already
-projected 2D SVG lines makes this hard to avoid: HLR projection is lossy
(it discards which 3D face an edge came from), so "is this really a
duplicate, or a different feature that happens to project to the same
line" is often genuinely ambiguous by the time Python sees it.

This addition takes a different approach for the OPENCASCADE linework_mode
(SvgSerializer.cpp/.h, IfcOpenShell/IfcOpenShell core, not just Bonsai):
extend the existing, already-shipped single-object 5-class SVG edge
classification system (issue #3668 — boundary/outline/sharp/crease/
flush, classify_edge_from_faces()) with a 6th class, cross-coplanar,
applied before HLR ever draws a line, using real, exact, un-projected 3D
BRep faces, at per-edge granularity. An edge is cross-coplanar only when
its entire length lies on a coincident, same-style/material face of a
different product. Because the classification is per-edge rather than
per-cluster, linking two elements can never affect an edge that isn't itself
genuinely coincident — the entanglement failure mode is structurally not
possible here, not just guarded against.

Implementation:

  • Three new settings (ConversionSettings.h): SvgUseCrossCoplanarClassification
    (master enable, only takes effect alongside SvgUseEdgeClassification),
    SvgRenderCrossCoplanarEdges (emit toggle, default off — hidden by
    default, like flush), SvgCrossCoplanarTolerance.
  • One representative style/material identity resolved per product
    (geometry_data::cross_coplanar_style_instance), not per-face/per-layer —
    per-item shapes are transformed and concatenated away by the time
    compound_local exists (IfcGeom::Representation::BRep::as_compound()),
    so this is deliberately a simplification: correct for single-material
    elements, or layered elements where the touching layer happens to be the
    one that resolves first. A known limitation, not a bug (see below).
  • 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
    (matching style identity, parallel normals, coincident planes via
    BRepAlgoAPI_Common), classifying an edge as cross-coplanar only on
    full-length containment in the overlap region — a partially-overlapping
    edge is left untouched (v1 scope; true sub-edge topological 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.
  • Bonsai wiring (prop.py/ui.py/tool/drawing.py/operator.py) mirrors
    the existing issue Drawing generation feature request : Having a crease threshold setting #3668 property/EPset/UI pattern exactly.

Verified via docker/ifcos_env (built both IfcConvert and the Python
wrapper):

  • Toggle off: byte-identical to the existing baseline on the synthetic
    test model across all 4 drawings — fully inert until explicitly enabled.
  • Toggle on, synthetic model: 44 genuine duplicate edges removed across
    the 4 drawings, zero false additions, and every previously-established
    protected case (notch, self-fold, both full-duplicate pairs, both
    false-positive pairs, the residual-sliver case, the contained-plug case)
    completely unaffected.
  • Toggle on, a real, independent project (XXX-Project file-XXX,
    855 elements, EXISTING EAST ELEVATION): 726 genuine duplicates removed,
    zero false additions. The originally-reported real-world bug that
    triggered this whole investigation — a return wall running end-on to the
    camera, whose end happens to coincide with a perpendicular wall's broad
    face at their corner — is correctly left at its true minimal boundary
    (matching the raw/undeduped count exactly) rather than over-merged, because
    this mechanism never makes that mistake in the first place; it isn't
    "detecting and reversing" a bad merge, there's simply no path to one.

This is intentionally additive, not a replacement: join_coplanar_boundary_lines
(Python, above) still exists and still covers BISECT cut_mode, which this
C++ addition doesn't touch. Once this is confirmed solid across more real
projects, simplifying/retiring the Python path for the cases this new
mechanism already covers is a reasonable follow-up, not done here.


Follow-up round 3: fixing the cross-coplanar matching predicate, then per-face material resolution

Testing the shipped cross-coplanar classification against purpose-built cases
in the synthetic coplanar join.ifc model (six specific reported cases, all
in NORTH SECTION) surfaced two systemic bugs in find_cross_coplanar_matches()
itself — not edge cases, but the algorithm's core predicate being wrong for
the majority of real "duplicate boundary" scenarios:

  1. Wrong geometric predicate. The matching test used BRepAlgoAPI_Common(face_i, face_j)
    to check whether two coplanar faces' areas overlap, rejecting the pair if
    the resulting area was ~0. That's correct for two fully-duplicated,
    perfectly-stacked faces, but wrong for the far more common case this
    feature exists to solve: two distinct, adjacent faces that share only a
    boundary (side-by-side slabs, a wall's void boundary coincident with a
    plug's outer boundary) have zero area in common by construction — there's
    no area to intersect, only a shared edge. Confirmed directly from raw SVG
    coordinates: two genuinely-adjacent objects' shared edges differed by a
    ~1.6e-6 unit gap (correctly rejected by the old predicate, incorrectly so
    — this pair should have matched); a separate pair that appeared to work
    only did so because their shared edges happened to differ by a similarly
    tiny amount in the opposite direction (a ~1.2e-7 unit accidental
    overlap) — i.e. the old predicate's apparent successes were luck, not
    correctness. Fixed by replacing the face-area-overlap test with a direct
    edge-to-edge coincidence test: collect each face's boundary edges
    (including inner wires, so void/hole boundaries are covered), and for each
    edge, check whether every other collinear edge's projected interval
    (unioned) fully contains it — the same line-key/interval-union idea used
    throughout this PR's Python approach, now over real 3D BRep edges instead
    of projected 2D lines.
  2. Style used as a material proxy, with no priority. The matching only
    compared cross_coplanar_style_instance; two products with genuinely
    different materials but a coincidentally-shared rendering style were
    wrongly matched. Fixed by resolving material via a new schema-agnostic
    helper (mirroring mapping::get_single_material_association()'s simple
    cases: direct IfcMaterial, or the first layer of an
    IfcMaterialLayerSet(Usage)) and giving it priority over style: if either
    side resolves a material, both must be present and equal; style is only
    the fallback when neither side has one.

svg-use-cross-coplanar-classification's default was also flipped to on
(still gated behind svg-use-edge-classification).

Verified via docker/ifcos_env: all 6 originally-reported cases now resolve
correctly (4 previously-missed genuine matches now caught, 1 previously-wrong
false match now correctly rejected, plus most of a 6th complex-wall case's
internal edges now matching where none did before). Full 4-drawing regression
sweep on the synthetic model and the XXX-Project file-XXX real-project scratch check
both remained purely subtractive vs. baseline (zero unexpected paths), with
noticeably more genuine duplicates now caught than the original, buggier
predicate ever found.

v2: per-face material resolution for layered elements

Fixing the predicate surfaced the next concrete gap directly: two layered
slabs sharing a Corten 1 first layer but differing in their second layer
(Concrete 1 vs Concrete 2) got both boundaries — the genuinely-matching
Corten one and the falsely-matching Concrete one — classified cross-coplanar,
because the single per-product material identity (documented as a known v1
limitation above) is applied uniformly to every coincident face pair between
the two products, with no way to tell which face belongs to which layer.

The obvious-looking fix — actually split each layered product into one solid
per layer and thread each slice's real material through — turned out to be a
dead end as a starting point: IFC material association is inherently
product-level (layers only subdivide it as an attribute, never as separate
geometry), and AbstractKernel::apply_layerset()/apply_folded_layerset()
default to throw not_implemented_error() with no kernel override
anywhere
— confirmed empirically (enable-layerset-slicing=True errors out)
and by inspection (the only real splitting logic, in
kernels/opencascade/layerset.cpp, is called exclusively from commented-out
dead code). It's dead-by-default since Bonsai never enables the setting, so
this isn't a live regression, but building out that currently-nonexistent,
shared kernel feature just to fix per-face matching would be a much larger,
riskier change than the problem warrants — it would also risk a new visual
regression, since splitting a product into multiple solids exposes new
internal seam edges (where a product's own layers touch each other) that
the existing edge-classification system has no reason to hide today.

Instead, this resolves per-face material via a pure geometric lookup, no
splitting required: IfcMaterialLayerSetUsage's own LayerSetDirection/
DirectionSense/OffsetFromReferenceLine/MaterialLayers attributes already
describe exactly where each layer sits; combined with the shape's own
measured bounding-box extent along that axis (which calibrates the raw,
unscaled IFC LayerThickness values against real geometry — sidestepping any
need to know the project's length-unit scale, and correct regardless of any
scale baked into placement, since bounding-box extent is measured before the
final transform and rigid transforms preserve length), a face's centroid is
projected onto the layering axis and binary-searched into the right layer —
one dot product and a lookup, entirely within SvgSerializer.cpp/.h, zero
changes to mapping.cpp, Converter.cpp, ConversionResult.h,
AbstractKernel.h, layerset.cpp, or taxonomy.h, and zero risk to the
rendered/HLR geometry. 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,
zero additional cost — for the common non-layered case.

Verified against the reported case directly: the Corten/Corten face pair now
matches, the Concrete 1/Concrete 2 face pair now correctly rejects. Re-checked
all 6 originally-reported cases plus this one — all still resolve correctly;
two cases with identical 2-layer stacks on both sides now correctly match
both layer boundaries instead of just one. Full 4-drawing regression sweep
remained purely subtractive (zero unexpected paths) with a few more genuine
duplicates now caught than the previous round; XXX-Project file-XXX real-project
scratch check was unchanged (no matching false-positive scenario existed
there, so a clean no-op as expected, not a regression).

Known issues — not resolved yet, this draft is not ready for review

  • Cross-coplanar material resolution is still per-product for non-layered
    elements, and per-layer (not per-face) even for layered ones
    — the v2
    work above resolves material per layer for elements with a 2+ layer
    IfcMaterialLayerSetUsage, correctly distinguishing which layer a face
    belongs to. It does not yet handle non-layer-set multi-material constructs
    (IfcMaterialProfileSet, IfcMaterialConstituentSet), which still fall
    back to no resolved material at all, same as before this round.

  • No sub-edge splitting for cross-coplanar — a partially-overlapping
    edge is left entirely as its original class rather than split into a
    cross-coplanar segment plus a normally-classified remainder. Deliberate
    v1 scope decision, not yet implemented. Confirmed on the complex-wall test
    case: 6 of 9 same-material neighbours now fully match; the remaining 3
    have only partial edge overlap and fall squarely into this known,
    deferred limitation rather than being a new bug.

  • Fallback-attribution warnings on some clusters. A handful of clusters
    (e.g. two of the 2-element "shared edge" test pairs, and the main
    REFLECTED_PLAN_VIEW 5-element cluster) log a small number of edge pieces
    (6-12) that needed fallback attribution — meaning the final geometry was
    correct, but no single original member's own segment interval cleanly
    covered that specific piece, so it was attributed by default rather than
    precisely. Needs investigation: likely notch/T-junction boundaries where
    the arrangement introduces a genuinely new vertex not colinear with any
    single original segment's own interval.

  • Attribution is greedy/first-match, causing asymmetric provenance. When
    a surviving edge is covered by more than one member's original segments
    (the normal case for a true external edge duplicated by both sides), the
    whole piece is attributed to whichever member is processed first, not
    split proportionally. Observed concretely: a fully-contained inner element
    can end up with zero paths in its own <g> group even though its
    boundary renders correctly via the neighbour's group. Visually correct,
    but asymmetric — could confuse anything that keys off a specific GUID's own
    linework (e.g. per-element highlighting/selection in an SVG viewer).

  • Not yet checked against every historical regression case from the old
    branch's 8 rounds individually (offset-wall-corner in particular) — only
    spot-checked; it was expected to need no special handling under this
    design (a real planar arrangement represents a genuine corner as two edges
    meeting at a vertex, not a false shared interior cell) but this has not
    been directly confirmed against the model's own such test case yet. (The
    hole-on-edge case is now confirmed fixed — see notch/self-fold ownership
    fix above.)

  • The bundled fixes from the old branch (empty-material wildcard fix,
    AXIS3-only camera-face fallback, toggle EPset persistence for
    generate_material_layers) are re-included here where relevant but not
    re-verified independently in this branch.

sboddy and others added 4 commits July 24, 2026 00:29
Adds a `join_coplanar_surfaces` drawing toggle (alongside the existing
`generate_material_layers`) that removes duplicate SVG boundary lines
between adjacent/contained same-material IFC elements, so coplanar
surfaces of the same material render as one seamless area.

A prior attempt at this (PR IfcOpenShell#7908 and its follow-ups) used pairwise 2D
line-interval heuristics to decide which lines were duplicates. That
approach is fundamentally underdetermined: a genuine internal seam and a
true external perimeter edge can produce identical 2D line data, and no
amount of additional bbox/interval heuristics can tell them apart in
general - only material-continuity information can.

This instead builds one real 2D planar arrangement per connected cluster
of mutually coplanar, same-material, same-style elements (via
ifcopenshell_wrapper's svgfill library, already used elsewhere in this
file for SVGFILL fill-mode hatching), and only dissolves an arrangement
edge when both cells it separates are genuinely owned by the cluster - a
true external edge always has an unowned (background) cell on its far
side, so it structurally cannot be merged away. Ownership is determined
by edge/interval overlap against each member's own raw segments rather
than point-in-polygon against a reconstructed per-member polygon, since
elements with complex voids/notches routinely fail to close their own
linework into a single polygon at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two follow-up fixes to join_coplanar_boundary_lines found by testing
against NORTH SECTION's wall assembly:

1. Ownership determination previously considered an edge dissolvable
   whenever both adjacent arrangement faces had *any* owner, without
   checking whether that owner was genuinely two different elements
   agreeing on a shared boundary. A single element's own line can make
   both faces it borders look "owned" by that one element too - a
   concave notch/void cut into its own silhouette (one side is void, not
   material), or an internal fold between two of its own non-coplanar
   facets - and both were being wrongly dissolved as if they were
   duplicates.

   Fixed with a hybrid per-member ownership test: point-in-polygon
   (side-aware) when a member's own segments close into a valid polygon,
   falling back to the previous edge/interval-overlap test only when they
   don't, plus a veto that blocks dissolving an edge whenever a single
   member's own polygon test exclusively owns both adjacent faces. The
   veto specifically requires the *same singleton* owner set on both
   sides, not just any overlap, since two genuinely different elements'
   polygons legitimately touch at their real shared boundary too.

2. Clustering required a material match for every pair, which meant
   elements with no material assignment at all could never join a
   cluster even when they share a real, non-empty style assignment.
   Style equality is already required unconditionally; this only lifts
   the material requirement specifically when neither side has a
   material to compare, so two elements with neither material nor style
   still don't wildcard-match each other.

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

Two independently-generated copies of "the same" shared boundary aren't
always just offset in parallel - occasionally one tapers slightly along
its length relative to the other (a few hundredths of a unit over the
line's extent). Where they cross, the arrangement can produce a small
knot of degenerate faces rather than one clean edge: most of the shared
line still dissolves correctly, but a short residual crumb survives in
one member's group instead of fully disappearing.

Extends the existing same-group interval-merge safety-net: after
merging same-key intervals as before, drop any resulting interval
shorter than a dedicated threshold if another cluster member's own
geometry genuinely overlaps that same span. A real, single-owner
feature would never coincide with a different member's line at the
exact same place, so this only ever fires on leftovers from what should
have been a multi-member shared (and fully dissolved) boundary - a
legitimate single-sided edge at the same line key but a non-overlapping
interval is left untouched.

Verified against the full regression set from the previous round (notch,
self-fold, contained, full-duplicate, and materialless+same-style cases)
plus a 4-drawing coverage sweep: no regressions, only the intended
residual slivers removed (3 total: the originally-reported case plus two
more of the same kind found in the same wall assembly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
At a corner where three coplanar members meet (an "L"-shaped run of
small wall/plug elements), independently-projected copies of the same
corner vertex land a few hundred-thousandths of a unit apart. The
arrangement turns this into a razor-thin sliver face right at the
corner, genuinely co-owned by the member with a closing polygon there
and by a fallback-only neighbour across the real duplicate edge. Since
authoritative_owners_of only ever reflects the polygon side,
face_authoritative_owners collapsed to a single member on both sides
of what was actually a genuine external duplicate, so the same-
singleton veto (added to protect true self-folds/notches) wrongly kept
the edge.

Compare the full face_owners (authoritative + fallback) instead of
just face_authoritative_owners in the veto: a true self-fold/notch has
no other cluster member touching either face at all, fallback
included, so it's still protected; a corner-sliver case now correctly
reveals the second, fallback-only member as a co-owner and merges.

Verified via a standalone reproduction of the affected clusters and a
full 4-drawing regression sweep: all 7 originally-reported GUIDs'
unwanted lines are gone (plus one more analogous instance found in
PLAN_VIEW), and every previously-fixed case (notch, self-fold,
full-duplicate pairs, contained plug, residual-sliver cleanup,
materialless-style clustering) remains correct, with coverage_check.py
showing no unexpected losses or gains beyond the intended fix.

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

sboddy commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@theoryshaw Ta-da!!!

Could do with some good rounds of testing, but I think that this one may have cracked it.

sboddy commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

@theoryshaw Yeah, I spoke too soon. It's very good on your synthetic tests. It falls down on a real model. While my best development of your algo had some issues on my main model for an elevation, this new method is currently worse - even more lines disappear, which is frustrating. The situation is even worse in an isometric. The older model again misses some lines, the new model is pretty terrible with many, many more missing lines.

Not sure I'm up to the challenge of the other alternative the AI proffered. That was to do boolean unions on objects before giving them to the serialiser. That sounded like a nightmare. But it is beginning to feel like this is not entirely possible - not without serious disruption in pretty gnarly code areas.

sboddy commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

I'm trying YAP (Yet Another Pivot). I have had a Claude chat, and it seems like it might be better (the plan so far looks good) to implement this as part of edge classification in the SvgSerialiser in C++ rather than in Python. At the point of the categorisation the code has access to the all the necessary 3D geometry, and is not trying to use tricks to reverse engineer the 3D information necessary. It looks like this might give a much more robust solution that doesn't suffer from difficult to resolve edge cases due to the post serialisation hoops. This would also mean that this cross-coplanar edge elimination will be usable from IfcConvert too, which the post serialisation python code would not enable.

sboddy and others added 3 commits July 26, 2026 21:58
…hell#3742)

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
Mill Cottage Renovations 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ea overlap, material priority over style

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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nts (v2)

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); Mill Cottage real-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).

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

sboddy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseeded by #9330

sboddy closed this Aug 20, 2026
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