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

* Fix #3742: Remove coplanar boundary lines between adjacent same-mat… by theoryshaw · Pull Request #7908 · IfcOpenShell/IfcOpenShell · GitHub

* Fix #3742: Remove coplanar boundary lines between adjacent same-mat… - #7908

Closed
theoryshaw wants to merge 16 commits into
v0.8.0from
fix-3742-coplanar-bonsai-projection_2
Closed

* Fix #3742: Remove coplanar boundary lines between adjacent same-mat…#7908
theoryshaw wants to merge 16 commits into
v0.8.0from
fix-3742-coplanar-bonsai-projection_2

Conversation

Copy link
Copy Markdown
Member

…erial elements in Bonsai SVG drawings

Adds remove_coplanar_boundary_lines() to operator.py (Bonsai uses this path, not draw.py's main()). After merge_linework_and_add_metadata() assigns material CSS classes, this post-processes the SVG to delete projection line segments that appear in two or more adjacent, coplanar elements with the same material and presentation style.

Key design decisions:

  • Material identity: compared via sorted IFC material ID tuples from get_materials(), not CSS class names — avoids false matches between unrelated material-null elements.
  • Presentation style identity: compared via IFC IfcPresentationStyle IDs from StyledByItem on geometry representation items — handles elements with no material but distinct visual styles.
  • Physical adjacency: confirmed by a 3D shared-vertex test (tol=0.01 m) after a quick AABB guard, rejecting elements whose 2D projections overlap but sit at different depths.
  • Coplanarity: determined by the dominant (largest-area) face normal of each Blender mesh object — area-weighted averages are unreliable for slabs whose equal top/bottom faces cancel out. Folded walls sharing an edge but meeting at an angle are correctly rejected (normal dot ≪ 1.0).

…erial elements in Bonsai SVG drawings

Adds `remove_coplanar_boundary_lines()` to operator.py (Bonsai uses this
path, not draw.py's main()). After `merge_linework_and_add_metadata()`
assigns material CSS classes, this post-processes the SVG to delete
projection line segments that appear in two or more adjacent, coplanar
elements with the same material and presentation style.

Key design decisions:
- Material identity: compared via sorted IFC material ID tuples from
  `get_materials()`, not CSS class names — avoids false matches between
  unrelated `material-null` elements.
- Presentation style identity: compared via IFC IfcPresentationStyle IDs
  from `StyledByItem` on geometry representation items — handles elements
  with no material but distinct visual styles.
- Physical adjacency: confirmed by a 3D shared-vertex test (tol=0.01 m)
  after a quick AABB guard, rejecting elements whose 2D projections
  overlap but sit at different depths.
- Coplanarity: determined by the dominant (largest-area) face normal of
  each Blender mesh object — area-weighted averages are unreliable for
  slabs whose equal top/bottom faces cancel out. Folded walls sharing an
  edge but meeting at an angle are correctly rejected (normal dot ≪ 1.0).

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

Copy link
Copy Markdown
Member Author

Summary

Fixes #3742. When a plan or RCP drawing is generated, adjacent elements that are coplanar and share the same material produce a visible line between them in the SVG output. This line exists because each IFC element is projected independently into its own <g class="projection"> group — the shared edge appears in both groups and is rendered twice. This PR removes those duplicate segments after linework generation so the surfaces read as a single continuous plane.

Background

Bonsai's drawing pipeline does not use draw.py's main() function — it has its own equivalent path in CreateDrawing.execute() inside operator.py. The draw.py implementation in the referenced PR (#7894) therefore has no effect on Bonsai drawings. This fix is applied at the correct location in operator.py.

What was added

A new method remove_coplanar_boundary_lines(root) is called on the SVG root immediately after merge_linework_and_add_metadata() (which is where IFC GUIDs and material CSS classes are stamped onto the <g> elements). It runs for both HALFEDGE and OPENCASCADE cut modes.

The method works in three stages:

1. Group collection
All <g class="... projection ..."> elements are grouped by their immediate SVG parent, so only elements that belong to the same drawing view are ever compared against each other.

2. Visual identity check (material + style)
Two elements are candidates for merging only if they carry exactly the same visual appearance:

  • Material key: a sorted tuple of IFC material IDs from ifcopenshell.util.element.get_materials(). Using IFC IDs (not CSS class names) avoids false matches between unrelated material-null elements that happen to share the same CSS class but are stylistically distinct.
  • Style key: a sorted tuple of IfcPresentationStyle IDs taken directly from StyledByItem on the element's geometry representation items. This handles elements that have no material assignment but do have distinct IFC presentation styles (e.g. different line weights or colours via direct style association).

3. Coplanarity and adjacency check (are_coplanar_and_adjacent)
Even with matching material and style, a boundary line should only be removed if the two elements are genuinely coplanar and touching in 3D space. Three sub-checks are performed in order:

  • AABB guard: bounding boxes must overlap in all three world axes (cheap early-out).
  • Shared vertex: at least one pair of vertices from the two Blender mesh objects must be within 0.01 m of each other in world space. This rejects elements whose 2D projections overlap because they sit at different depths — a common case with stacked floor slabs.
  • Dominant face normal: the normal of each object's largest polygon (by area) must be nearly parallel (|n_a · n_b| > 0.999). Area-weighted average normals were considered but are unreliable for slabs: the top and bottom faces have equal area and opposite normals, so they cancel and the average drifts toward the side faces. Using the single largest face gives the true orientation. This check also correctly rejects folded walls that share an edge but meet at an angle.

Results are cached per GUID pair to avoid redundant 3D queries.

4. Segment matching
Surviving candidate pairs have their <path d="Mx0,y0 Lx1,y1"> segments compared endpoint-to-endpoint (in both directions) with a 0.01 SVG-unit tolerance. Matching paths are removed from both groups.

Cases correctly handled

Scenario Result
Two coplanar slabs, same material, touching edge ✅ Boundary removed
Two walls in line, same material, touching edge ✅ Boundary removed
Two elements, different materials ✅ Boundary kept
Two elements, no material but different IFC styles ✅ Boundary kept
Two elements, same material, overlapping in 2D but different depths ✅ Boundary kept (no shared vertex)
Two walls sharing an edge but meeting at a fold angle ✅ Boundary kept (normals not parallel)

…rations

Extends the SVG projection line deduplication introduced earlier with
several new cases:

**Containment adjacency (inner element inside outer)**
- The original shared-vertex check failed when an inner element's corners
  lie on edges of the outer element rather than at corner vertices.
- Added vertex-to-edge proximity check using point-to-segment distance.
- When one AABB is fully contained within another, skip the dominant-face
  normal check: a short/flat inner element's largest polygon is its
  top/bottom face, giving a spurious perpendicular normal.

**Anti-parallel normals (mirrored wall faces)**
- Two adjacent wall sections can have opposite face normals (+Y vs -Y)
  while still being on the same plane. The original `abs(dot)` check
  correctly identifies them as parallel, but projecting onto each
  object's own normal produced mirrored plane-position values that never
  matched.
- Fixed by projecting both objects' vertices onto the same reference
  normal (n_a) when computing face-plane positions.

**Parallel offset walls (false positive rejection)**
- Two walls with identical orientations but on different parallel planes
  (e.g., offset walls meeting at a corner) were incorrectly accepted by
  the normal-direction check alone.
- Added face-plane position check: after confirming normals are parallel,
  verify at least one face-plane position of A (projected onto the shared
  normal) matches one of B within tolerance.

**L-shaped wall split segments (collinear overlap)**
- An L-shaped wall's boundary at a shared interface can be split into
  multiple sub-segments (one per notch side), while the adjacent simple
  wall has a single full-length segment covering the same edge.
- Exact endpoint matching always failed in this case.
- Extended `lines_match` with a collinear-overlap check: axis-aligned
  segments on the same line with overlapping extents (beyond a trivial
  point touch) are now treated as the same physical edge.

Fix #3742: Improve coplanar boundary removal for complex wall configurations

Extends the SVG projection line deduplication introduced earlier with
several new cases:

**Containment adjacency (inner element inside outer)**
- The original shared-vertex check failed when an inner element's corners
  lie on edges of the outer element rather than at corner vertices.
- Added vertex-to-edge proximity check using point-to-segment distance.
- When one AABB is fully contained within another, skip the dominant-face
  normal check: a short/flat inner element's largest polygon is its
  top/bottom face, giving a spurious perpendicular normal.

**Anti-parallel normals (mirrored wall faces)**
- Two adjacent wall sections can have opposite face normals (+Y vs -Y)
  while still being on the same plane. The original `abs(dot)` check
  correctly identifies them as parallel, but projecting onto each
  object's own normal produced mirrored plane-position values that never
  matched.
- Fixed by projecting both objects' vertices onto the same reference
  normal (n_a) when computing face-plane positions.

**Parallel offset walls (false positive rejection)**
- Two walls with identical orientations but on different parallel planes
  (e.g., offset walls meeting at a corner) were incorrectly accepted by
  the normal-direction check alone.
- Added face-plane position check: after confirming normals are parallel,
  verify at least one face-plane position of A (projected onto the shared
  normal) matches one of B within tolerance.

**L-shaped wall split segments (collinear overlap)**
- An L-shaped wall's boundary at a shared interface can be split into
  multiple sub-segments (one per notch side), while the adjacent simple
  wall has a single full-length segment covering the same edge.
- Exact endpoint matching always failed in this case.
- Extended `lines_match` with a collinear-overlap check: axis-aligned
  segments on the same line with overlapping extents (beyond a trivial
  point touch) are now treated as the same physical edge.

Generated with the assistance of an AI coding tool.

Copy link
Copy Markdown
Member Author

Background

Bonsai generates SVG drawings by projecting 3D IFC elements into 2D. When two
adjacent elements share the same material/style and lie on the same face plane,
the boundary line between them appears in both SVG projection groups as a
duplicate — creating a visible internal line that should not exist. The goal of
this fix is to detect and remove those duplicate lines automatically.

The core function is remove_coplanar_boundary_lines inside the
CreateDrawing operator in operator.py. It iterates over all pairs of
projection <g> elements, checks whether the two elements are adjacent and
coplanar, and removes matching SVG <path> segments from both groups.


Changes Made This Session

1. Vertex-to-Edge Proximity Check (Containment Case)

Problem: The original adjacency check used a vertex-to-vertex shared-point
test: if no vertex of element A was within tolerance of any vertex of B, the
pair was rejected as not adjacent. This failed for containment — where a
smaller element sits physically inside a larger one (e.g., a pilaster embedded
in a wall). In that scenario, the inner element's corners lie on the edges of
the outer element, never at its corner vertices.

Fix: After the vertex-to-vertex check fails, fall back to a
vertex-to-edge check: for every vertex of B, compute the perpendicular
distance to every edge of A (and vice versa). If any vertex lies within
tolerance of any edge, the elements are considered adjacent.

point_to_seg_dist_sq(p, a, b):
    projects p onto segment a–b, clamps to [0,1], returns squared distance

This correctly handles T-junctions, embedded elements, and other cases where
shared corners don't coincide with outer-element vertices.


2. AABB Containment Bypass for the Normal Check

Problem: After proximity passes, the code checks coplanarity using the
dominant face normal (the normal of the largest polygon in each mesh). For
typical walls this is the large side face. But for a short, flat inner element
(e.g., a thin wall stub), the largest polygon is the top face, giving a
normal pointing in Z (up). The adjacent outer wall's normal points in Y (wall
face direction). The dot product is 0 → coplanarity check fails → boundary not
removed.

Fix: Before the normal check, detect whether one element's AABB is fully
contained
within the other's on all three axes (within tolerance). If so,
skip the normal check entirely — the proximity check already guarantees they are
physically touching, and the dominant-normal heuristic is unreliable for
geometrically compact inner elements.

def aabb_contains(outer, inner):
    for axis in range(3):
        if min(inner[axis]) < min(outer[axis]) - tol: return False
        if max(inner[axis]) > max(outer[axis]) + tol: return False
    return True

if aabb_contains(corners_a, corners_b) or aabb_contains(corners_b, corners_a):
    return True  # skip normal check

This is safe because two elements at different depth layers (which the normal
check was protecting against) can never have one AABB fully nested inside the
other.


3. Face-Plane Position Check (Parallel Offset Walls)

Problem: Two walls with identical orientation (same face normal direction)
but at different positions along that normal (e.g., two offset walls meeting
at a corner in an L-junction) were incorrectly accepted as coplanar. The
dot-product normal check only verifies that normals are parallel — it says
nothing about whether the elements are on the same plane.

Fix: After confirming the normals are parallel (dot > 0.999), project all
vertices of both elements onto the shared normal direction and compare the
resulting scalar positions. At least one face-plane position of A must be within
tolerance of one face-plane position of B for the pair to be accepted.

plane_pos_a = {round(v.dot(n_a), 5) for v in verts_a}
plane_pos_b = {round(v.dot(n_a), 5) for v in verts_b}  # both use n_a
same_plane = any(abs(pa - pb) < tol for pa in plane_pos_a for pb in plane_pos_b)

Note that both sets are projected onto n_a (not each onto its own
normal). This is required for correctness when the two normals are
anti-parallel (+Y vs −Y): projecting onto each object's own normal produces
mirrored values (opposite signs) that would never match, even though the
elements are genuinely on the same plane.

Example rejected: Wall A at Y ∈ [−0.25, +0.05], Wall B at Y ∈ [−0.41,
−0.10]. Both face Y, but no Y position from A matches any from B within 0.01 m
→ correctly rejected.

Example accepted: Wall A and Wall B both at Y ∈ [−0.41, −0.10], normals
+Y and −Y respectively. Projecting both onto +Y gives identical sets of
positions → correctly accepted.


4. Collinear-Overlap Segment Matching (L-Shaped Walls)

Problem: The segment matching function lines_match required exact
endpoint correspondence (within tolerance, in either direction). This failed for
L-shaped or notched walls whose boundary at a shared interface is split into
multiple sub-segments in the SVG, while the adjacent simple rectangular wall
has a single full-length segment covering the same edge.

Example:

  • Simple wall 2x0$XNl has one segment at x ≈ 41.91, spanning y = 68.48 → 87.53
  • L-shaped wall 1eW6OAC has two segments at x ≈ 41.91:
    • y = 87.53 → 85.57 (upper portion)
    • y = 76.82 → 68.48 (lower portion)
    • (the gap y = 85.57 → 76.82 is the interior notch, handled by horizontal segments)

No exact match is possible, but all three segments represent the same physical
interface and should be removed.

Fix: Added a collinear-overlap check as a second case in lines_match.
For two axis-aligned segments (both horizontal or both vertical):

  1. They must lie on the same line (same perpendicular coordinate within TOL).
  2. Their extents along that line must overlap by more than TOL (not just touch
    at a single point).
# Vertical case
if abs((x0a + x1a)/2 - (x0b + x1b)/2) < TOL:
    return max(min_ya, min_yb) < min(max_ya, max_yb) - TOL

The - TOL threshold prevents false matches between segments that merely share
an endpoint.


Summary of Guards in are_coplanar_and_adjacent

The full decision pipeline is now:

1. Look up Blender objects by GUID (cache miss → query IFC + tool.Ifc)
2. If either object is missing or non-mesh → assume adjacent (True)
3. AABB guard: if bounding boxes are separated on any world axis → False
4. Proximity: vertex-to-vertex within tol?
      No → vertex-to-edge within tol?
          No → False
5. AABB containment: if one bbox fully contains the other → True (skip 6–7)
6. Dominant-normal check: largest-polygon normal for each object
      dot product < 0.999 → False (elements meet at an angle)
7. Face-plane check: project all vertices onto n_a
      no position from A within tol of any position from B → False
8. → True (adjacent and coplanar)

And lines_match now handles both:

  • Exact match: same endpoints within TOL (either direction)
  • Collinear overlap: both axis-aligned, on the same line, overlapping range > TOL

Known Remaining Debug Output

The code still contains [TARGET PAIR], [MERGED], and [COPLANAR DEBUG]
print statements left in for ongoing troubleshooting. These should be removed
before merging to v0.8.0.

Copy link
Copy Markdown
Member Author

…aries

Some adjacent same-material elements don't explicitly draw their shared
boundary in both SVG projection groups — one element's edge is implicit
(absorbed into its parent path or omitted because the other element
"owns" it). In these cases the bilateral lines_match check finds zero
hits even though the elements are correctly identified as adjacent and
coplanar.

Add a unilateral bbox-edge fallback: after the bilateral pass finds
nothing, compute the SVG bounding box of each element's parsed segments
and check whether any segment from one element lies on a face of the
other element's bbox with meaningful overlap. Segments that pass are on
the shared interface and are removed even without a mirror segment in the
opposite group.

The overlap check (> TOL) prevents false positives from segments that
merely touch a bbox corner without sharing real extent.

Generated with the assistance of an AI coding tool.

Copy link
Copy Markdown
Member Author

What the problem was

When two adjacent walls/slabs share a boundary, Bonsai generates an SVG where both elements have a <path> drawn along that shared edge — one from each element. The algorithm removes these duplicate lines by finding segments that appear in both elements' path lists and deleting them.

The problem: sometimes only one element actually draws the shared boundary as an explicit path segment. The other element's path simply doesn't include it — the shared edge is implicit in the polygon closure or owned entirely by the neighboring element. So the search for a matching pair comes up empty, and neither segment gets removed.

In the failing case (0QpDRRuif0R80d4CYPEQ$L vs 0YyL6RtCL8axn6AG9ItFhs):

  • Element j drew two explicit vertical segments along the shared edge at x≈89.73 (one from y=62→99, another from y=111→155)
  • Element i had no segments at those y-ranges along x=89.73 — just a tiny stub at the very bottom
  • The bilateral search found zero matches and stopped — those lines stayed visible in the drawing

What the fix does

After the bilateral search finds nothing, a fallback check kicks in. It computes a simple bounding box from each element's drawn segments, then asks:

Does any segment from element j lie on the edge of element i's bounding box, with meaningful overlap?

Element j's two vertical segments at x≈89.73 land exactly on element i's right bbox edge, and their y-ranges sit well within element i's y-extent — so they pass the test and get removed, even though element i had no matching segment to pair them against.

The overlap > TOL guard prevents false positives from segments that merely graze a bbox corner without sharing real length.

test file:
Highland_Haven_old.zip

Handle additional adjacency cases in the plan/section SVG
projection line deduplication introduced for #3742:

- Use IfcMaterialLayerSetUsage layer sequence instead of a
  flat material set for material comparison. Prefix/suffix
  matching allows elements whose assemblies share the same
  layers at the interface (one having extra finish layers)
  to be correctly merged.

- Add a unilateral bbox-edge fallback for pairs where one
  element's shared boundary is implicit (not drawn as an
  explicit SVG path segment). Segments from the other
  element that lie on the boundary of the first element's
  SVG bounding box are removed even without a bilateral
  match.

Generated with the assistance of an AI coding tool.
When an L-shaped element wraps around a smaller coplanar element,
the shared face is interior to the larger element's bounding box.
The previous seg_on_boundary_of check required the shared face to
lie on the larger element's own bbox edge (outer abutment only),
so it missed these cases entirely.

The new logic checks whether the segment lies on the *smaller*
element's own bbox edge and whether the other element extends past
that edge on the adjacent side — correctly handling both simple
side-by-side abutment and notch/wrap configurations.

Generated with the assistance of an AI coding tool.
When two coplanar same-material elements share only part of an
edge (e.g. an L-shaped element abutting a rectangle along a
notch), the previous bilateral match removed whole segments and
left gaps or over-removed lines.

The new approach groups all segments per element by their
axis-aligned line, merges them into interval unions, computes the
shared intersection, removes all original segments on that line,
and re-adds the non-shared remainders as new path elements. This
correctly handles any number of sub-segments on the same line.

Generated with the assistance of an AI coding tool.

theoryshaw commented Apr 6, 2026
edited
Loading

Copy link
Copy Markdown
Member Author

Added in segments for portions of the shared edge that are not shared....


Two changes to mat_keys_match:

1. Boundary-ends fallback: when exact/reversed/prefix/suffix checks
   all fail, allow a join if any end layer (first or last) of each
   assembly shares a material ID. This handles adjacent elements
   with different inner layers but the same face material at the
   shared interface (e.g. two-layer roofs that share only their
   visible surface material).

2. Camera-directional face check: for IfcMaterialLayerSetUsage
   elements with AXIS3 (vertical) layer direction (slabs, roofs),
   compute the actual camera-facing layer — top layer for plan
   views (camera looks down), bottom layer for RCPs (camera looks
   up) — using DirectionSense to resolve which end is up. When
   both elements provide a face layer ID, only those are compared,
   preventing incorrect joins in RCPs where elements expose
   different bottom materials even though they share a top material.

   Walls (AXIS1/AXIS2) are unaffected and continue to use the
   generic ends-intersection fallback.

Generated with the assistance of an AI coding tool.
The same-plane check in are_coplanar_and_adjacent tested whether
any vertex of element A shared a depth value (projected onto n_a)
with any vertex of element B. This falsely passed for elements
stacked end-to-end along the normal axis, because they touch at
a single interface point that appears in both vertex sets.

Replace the point-match with a range-overlap test: project each
element's full vertex set onto n_a to get a 1-D depth interval,
then require the two intervals to overlap by more than tol. Side-
by-side coplanar elements overlap across their full shared face
thickness; end-to-end stacked elements only touch at a zero-width
boundary, so their overlap is 0 and the join is correctly rejected.

Generated with the assistance of an AI coding tool.
The generic boundary-ends check (any end layer of a matches any
end layer of b) was too permissive for AXIS2 walls in plan view.
Because the full cross-section is visible in section, two walls
with different assemblies that merely share an interior or exterior
layer were incorrectly joined.

For AXIS3 slabs/roofs the camera-face layer check already handles
cases where only the visible face layer matters. The boundary-ends
fallback is no longer needed and has been removed. AXIS1/AXIS2
walls now only join via exact, reversed, prefix, or suffix layer
sequence matches.

Generated with the assistance of an AI coding tool.
Replace the camera_looks_up flag (plan vs RCP) and dominant-face-
normal heuristic with a unified local-axis projection for all
LayerSetDirections:
  AXIS3 (slabs/roofs) → object local Z (col[2])
  AXIS2 (walls)       → object local Y (col[1])
  AXIS1 (rare walls)  → object local X (col[0])

The camera look vector is projected onto the object's world-space
stacking axis; a negative dot product means the camera is on the
positive (exterior) side of the layer sequence, a positive dot
means it is on the negative (interior) side.

This correctly handles sloped AXIS3 slabs viewed by two plan-type
cameras from opposite sides — the tilted local Z produces different
dot products for each camera direction, where the old camera_looks_up
flag returned the same result for both.

Generated with the assistance of an AI coding tool.
The same-plane check projected vertices onto the dominant face
normal (n_a) to determine if two elements span the same depth
range. This correctly rejected slabs stacked along their normal
axis but broke for elements whose largest face is perpendicular
to the camera (e.g. X-normal boxes in plan view) — projection
onto X produces zero overlap for legitimate side-by-side pairs.

Replace n_a projection with projection onto _cam_look. Elements
at the same camera depth produce overlapping ranges and are joined;
elements separated in camera depth produce disjoint ranges and are
correctly rejected regardless of the orientation of their faces.

Generated with the assistance of an AI coding tool.
theoryshaw marked this pull request as draft April 9, 2026 18:34
Introduces a "Join Coplanar Surfaces" boolean property (default off)
nested under the "Generate Material Layers" option in BIM_PT_camera.
When enabled, remove_coplanar_boundary_lines runs after linework
generation to suppress shared boundary lines between adjacent coplanar
elements of the same material.

Generated with the assistance of an AI coding tool.
Adds a "Join Coplanar Surfaces" bool property (default off) nested
under "Generate Material Layers" in BIM_PT_camera. When enabled,
remove_coplanar_boundary_lines runs after linework generation to
suppress shared boundary lines between adjacent coplanar elements
of the same material.

Fixes a false-positive where back-to-back walls at adjacent face
planes (e.g. two thin walls touching at Y=1.185) were incorrectly
joined. The bilateral segment matching now only removes a shared
line segment when it covers the full running-direction extent of at
least one element's union — offset partial overlaps are skipped.
A companion flag suppresses the unilateral bbox fallback when
bilateral already found explicit segments on that line, preventing
the fallback from removing the same boundary the bilateral pass
correctly rejected.

Generated with the assistance of an AI coding tool.
theoryshaw marked this pull request as ready for review April 12, 2026 14:31
theoryshaw and others added 2 commits April 12, 2026 12:45
…airs

Extends the "Join Coplanar Surfaces" drawing feature with three fixes:

- Offset walls on adjacent parallel planes (e.g. back-to-back walls at a
  corner) were incorrectly joining. The ivs_equal guard now correctly
  blocks removal when the shared SVG segment covers neither element's full
  running-direction extent.

- The unilateral bbox fallback was firing after the bilateral pass already
  rejected an offset-wall pair (matched=0 but keys were found), removing
  the boundary line the bilateral correctly skipped. A new
  _bilateral_had_explicit_keys flag suppresses the fallback in this case.

- Wall panels on the exact same surface (identical material-layer plane
  positions) were incorrectly blocked by the ivs_equal guard when their
  SVG segments were offset due to panel arrangement. are_coplanar_and_adjacent
  now returns "same_surface" vs True to distinguish same-surface pairs
  (ivs_equal bypassed) from adjacent-surface pairs (ivs_equal enforced).

Also handles AABB-contained element pairs (inner element physically inside
outer in 3D) by returning a "contained_a"/"contained_b" sentinel so the
bilateral loop can skip the ivs_equal full-extent requirement for the
partial overlap at the shared face.

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

theoryshaw commented Apr 12, 2026
edited
Loading

Copy link
Copy Markdown
Member Author

Updated test files: https://hub.openingdesign.com/OSArch/Community_Troubleshooting/src/commit/42fdfcf3185f0d3b1be1e57696e1527d1f0ec4fb/7894_Join%20coplanar%20SVG%20projection%20cells%20using%20normal%20vector%20evaluation

I also created an architectural document for a dense knowledge-transfer reference for AI agents or developers who need to continue work on the "Join Coplanar Surfaces" feature for Bonsai's SVG drawing generation.

https://hub.openingdesign.com/OSArch/Community_Troubleshooting/src/commit/42fdfcf3185f0d3b1be1e57696e1527d1f0ec4fb/7894_Join%20coplanar%20SVG%20projection%20cells%20using%20normal%20vector%20evaluation/fix-3742-architectural-decisions.md

The following highlighted tests still fail, and I probably will not address them in the near future, since a lot of AI time was burned trying to solve and it continued to run into dead ends.

drawing: PLAN_VIEW

drawing: NORTH SECTION

Copy link
Copy Markdown
Contributor

@theoryshaw @Moult @sboddy I ran a verification pass on this PR against current v0.8.0 so it does not stay stuck. Summary of what I found.

It merges cleanly against v0.8.0 at 55a2430, no conflicts. The lint-formatting failure is 31 cosmetic lines that one black . fixes. The compile-and-test failure is pre-existing on main (every ci.yml run on v0.8.0 in April 2026 was red) and unrelated, since this PR touches no C++.

It composes with sboddy's merged #8608. They sit at different layers: #8608 classifies edges within a single element in the C++ serializer, this PR removes duplicate segments between elements in Python after merge. #8608 keeps the d="Mx,y Lx,y" format and the per-product group structure that this PR's parser relies on, so nothing here is superseded. Both default off, so the combination is untested.

It genuinely works, on the cases it reaches. Using your own Highland_Haven_old.ifc in headless Blender 5.1.2, with the toggle on it removed real seams on 6 of 6 drawings I tried, including the exact 0QpDRRuif0R80d4CYPEQ$L / 0YyL6RtCL8axn6AG9ItFhs pair you called out. With the toggle off the output is byte-identical to baseline across all 76 projection groups and 3674 paths, so the opt-in gating is clean.

Three things I think need addressing before merge:

  1. Elements with no material match every other element. mat_keys_match((), anything) returns True because long_[:0] == (). In this model IfcDoor and IfcSanitaryTerminal have empty material keys, and on 1ST FLOOR RCP 31 of 61 touched segments were door outlines being dissolved into walls. This defeats the material-identity goal described in the PR body. Looks like a one-line guard.

  2. Two walls with different exterior siding (SIDING - BATTEN_2A vs SIDING - BATTEN_1) had their shared boundary removed in ELEVATION - WEST. The exact/reversed/prefix/suffix checks correctly reject them, then it falls through to return face_a == face_b. The docstring says that camera-face comparison is for AXIS3 slabs, but the code does not restrict it to AXIS3, so walls reach it too.

  3. The toggle can silently reset itself. join_coplanar_surfaces is not persisted to EPset_Drawing and has no update callback, so when CreateDrawing calls update_representation the camera data is recreated and the flag goes back to False. On two of the six drawings I tested the pass was skipped entirely despite the setting being on, with no feedback to the user. Forcing the default to True made both run. This may explain part of what you experienced as "still fails".

One more thing worth a maintainer's eye: the fill_mode == "SVGFILL" changes are not behind the toggle, and num_passes going from 0 to 1 activates a code path that could never execute before. That is the draw.py-style approach aothms suggested in #3742 and may be the more promising direction, but it currently ships ungated. I could not A/B it because SVGFILL already crashes on baseline on this model (A linearring requires at least 4 coordinates), which looks like a separate pre-existing bug.

On cost, the new pass added 7.5s to a 76-element plan and 5.5s to NORTH SECTION. Fixing item 1 should bring that down, since empty material keys currently force the expensive geometry test on every unmaterialised element.

Happy to help with any of the above if useful. Test artifacts and the exact before/after segment lists are reproducible from your Highland_Haven_old.ifc.

This comment was written with AI assistance.

sboddy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

So I had a crack at this. On testcase output I've gotten to...

  1. Looks like a regression.
  2. Looks like a fail. Removed one correct line, and one incorrect line.
  3. Regression... I think... maybe. Are we supposed to merge styles now too?
  1. Looks like a success.
  2. Regression... I think... maybe. Are we supposed to merge styles now too?
  3. An improvement, but no gold star. There are still bits that should not be there.

I think my Claude also picked up some of the stuff BIMvoice outlines:

  • Empty-material wildcard in mat_keys_match (elements with no material no longer match everything)
  • Camera-face fallback restricted to AXIS3 slabs only (was wrongly reachable by walls)
  • join_coplanar_surfaces/generate_material_layers toggles now persist across camera representation recreation (confirmed via a live round-trip test)

sboddy commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

https://github.com/sboddy/IfcOpenShell/tree/fix-3742-coplanar-bonsai-projection_2_sjb

If you want to take a look. I'll see if I (or more accurately, Claude) can't fix the outstanding issues.

Copy link
Copy Markdown
Member Author

awesome, will take a look, this weekend.

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Wow! This thing is a severe PITA. Multiple rounds of trying to fix this thing. I got it to fix some of the above problems, but damn that wall is evil... evil I tell you!

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The more I've fought with this the more I think (1) is the correct interpretation for both PLAN and NORTH SECTION.

PLAN:

(2) Is now correct. If you look real close the top lines look to have different thicknesses. However if you zoom right in on the actual model you will in fact see a difference between the two sides. So I'm calling this as right.

NORTH SECTION:

(2) This is the best I've got so far. These lines are really tricky little buggers!

I don't want to "steal your thunder" by posting my own PR, but there's quite a lot of useful background in my drafted PR description that might be worth preserving, and feeding to the next rounds of trying to beat this into submission. I can either create a draft PR, or post the draft .md directly here as a very long comment. Your choice.

Copy link
Copy Markdown
Contributor

@sboddy this is genuinely appreciated, thank you for grinding through this and for folding in the three things we flagged (the empty material wildcard, the AXIS3 restriction on the camera-face fallback, and the toggle persistence). Please go ahead and open the draft PR, not just a comment. Reasons: it turns your write up into a referenceable, versioned artifact instead of one very long comment buried in an already long thread, CI can actually run against your branch so we get a real signal instead of screenshots, and it gives theoryshaw a clean diff to review against his original when he gets to it this weekend, rather than a wall of text to parse. We would rather build on a draft PR than a comment, and we will actively use it, it slots straight into the linework work we're doing on #8633.

On your test rig calls: tracing your circled cases against the labeled grid, (1) being correct in both PLAN and NORTH SECTION lines up with how the code is actually meant to work. The style key check (IfcPresentationStyle IDs from StyledByItem) was written into the original PR specifically to give material less elements an identity to match on, so two undecorated elements sharing the same style merging is intended behavior, not scope creep. So no, you're not accidentally merging styles, that's the design working as intended for the no material case.

(2) in PLAN, keeping the line where there is a real but subtle difference, is the right default for a drawing tool. Erring toward a spurious line beats erring toward erasing a real boundary, so that call looks right to us too.

(2) in NORTH SECTION, the complicated wall with voids and infills, matches what theoryshaw already flagged back in April as one of the two tests he could not crack. That is the genuine frontier here, not a regression you introduced, and it is fine if that one stays open for another round.

One thing worth knowing before you push: we checked and your branch and #8633 do not touch any of the same code paths (#8633 only changes the grouping criteria in merge_linework_and_add_metadata, your work is entirely in remove_coplanar_boundary_lines/mat_keys_match), so there is no conflict either direction and they can land in any order. Once the draft PR is up we are happy to run the same before and after regression pass on your branch that we ran on theoryshaw's, using Highland_Haven_old.ifc plus your synthetic test rig, so there is a reproducible verification alongside the screenshots.

Copy link
Copy Markdown
Contributor

@sboddy I independently re-verified your branch (fix-3742-coplanar-bonsai-projection_2_sjb, currently at 4bc3e808d9) against Highland_Haven_old.ifc in headless Blender.

All three things we flagged are fixed exactly as described. mat_keys_match((), x) now returns False (operator.py line 2130), the camera-face fallback now returns None for anything but AXIS3 (operator.py lines 2084 to 2085), and both toggles are now wired to EPset_Drawing the same way as has_linework/linework_mode (prop.py lines 500 to 511, tool/drawing.py lines 1012 to 1013 and 1049 to 1052).

Live, on the real model: with the toggle off, output is path-count identical to v0.8.0 baseline on every drawing I tried, so the opt-in gating stays clean. With it on, the door and sanitary terminal outlines that were wrongly dissolved on theoryshaw's original branch are now correctly kept (confirmed the exact pair theoryshaw called out in April too, 0QpDRRuif0R80d4CYPEQ$L and 0YyL6RtCL8axn6AG9ItFhs, now resolves). On ELEVATION - WEST, the different-siding wall pair that was wrongly merged before is now correctly kept apart. I did not find any case where a boundary that should stay visible got newly removed.

I was not able to fetch your synthetic test rig, hub.openingdesign.com blocks both git clone and raw file access for me, so I could not independently replay the numbered grid cases from your screenshots. From the screenshots themselves, the COMPLICATED WALL WITH VOIDS AND INFILLS case in NORTH SECTION does look like the same genuinely hard frontier theoryshaw flagged in April, not a regression, so it seems reasonable for that to stay open for another round.

This is solid work, thank you for grinding through it. Given theoryshaw's own suggestion, this seems ready for a draft PR so CI can run and the diff is easy to review against the original.

sboddy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

I'm making progress, but it is a torturous journey filled with lurking traps. I'm pushing fixes to my branch, and currently having yet another go at removing those lingering lines. I've not (so far) addressed the no-material, same style item. I'm still not sure if the intent is to merge styles or not. I've been ignoring that for now in favour of solving the real knotty problem wall.

When I reach the point that I feel I can't prompt Claude to further resolve it (getting real close now) I'll create the draft PR with all the gory details.

Copy link
Copy Markdown
Member Author

Closing mine, in favor of @sboddy's solution! #9330

theoryshaw 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