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

Fix contained-pair and multi-neighbour cases in remove_coplanar_boundary_lines by sboddy · Pull Request #8891 · IfcOpenShell/IfcOpenShell · GitHub

Fix contained-pair and multi-neighbour cases in remove_coplanar_boundary_lines - #8891

Closed
sboddy wants to merge 8 commits into
IfcOpenShell:fix-3742-coplanar-bonsai-projection_2from
sboddy:fix-3742-coplanar-bonsai-projection_2_sjb
Closed

Fix contained-pair and multi-neighbour cases in remove_coplanar_boundary_lines#8891
sboddy wants to merge 8 commits into
IfcOpenShell:fix-3742-coplanar-bonsai-projection_2from
sboddy:fix-3742-coplanar-bonsai-projection_2_sjb

Conversation

sboddy commented Jul 23, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Branch: fix-3742-coplanar-bonsai-projection_2_sjb
Fork: https://github.com/sboddy/IfcOpenShell/tree/fix-3742-coplanar-bonsai-projection_2_sjb
Base: built on top of theoryshaw's fix-3742-coplanar-bonsai-projection_2 (upstream PR #7908), which itself fixes #3742.
Status: commits pushed to my fork; opening as a draft PR against
IfcOpenShell/IfcOpenShell's fix-3742-coplanar-bonsai-projection_2 branch
(confirmed via gh pr view 7908 that this branch lives directly in the
upstream repo itself, not a separate fork).


Summary

Follow-up to #7908. That PR's remove_coplanar_boundary_lines removes duplicate
SVG boundary lines between adjacent, same-material, coplanar IFC elements, but
left one class of case explicitly unresolved (documented in the branch's own
knowledge-transfer notes as the "Known Unresolved Case"): a small same-material
element fully contained inside a larger one — e.g. a masonry infill/repair
"plug" embedded in a long existing wall — still shows visible internal boundary
lines around the plug.

This was reported concretely against theoryshaw's own test model
(coplanar join.ifc, linked from
PR #7908 comment):
a long Status=EXISTING wall (3_TyYp2_X03PtH$QzJHWjh, material "Rubble
Stone" / type WAL600-Solid-Ext) containing several Status=NEW plug walls of
the identical material/type. In the NORTH SECTION drawing ("A COMPLICATED
WALL WITH COMPLICATED VOIDS AND INFILLS" test case), the plugs' outlines
remained fully visible instead of merging seamlessly into the wall.

This branch adds eight commits on top of 5a1723f524 (the PR's tip at the
time of this work):

  1. Purge duplicate boundary lines between contained same-material elements
    — the main fix, addressing the reported bug directly.
  2. Fix mat_keys_match treating "no material" as a wildcard match
  3. Restrict camera-face material fallback to AXIS3 elements
  4. Persist generate_material_layers/join_coplanar_surfaces across camera recreation
  5. Fix remaining over/under-removal in contained-pair coplanar line purge
    — a follow-up round fixing several further cases found by re-testing
    commit 1's own fix against the same repro model (see below).
  6. Fix hole-on-edge regression from fixed-point iteration's unilateral fallback
    — a targeted fix for a regression commit 5 itself introduced (see below).
  7. Fix multi-neighbour remainder-coordination bug in coplanar line purge
    — resolves the residual NORTH SECTION lines left open after commit 6
    (see below).
  8. Strip near-zero-length coplanar path segments that render as unwanted dots
    — a separate, unrelated bug surfaced by re-testing commit 7 (see below).

Commits 2–4 fix separate, real correctness bugs found while investigating the
main issue (independently corroborated by another reviewer's comment on the
PR thread) — they are not required for the main fix but sit in the same code
path and were bundled in at the user's request.


Root cause of the main bug

remove_coplanar_boundary_lines (in CreateDrawing, operator.py) only
removes an SVG line segment when both elements in a pair draw an explicit
<path> at the exact same bucketed line key (bilateral matching). For a
"contained" pair (one element's 3D AABB fully nested inside the other's, both
same material) this leaves lines behind two ways:

  • An inner element's edge that is strictly interior to the outer's face
    (doesn't touch the outer's own bbox boundary) has no matching key at all
    — the outer never draws anything at that interior coordinate beyond its own
    true silhouette, so bilateral matching finds nothing to pair against.
  • Contained plugs are frequently modelled as filling an actual notch/void cut
    into the outer element, so the outer's own silhouette legitimately traces
    around that void too — but because each element's cut geometry is generated
    independently, the two copies of that boundary commonly land a fraction of
    a millimetre apart, just outside bilateral's exact-key tolerance, so
    neither side's copy is ever recognised as a duplicate of the other.

Both failure modes leave real, visible internal lines splitting up what should
render as one seamless area — exactly the "voids and infills" symptom
reported.

The fix

A new geometry-based purge pass, gated on confirmed contained_a/
contained_b pairs, that doesn't require an exact line-key match:

  • Purges an inner segment that is strictly interior to the outer's
    bbox (not touching its edge) — it can only be a boundary against this outer
    element.
  • Purges an outer segment that lies on/within the inner's bbox
    footprint, but excludes anything also sitting on the outer's own true
    perimeter (protects the case where the inner touches/spans flush to one
    side of the outer — that line is a genuine external boundary of the merged
    shape and must stay).
  • Both purges are skipped if some other, differently-materialed element
    also has a segment at that same position — that's a genuine boundary
    against a third neighbour (e.g. a door cut into the plug area), not an
    internal seam between the contained pair.

The three bundled fixes

  • Empty-material wildcard: mat_keys_match((), b) returned True for any
    b, because the prefix-match check long_[:0] == () is trivially true
    regardless of long_. Elements with no material at all (e.g. IfcDoor)
    matched every other element's material key, so a door's outline could be
    wrongly dissolved into an adjacent wall's boundary.
  • AXIS3-only camera-face fallback: get_camera_face_layer_id computed a
    "camera-facing layer" value for every LayerSetDirection
    (AXIS1/AXIS2/AXIS3), but mat_keys_match's own docstring says that fallback
    is only valid for AXIS3 slabs/roofs — for AXIS1/AXIS2 walls the full
    cross-section is always visible in plan. Since this function's only caller
    feeds mat_keys_match's face_a/face_b, two walls with genuinely
    different materials (e.g. different exterior siding) could reach and pass
    the face_a == face_b fallback and have their boundary wrongly removed.
  • Toggle persistence: generate_material_layers/join_coplanar_surfaces
    were plain BoolProperty fields with no EPset_Drawing storage or update
    callback (unlike sibling properties has_linework/linework_mode/
    fill_mode/cut_mode, which already follow this pattern). Camera "Block
    representation" recreation — triggered from CreateDrawing whenever
    update_representation() detects a stale matrix/raster — calls
    import_camera_props(), which resets any property not explicitly restored
    from the pset back to its Python default, silently turning
    join_coplanar_surfaces back off with no feedback to the user.

Verification

Ran headless (Blender 4.5.3 debug build, bonsai_test profile) against the
real repro model linked from the PR thread:

  • Loaded coplanar join.ifc fresh, activated the NORTH SECTION drawing,
    enabled generate_material_layers/join_coplanar_surfaces, ran
    bim.create_drawing.
  • Diffed the regenerated SVG against a freshly-generated baseline (original
    PR code only, same environment) — the reported plug outlines are fully
    resolved, confirmed both in the raw SVG path data (target elements' groups
    now empty/reduced to legitimate boundary segments only) and visually
    (rendered PNG crops, pixel-diffed).
  • Confirmed no regressions: every other test case in the same drawing
    (small-angle coplanarity, different layer makeups, holes on edges,
    "coplanar donut", extrusion/tessellation material-match cases, different
    material/style combinations, no-material-assignment cases) renders
    pixel-identical to the baseline.
  • Confirmed the toggle-persistence fix live: forced a camera representation
    recreation mid-session and verified join_coplanar_surfaces survives it.

Test artifacts (repro IFC/blend, before/after SVGs, rendered PNG comparisons,
verification scripts) are kept alongside this file in
kt_repo/ and verify/.


Follow-up round: commit 5

Re-testing commit 1's contained-pair purge against the same coplanar join.ifc model (PLAN_VIEW and NORTH SECTION drawings) surfaced several
further concrete cases where it still removed the wrong line, or failed to
remove one it should have:

  • PLAN_VIEW: a roof slab (2CH9svq8r2gfCQ$eEjlBKa) was missing its real
    upper perimeter line; a second pair
    (2NR8CMK21CwOCIzz8vKnRv/0XMcotQ9nDsxGg_VJcDKKd) that should merge into
    one rectangle instead had its own upper line wrongly deleted and a
    spurious leftover vertical line down the middle; and once those were fixed,
    a follow-up asymmetry appeared where one side of a shared roof edge kept
    its own material-layer/fascia detail line while the other side's matching
    line had been purged.
  • NORTH SECTION: the reported wall assembly of 15 elements still showed
    numerous interior lines, including several that had disappeared after an
    earlier fix in this round and then reappeared once a later fix in the same
    round was added — a sign the underlying algorithm, not any single
    guard, needed to change.

Root causes (all in remove_coplanar_boundary_lines):

  1. The contained-pair purge judged a segment purely from a bounding-box
    test against the other element's overall bbox, which can't tell a genuine
    interior seam apart from a real external edge that simply happens to run
    through that bbox range (e.g. two colinear, end-to-end perimeter segments
    either side of a contained element, or one continuous outer wall corner
    that merely passes near/through a small contained plug's footprint without
    being confined to it).
  2. The same coarse test also couldn't tell a genuine interior seam apart from
    an inner element's own real perimeter edge that happens to coincidentally
    fall inside the outer's bbox range — e.g. two independently generated,
    adjacent roof panels each drawing their own material-layer/fascia edge at
    a slightly different position.
  3. The whole pass computed every removal once, from a single static parse of
    each element's segments — so a remainder segment created mid-pass (e.g.
    after a partial match against one neighbour) never got a further chance
    to be checked against a different neighbour that should have accounted
    for the rest of it. This explains both the "still there" and the
    "reappeared" symptoms: whether a given edge happened to be fully consumed
    in one shot depended on incidental processing order, not on the
    pairwise logic being right or wrong.
  4. dominant_world_normal() picked whichever single polygon had the largest
    raw area to decide an element's facing direction. For a thin, elongated
    element (e.g. a single masonry course modelled as its own sliver), the
    top/bottom cap has more area than the front face, so two genuinely
    coplanar, touching wall elements were wrongly judged non-coplanar
    entirely — the normals being compared didn't even point along the same
    axis.

Fixes:

  • Both the inner- and outer-segment purge loops now skip a candidate
    whenever the other element independently draws its own real edge on the
    same line key, deferring to bilateral matching's own (already correct)
    decision for it, instead of re-deciding with the coarser bbox test.
  • The outer-segment purge now clips to the actual overlap with the contained
    element's extent (reusing the existing interval-intersection helpers)
    instead of removing a candidate segment wholesale, preserving whatever
    part of it sticks out beyond the contained element as a real edge.
  • Bilateral matching's own leftover remainder, for contained pairs, is now
    filtered the same way before being re-added — if it's still strictly
    interior to the other element's bbox, it's dropped rather than re-drawn.
  • The inner-segment purge loop now also requires positive confirmation — a
    same-orientation outer segment beyond the candidate line, overlapping its
    interval — that outer material genuinely continues past it, rather than
    purging on bbox-range membership alone.
  • The per-parent-group pass now iterates to a fixed point (bounded at 10
    rounds): after applying a round's removals/additions, it re-derives the
    segment list from the now-mutated SVG and runs again, until a round makes
    no further changes.
  • dominant_world_normal() now picks the polygon whose world-space normal is
    most aligned with the camera's own view direction, rather than the one
    with the largest raw area — the face that actually matters for whichever
    projection is being drawn (this still naturally picks the top/bottom face
    for plan views, where that's the correct choice).

Verification (commit 5)

Same headless setup (Blender 4.5.3 debug build, bonsai_test profile)
against the same coplanar join.ifc model:

  • PLAN_VIEW: both reported roof-panel pairs now render as a single seamless
    rectangle with no spurious middle line, and both sides show matching
    material-layer/fascia detail (previously asymmetric).
  • NORTH SECTION: the 15 reported wall elements' combined remaining path
    count dropped from 61 to 46 (several elements fully resolved to zero
    interior lines); some interior lines remain, traced to further instances
    of the same multi-neighbour partial-coverage pattern (see Open items).
  • Broader before/after path-set comparison across all four drawings in the
    model (PLAN_VIEW, NORTH SECTION, SOUTH SECTION,
    REFLECTED_PLAN_VIEW) shows only small, legitimate-looking corrections of
    the same kind elsewhere, no unexplained regressions.

Follow-up round: commit 6

Commit 5's fixed-point iteration (re-deriving segments and reprocessing each
parent group across multiple rounds) fixed several NORTH SECTION wall cases,
but introduced a regression of its own, reported against the model's "HOLE
ON EDGE" test case: 1eW6OACmXD1hWJLDeB6erW (a wall with a hole cut all the
way through to its right edge) and 2x0$XNlFH6FxlvS7eYJttx (the adjacent
wall sharing that edge, with no hole). The entire left edge of 2x0$ — the
only remaining copy of that hole's right-hand boundary, since 1eW6 has no
material left on its own side to draw it — had been deleted, leaving that
side of the hole unlined.

Root cause: in round 0, bilateral matching correctly matches the overlapping
portions of this pair's shared edge and leaves the non-overlapping remainder
in place (the hole's exposed edge) — this fully drains 1eW6's presence at
that line key (nothing left on its side at all). In round 1, the segment
list is re-derived from the mutated SVG; since 1eW6 now has zero segments
at that key, bilateral has nothing to compare this round, so the cruder
unilateral fallback fires instead — and its bbox-boundary heuristic, blind
to the fact that 1eW6's side was already deliberately and fully drained,
wrongly treats the remainder as an unmatched implicit edge and deletes it.
This failure mode only exists because the pass now runs more than once —
with a single pass, round 1 (and this re-examination) never happened.

Fix: track every (guid, line_key) whose entire presence at that line was
consumed to nothing by a bilateral match in an earlier round. The unilateral
fallback now skips a removal whenever the bbox reference it would rely on
belongs to a guid+key that was already fully drained — that match already
gave its final, precise answer for this line; a later round must not
re-litigate it with a cruder heuristic. An initial attempt blocked
unilateral for an entire pair once they'd shown any bilateral relationship
at all, which fixed the hole case but was too broad — it also blocked a
legitimate wall-panel cleanup elsewhere in NORTH SECTION that genuinely
needed a later round's unilateral pass. Tracking drained keys instead of
related pairs is narrower and correct: it only suppresses unilateral for
the specific line that was actually resolved, leaving every other key and
pair (including the wall-panel case) unaffected.

Verification (commit 6)

Same headless setup against the same coplanar join.ifc model:

  • 2x0$'s hole edge is restored in both NORTH SECTION and SOUTH SECTION,
    matching the pre-regression baseline exactly.
  • Commit 5's NORTH SECTION wall-panel fix is preserved: the 15-GUID
    remaining path count is 48 (versus 47 with the regression still present) —
    one small residual segment remains on a different element
    (0gbhn0X9z7BBLNF58AuXfq), part of the same disclosed multi-neighbour
    partial-coverage tail from commit 5, not a new issue.
  • Broader before/after comparison across all four drawings shows only this
    restoration plus the same class of sub-micron coordinate noise seen in
    every previous round.

Follow-up round: commit 7

Commit 6's own "Open items" note named the next issue precisely: NORTH
SECTION's wall assembly still showed a small number of residual interior
lines (e.g. one segment on 0gbhn0X9z7BBLNF58AuXfq), and had already
identified the root cause without yet fixing it.

Root cause: bilateral matching computed each pair's remainder ("this
element's line minus what this one partner matched") immediately and
independently, inside the per-pair loop. When an element shares the same
line key with more than one same-material neighbour in the same round —
0gbhn shares its bottom edge with both 0ZA0gbiYP4yeaGvSMlNATl (matching
the left portion) and 2gKm6D45r4xgNcZthbYpFn (matching the middle
portion) — each pair's remainder calculation was blind to what the other
pair contributed against that same element+line: 0gbhn's remainder from
the 0ZA0g match still included the portion 2gKm6 was about to claim, and
vice versa. Both partial remainders got added back as separate paths, and
the next round's group_by_line/ivs_union merged them back into something
that looked like almost the entire original, untouched edge — even though
the two neighbours together had already fully accounted for it. Neither the
round-based iteration (commit 5) nor the drained-keys guard (commit 6) fixes
this, since it isn't about a segment failing to find a second-round match
or being wrongly re-examined — it's that the first-round remainder itself
was computed wrong whenever two partners split the same line.

Fix: group_by_line is now computed once per round (lines_by_guid),
shared across every pair instead of recomputed per pair. For non-contained
matches (same_surface / plain adjacent), a pair no longer computes and
re-adds its own remainder immediately — it accumulates its matched interval
into a per-(guid, line_key) union across every partner this round. After
the full (i, j) double loop finishes, a finalisation pass computes each
affected element's true remainder once — its original extent minus the
combined union of every partner's match — and adds back only that (or
marks the key drained if nothing is left). Contained-pair remainder handling
is completely untouched; multi-neighbour splits only occur on the
non-contained path.

Verification (commit 7)

Same headless setup against the same coplanar join.ifc model:

  • NORTH SECTION's 15-GUID remaining path count drops from 48 to 43. 0gbhn's
    residual is gone, and 0MmPs4lGP0zgpUVE$tStUb (sharing the same underlying
    pattern) resolves fully as a side effect — neither appears in an
    overall-bbox interior-line scan any more.
  • The "hole on edge" pair (1eW6OACmXD1hWJLDeB6erW / 2x0$XNlFH6FxlvS7eYJttx)
    and PLAN_VIEW's contained-pair fixes are unaffected (identical segment
    counts before and after).
  • Broader before/after path-set comparison across all four drawings shows
    only this fix's intended removals plus the same class of sub-micron
    coordinate noise seen in every previous round.

Follow-up round: commit 8

Re-testing commit 7 against NORTH SECTION's wall assembly turned up two
distinct problems, reported precisely by GlobalId plus line start-coordinate.
Only one of the two is fixed here.

Fixed: degenerate near-zero-length paths render as unwanted dots

IfcConvert's own linework export leaves a small number of near-zero-length
path segments in the raw, untouched output for elements with complex voids
and infills (confirmed present in 3_TyYp2's very first raw dump, long
before any of this branch's changes) — almost certainly floating-point noise
from the underlying BRep boolean/cut operations. These render as visible
dots rather than lines. Measured on 3_TyYp2: 9 segments at length < 0.0001
(true points), 5 more at length ≈0.0135–0.0145; the smallest confirmed
genuine line on the same element is length ≈2.83 — a 100x+ margin either
side. Fix: strip any remaining path below a MIN_VISIBLE_LENGTH = 0.1
threshold, applied to every projection group this function already
processes, after the existing round-based matching converges.

Verified by comparing total line coverage (the union of every real-length
segment) before/after, rather than just path counts or path-string diffs —
identical in every one of the four drawings in the model, confirming nothing
with genuine length is ever removed. This methodology was itself checked for
soundness: re-running identical, unmodified code twice (to measure Blender's
own run-to-run floating-point noise) also produces identical total coverage,
so the technique reliably distinguishes "real content changed" from
"harmless coordinate jitter."

Found, not fixed: contained-pair multi-neighbour bug causes real data loss

The other reported issue — several NORTH SECTION lines following the exact
same "two different neighbours independently compute their own remainder"
pattern commit 7 fixed, but occurring in the contained-pair code path
(which commit 7 deliberately left untouched, reasoned to be "usually 1:1").
3_TyYp2 is the counter-example: it's the contained_b outer for four
different inner plugs, and the identical blind-spot bug exists in two
contained-pair mechanisms that each compute an "outer" remainder per-pair,
independently (the bilateral match's own remainder, and the interior-purge
pass's outer-segment clip-to-overlap). Traced precisely: 1$k4ji92 and
3taVdKrmD45ehPoNLdDmdt together account for the entirety of one of
3_TyYp2's vertical lines, but each pair's independent remainder
computation left an incomplete piece behind, exactly reproducing two of the
reported unwanted lines.

Extending commit 7's same accumulate-then-finalise pattern to the outer side
of contained-pair interactions did resolve those two specific lines — but
checking total wall-edge coverage in REFLECTED_PLAN_VIEW (where
3_TyYp2 is visible from a different camera angle, overlapping a different
combination of contained plugs) exposed real data loss: a continuous
360.44-unit wall-top edge shrank to 246.5 units, an 80+ unit gap. Diagnosed as
the finalisation pass running every round and recomputing "original extent"
from the current round's segment state — which, in a later round, is already
the shrunk remainder from an earlier round, not the true pristine original
— so a further match against that already-reduced piece keeps eroding it,
compounding across up to 10 rounds.

A follow-up attempt replaced the per-round remainder computation with a
persisted-across-rounds pristine original (captured once, at round 0, before
any pair processing) and a persisted, ever-growing accumulator of every
partner's matched interval, so the remainder is always computed against the
same true original rather than a prior round's already-shrunk one. This
correctly fixed the round-erosion arithmetic: reverifying with the same
coverage check, the two originally-targeted NORTH SECTION lines
(418.17856 205.35803 and 418.17856 196.76428 on 3_TyYp2) now resolve to
exactly zero remainder, as expected.

However, the same coverage check caught a second, deeper problem in
REFLECTED_PLAN_VIEW that this arithmetic fix doesn't and can't address:
bilateral matching's core assumption — that any line drawn by both sides of a
same-material pair is necessarily a duplicate internal seam, safe to remove
from both — is wrong whenever that shared line actually coincides with the
combined shape's true external perimeter. Traced precisely: contained
plug 3taVdKrmD45ehPoNLdDmdt reaches flush to containing wall
3_TyYp2_X03PtH$QzJHWjh's own exterior top face; both independently draw a
copy of that same real, visible exterior edge at line key ('h', 2714) in
REFLECTED_PLAN_VIEW (a plan/roof-facing camera where this edge is genuinely
part of the silhouette, not an interior seam at all). Bilateral matching
removes both copies as if deduplicating an interior seam, deleting 113.9
units of real, visible boundary rather than reducing two copies to one.
Distinguishing this case from a genuine interior seam using only 2D SVG
line-key/interval data isn't possible — both look identical at that level;
it needs 3D reasoning about whether the material genuinely continues past
the shared line (in the same sense already used for the inner-segment
purge's _outer_confirms_continuation guard) applied to bilateral matching
itself. This is a pre-existing gap in bilateral matching's own core
assumption, predating this branch entirely (present in the original #7908
design) — it was previously masked here only by chance, because the old,
buggy round-accumulation logic happened to leave an accidental redundant
copy in that exact spot instead of cleanly deleting both.

Given the depth of the fix this now needs, the round-erosion redesign was
reverted rather than committed (confirmed via git checkout -- back to
commit 8's tip, re-verified against the test profile). No new commit is
added for this round; the contained-pair multi-neighbour bug remains open,
now with a materially clearer, precisely-located root cause.

A third, distinct edge case was also found while investigating the above:
colinear-but-adjacent (non-overlapping) segments between a contained
inner and its outer, which represent a shared void/notch boundary split
between the two — bilateral correctly leaves both alone (matching its
existing, deliberate handling of offset-wall corners), but the interior-purge
pass's line-key-deference guard (from an earlier round) then also defers to
that decision, so neither side is ever merged. Distinguishing "genuine
offset-wall corner, must stay split" from "split void boundary, should merge"
using only line-key/interval data is nontrivial and needs its own dedicated
design.

One reported line (310.06823 199.15698 on 3_TyYp2) was confirmed to be an
original, untouched raw segment with no matching partner in any pair — not
caused by either bug above; likely needs a same-surface/adjacent partner
elsewhere not yet identified.


Open items / not addressed here

  • The bundled fixes are real but were surfaced as a side effect of this
    investigation, not exhaustively tested beyond the repro model above.
  • fill_mode == "SVGFILL" changes noted elsewhere on the PR thread as
    ungated/pre-existing-crash are untouched by this branch.
  • NORTH SECTION's wall assembly still shows residual interior lines. Three
    distinct causes have been identified and precisely characterised (see
    "Follow-up round: commit 8" above) but not yet fixed:
    1. The contained-pair analogue of commit 7's multi-neighbour bug. A
      redesign (persisted pristine-original + ever-growing accumulator,
      rather than each round's own restated "original") correctly fixed the
      round-erosion arithmetic — verified against the two originally-targeted
      NORTH SECTION lines — but exposed a deeper, pre-existing gap: bilateral
      matching assumes any line both sides of a same-material pair draw is a
      safe-to-remove duplicate internal seam, which is wrong when that line
      is actually the combined shape's true external perimeter (confirmed
      concretely: 3taVdKrmD45ehPoNLdDmdt flush with 3_TyYp2's exterior
      face, 113.9 units of real boundary deleted at REFLECTED_PLAN_VIEW line
      key ('h', 2714)). This predates the whole branch (present in the
      original * Fix #3742: Remove coplanar boundary lines between adjacent same-mat… #7908 design) and needs 3D material-continuity reasoning, not
      just 2D SVG line-key/interval data, to resolve safely. The redesign was
      reverted rather than committed.
    2. Colinear-but-adjacent split void/notch boundaries between a contained
      inner and outer, currently left unmerged by design (correctly
      protecting a different, legitimate case — offset-wall corners) with no
      way yet to distinguish the two.
    3. At least one original raw segment with no matching partner in any pair
      at all (310.06823 199.15698 on 3_TyYp2), needing its own
      investigation.

sboddy and others added 8 commits July 21, 2026 23:11
Elements with an empty material-key tuple (e.g. IfcDoor, which has no
IfcMaterialLayerSetUsage) satisfied the prefix-match check trivially,
since `long_[:0] == ()` is true regardless of `long_`'s contents. This
made unmaterialised elements match every other element's material key,
so remove_coplanar_boundary_lines could dissolve door/window outlines
into an adjacent wall's boundary. Guard against empty keys before the
prefix/suffix comparisons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_camera_face_layer_id computed a "camera-facing layer" value for
every LayerSetDirection (AXIS1/AXIS2/AXIS3 alike), but mat_keys_match's
contract only intends that fallback for AXIS3 slabs/roofs — for
AXIS1/AXIS2 walls the full cross-section is always visible in plan, so
sharing only a facing layer must not count as a material match. Since
this function's only caller feeds mat_keys_match's face_a/face_b, two
walls with genuinely different materials (e.g. different exterior
siding) could reach and pass the face_a == face_b fallback and have
their boundary wrongly removed. Return None for anything but AXIS3.

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

Both toggles were plain BoolProperty fields on BIMCameraProperties with
no EPset_Drawing storage or update callback, unlike sibling properties
(has_linework, linework_mode, fill_mode, cut_mode) that already follow
this pattern. Camera "Block representation" recreation (triggered from
CreateDrawing when update_representation() detects a stale
matrix/raster) calls import_camera_props(), which resets any property
not explicitly restored from the pset back to its Python default —
silently turning join_coplanar_surfaces back off (and
generate_material_layers back on) with no feedback to the user.

Wire both properties to get_update_layer_callback(), and restore them
from GenerateMaterialLayers/JoinCoplanarSurfaces in import_camera_props,
matching the existing has_linework/linework_mode/fill_mode/cut_mode
pattern. Verified live: the toggle now survives a forced representation
recreation in headless Blender.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
remove_coplanar_boundary_lines only removed a segment when both
elements in a pair drew an explicit path at the exact same SVG line
key (bilateral matching). For a "contained" pair — a small same-material
element (e.g. an infill/repair "plug") fully embedded inside a larger
one (e.g. a long existing wall) — this leaves lines behind in two ways:

  - An inner element's edge that is strictly interior to the outer's
    face (not touching the outer's own bbox boundary) has no matching
    key to pair against at all, since the outer never draws anything
    at that interior coordinate other than its own true silhouette.

  - Contained elements are frequently modelled as filling an actual
    notch/void cut into the outer element, so the outer's own
    silhouette legitimately traces around that void too — but since
    each element's cut geometry is generated independently, the two
    copies of that boundary commonly land a fraction of a mm apart,
    just outside bilateral's exact-key tolerance, so neither side's
    copy is ever recognised as a duplicate of the other.

Both cases left real, visible internal lines splitting up what should
render as one seamless area (reported as a long EXISTING wall with
several NEW same-material "plugs" still showing outlines around each
plug).

Add a geometry-based purge pass for confirmed contained pairs that
doesn't require an exact key match: it removes an inner segment that
is strictly interior to the outer's bbox, and an outer segment that
lies on/within the inner's bbox footprint (but not on the outer's own
true perimeter), in both cases only when no other differently
-materialed element also has a segment there (protecting a genuine
boundary against a third neighbour, e.g. a door cut into the plug
area).

Verified against the real-world repro model linked from PR IfcOpenShell#7908: the
reported "voids and infills" wall in the NORTH SECTION drawing now
renders with the plug outlines fully resolved, and every other
regression case in the same test drawing (materials, styles, other
containment cases) renders pixel-identical to the prior baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to ea378a3's contained-pair purge, which itself left several
concrete cases wrong when re-tested against coplanar join.ifc's PLAN_VIEW
and NORTH SECTION drawings:

- Line-key deference guard: the contained-pair purge decided whether to
  delete a segment using only a bounding-box test against the other
  element's overall bbox, so it would delete a real external edge that
  simply happened to run through that bbox range (e.g. two colinear,
  end-to-end perimeter segments on either side of a contained element)
  even when bilateral matching, just above it, had already correctly left
  that exact line alone. Both the inner- and outer-segment purge loops now
  skip a candidate whenever the other element independently draws its own
  real edge on the same line key, deferring to bilateral's own decision.

- Clip-to-overlap for the outer-segment loop: a long containing element's
  one continuous real edge (e.g. a full-height wall corner) that merely
  passes near/through a small contained element's footprint was removed
  wholesale instead of only the truly-overlapping portion, destroying the
  part that sticks out beyond the contained element. Now intersects the
  segment's own interval with the contained element's extent and only
  removes/re-adds the overlap, preserving the remainder.

- Bilateral-remnant interior filter: for contained pairs, a partial
  bilateral match's leftover remainder was re-added unconditionally even
  when it was itself still strictly interior to the other element's bbox
  (not a real perimeter edge, just an artifact of the partial match).

- Outer-continuation guard on the inner-segment purge loop: purging an
  inner segment purely because its coordinate falls inside the outer's
  bbox range doesn't distinguish a genuine interior seam from the inner
  element's own real perimeter edge that coincidentally falls in that
  range (e.g. a material-layer/fascia edge two independently generated,
  adjacent panels each draw at slightly different positions). Now also
  requires a same-orientation outer segment beyond the candidate line,
  overlapping its interval, as positive confirmation that outer material
  genuinely continues past it.

- Fixed-point iteration: the per-parent-group pass computed removals once
  from a single static parse of each element's segments, so a remainder
  segment created mid-pass (e.g. after a partial bilateral match against
  one neighbour) never got a chance to also be evaluated against a
  different neighbour that should account for the rest of it. The pass now
  repeats (bounded, 10 rounds) until a round makes no further changes.

- Camera-aligned dominant normal: dominant_world_normal() picked whichever
  single polygon had the largest raw area, which fails for thin, elongated
  elements (e.g. a single masonry course modelled as its own sliver) whose
  top/bottom cap outweighs its front face by area — the wrong axis entirely
  for a section/elevation view. Now picks the polygon whose world normal is
  most aligned with the camera's own view direction, which is the face that
  actually matters for whichever projection is being drawn (naturally still
  picks the top/bottom face for plan views, where that's correct).

Verified headless (Blender 4.5.3 debug build, bonsai_test profile) against
coplanar join.ifc: PLAN_VIEW's two roof-panel pairs now render as a single
seamless rectangle with symmetric material-layer detail on both sides;
NORTH SECTION's 15 reported wall elements drop from 61 to 46 remaining path
segments (several fully resolved to zero). Broader before/after path-set
comparison across all four drawings in the model shows only small,
legitimate corrections elsewhere, no unexplained regressions.

Some interior lines remain in NORTH SECTION's wall assembly, traced to
further instances of the same multi-neighbour partial-coverage pattern;
not yet fully resolved.

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

The previous commit's fixed-point iteration re-derives segments and
reprocesses each parent group across multiple rounds, letting a remainder
created mid-pass get a further chance to be matched against a different
neighbour. That correctly fixed several NORTH SECTION wall cases, but
introduced a regression: 2x0$XNlFH6FxlvS7eYJttx (a wall adjacent to
1eW6OACmXD1hWJLDeB6erW, which has a hole cut all the way through to their
shared boundary) lost the entire left edge of that hole — the only
remaining copy of that boundary line, since 1eW6 has no material left there
at all to draw its own copy.

Root cause: in round 0, bilateral correctly matches the overlapping portions
of this pair's shared edge and leaves the right remainder in place (the
hole's exposed edge) — this fully drains 1eW6's presence at that line key
(nothing left on its side at all). In round 1, group_data is re-derived from
the mutated SVG; since 1eW6 has zero segments at that key now, bilateral has
nothing to compare this round, so the cruder unilateral fallback fires
instead — and its bbox-boundary heuristic, blind to the fact that 1eW6's
side was already deliberately and fully drained, wrongly treats the
remainder as an unmatched implicit edge and deletes it.

Fix: track every (guid, line_key) whose entire presence at that line was
consumed to nothing by a bilateral match in an earlier round. The unilateral
fallback now skips a removal whenever the bbox reference it would rely on
belongs to a guid+key that was already fully drained — that match already
gave its final, precise answer for this line; a later round must not
re-litigate it with a cruder heuristic. This is deliberately narrower than
blocking unilateral for the whole pair once any bilateral relationship
existed between two elements (tried first, but that also blocked a
legitimate wall-panel cleanup elsewhere) — draining is tracked per line key,
not per pair, so it only suppresses unilateral for the specific line that
was actually resolved, leaving every other key and pair unaffected.

Verified headless (Blender 4.5.3 debug build, bonsai_test profile) against
coplanar join.ifc: 2x0$'s hole edge is restored in both NORTH SECTION and
SOUTH SECTION, matching the pre-regression baseline exactly. NORTH SECTION's
wall-panel fix from the previous commit is preserved (15-GUID remaining path
count 48, versus 47 with the regression still present) — one small residual
segment remains on a different element, part of the same disclosed
multi-neighbour partial-coverage tail, not a new issue. Broader before/after
comparison across all four drawings shows only this restoration plus the
same class of sub-micron coordinate noise seen in every previous round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
remove_coplanar_boundary_lines' bilateral matching computed each pair's
remainder ("this element's line minus what this one partner matched")
immediately and independently. When an element shares the same line key with
more than one same-material neighbour in the same round (e.g. a wall panel
flanked by two others, each covering a different part of its edge), each
pair's remainder calculation was blind to what any other pair contributed
against that same element+line — so each remainder still included the
portion the other pair correctly claimed. Both partial remainders got added
back as separate paths, and the next round's group_by_line/ivs_union merged
them back into something that looked like almost the entire original,
untouched edge, even though the two neighbours together had already fully
accounted for it.

Concretely: 0gbhn0X9z7BBLNF58AuXfq shares one line key with both
0ZA0gbiYP4yeaGvSMlNATl (matching the left portion) and
2gKm6D45r4xgNcZthbYpFn (matching the middle portion) in the same round. Each
match's independently-computed remainder for 0gbhn included the portion the
other one had just claimed, and both got re-added, reconstructing 0gbhn's
edge almost in full for the next round to deal with (which the round-based
iteration and unilateral fallback couldn't cleanly resolve, since neither
guid_i is a fully "drained" claimant on its own).

Fix: group_by_line is now computed once per round (lines_by_guid), shared
across every pair instead of being recomputed per pair. For non-contained
matches (same_surface / plain adjacent), a pair no longer computes and
re-adds its own remainder immediately — it just accumulates its matched
interval into a per-(guid, line_key) union across every partner this round.
After the full (i, j) double loop finishes, a finalisation pass computes
each affected element's true remainder once, as its original extent minus
the combined union of every partner's match, and adds back only that (or
marks the key drained if nothing is left). Contained-pair remainder handling
is completely untouched — this only changes the non-contained path, which is
where multi-neighbour splits actually occur.

Verified headless (Blender 4.5.3 debug build, bonsai_test profile) against
coplanar join.ifc: NORTH SECTION's 15-GUID remaining path count drops from
48 to 43. 0gbhn's residual is gone, and 0MmPs4lGP0zgpUVE$tStUb (sharing the
same underlying pattern) resolves fully as a side effect — neither appears
in an overall-bbox interior-line scan any more. The "hole on edge" pair
(1eW6OACmXD1hWJLDeB6erW / 2x0$XNlFH6FxlvS7eYJttx) and PLAN_VIEW's
contained-pair fixes are unaffected (identical segment counts before and
after). Broader before/after path-set comparison across all four drawings
shows only this fix's intended removals plus the same class of sub-micron
coordinate noise seen in every previous round.

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

IfcConvert's own linework export leaves a small number of near-zero-length
path segments in the raw, untouched output for elements with complex voids
and infills — almost certainly floating-point noise from the underlying BRep
boolean/cut operations, present in the very first raw dump captured for
these elements, long before any of this branch's changes. These render as
visible dots rather than lines, mixed in among the genuine boundary lines
remove_coplanar_boundary_lines is meant to clean up.

Measured on the real repro model (3_TyYp2_X03PtH$QzJHWjh): 9 segments at
length < 0.0001 (true points) and 5 more at length ~0.0135-0.0145; the
smallest confirmed genuine line on the same element is length ~2.83 — a
100x+ margin either side of the chosen 0.1 threshold.

Adds a cleanup pass, gated the same way as the rest of this feature (only
runs for multi-element projection groups this function already processes),
that strips any remaining path whose parsed length falls below
MIN_VISIBLE_LENGTH after the existing round-based matching converges.

Verified headless (Blender 4.5.3 debug build, bonsai_test profile) against
coplanar join.ifc: confirmed via total-line-coverage comparison (summing the
real-length line union before/after) that this never removes anything with
genuine length — identical total coverage across all four drawings in the
model, only the confirmed dot artifacts disappear. Also confirmed this
before/after methodology is sound by checking that Blender's own run-to-run
floating-point noise (re-running identical, unmodified code twice) never
changes total coverage either — only which of two near-identical duplicate
segments happens to survive.

A companion fix for the contained-pair "multi-neighbour" duplicate-removal
bug (the other half of the plan this was drafted alongside) was found, via
the same total-coverage check, to cause real data loss in a different
drawing (a wall edge visible from another camera angle lost ~80 units of
real coverage) — not included here, reverted pending a redesign.

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

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

This is the human feedback. I'm not sure that these complex cases are resolvable purely using the information available at the time this code executes. The AI has basically reached an impasse where trying to take a step forward will cause a regression.

It may be that this is a developmental cul-de-sac, with no choice but to go back to the start and reassess the how and the where of how this is done.

Copy link
Copy Markdown
Contributor

@sboddy congratulations, this is a real breakthrough on the case that had both you and theoryshaw stuck. I read through the contained-pair and multi-neighbour work and the approach is sound on all four fronts: the MIN_VISIBLE_LENGTH filter catching the near-zero-length float-noise segments from the BRep cuts (the dots that read as lingering lines), hoisting group_by_line to a round-wide per-guid reference, the _consumed accumulator so an element's remainder is finalised against the union of ALL its neighbours' claims that round rather than pair by pair (the actual 3-way-junction fix), and the interior-seam suppression for the contained plug, which is the crux of the masonry-infill case.

On your open no-material same-style question, I traced the current matching logic to give you a definite answer rather than a guess. As the branch stands, style is a refinement on top of material, not a substitute for it: mat_keys_match returns False for any element with an empty material tuple (your own empty-tuple guard), and the style check is only reached after a material match already succeeded. So two material-less elements never merge today regardless of shared style, and style only ever makes a match stricter, never looser. That is arguably the safer default: merging purely on a shared style would over-merge unrelated material-less elements that happen to carry the same default style (two different doors, say). If you did want material-less-but-same-style to merge, that would be a deliberate opt-in where the style key becomes an alternative material key, with that over-merge risk to weigh. So I would not treat the current non-merge as a bug to chase.

This was a code-level read on my side this round rather than a fresh Blender pass. Whenever you feel it is settled, happy to run the before-and-after regression on your coplanar join.ifc plus Highland_Haven in headless Blender and post the path-count deltas alongside your screenshots, same as we did on the earlier tip.

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

But it is ever more fragile. I don't think it can be improved any further.

On the other hand, I tried asking the AI to step back and review, and look for alternatives. The test I'm currently experimenting with is to reuse the svgfill polygonizer code to merge adjacent cells. The very first test of the test file gives:

This is very nearly perfect on the oh-so-complicated wall (just a couple of specks to clean up). There are some issues to be sorted. I think the main one is that I don't think it is accounting for layer set boundaries.

Copy link
Copy Markdown
Member

so close!
seems like these are the outstanding issues?

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@theoryshaw Yeah, the edge hole is a definite failure. The horizontal lines are all layerset related. The X is one where I wasn't sure what the correct approach was. Merge or not merge. Now I know equal styles should be merged, it should be relatively easy to fix that one. I'll have a tinker.

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@theoryshaw I'm so dumb!!! Those horizontal lines are the edges of planes, not the dividing line between materials in a layerset.

Is there supposed to be a dividing line between different materials too? Because if so there should be more lines than you have marked in red, which I assume is indicating the missing edge for the plane change.

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.

3 participants


Back | FazBrowse Home | New Git URL