Community_Troubleshooting/7894_Join coplanar SVG projection cells using normal vector evaluation/fix-3742-architectural-decisions.md
Ryan Schultz 42fdfcf318 updated for 5a1723f524
also added 'architectural doc'...
This document is 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. It covers the full decision history —
every guard, heuristic, and edge case that was discovered and resolved —
so that a new contributor can understand *why* the code is shaped the way
it is before proposing changes.
2026-04-12 13:20:19 -05:00

14 KiB
Raw Permalink Blame History

fix-3742-coplanar-bonsai-projection_2 — Architectural Decisions

Purpose

This document is 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. It covers the full decision history — every guard, heuristic, and edge case that was discovered and resolved — so that a new contributor can understand why the code is shaped the way it is before proposing changes.


What the Feature Does

Bonsai generates plan/section/elevation SVGs by projecting 3D IFC elements through a camera. When two adjacent, same-material elements share a face plane, the projection renders the shared boundary as a visible line in both SVG groups — a duplicate that should not appear in the final drawing.

remove_coplanar_boundary_lines(root) in src/bonsai/bonsai/bim/module/drawing/operator.py (method of CreateDrawing) post-processes the SVG to detect and remove those duplicate line segments.

The feature is opt-in: it only runs when both cprops.generate_material_layers and cprops.join_coplanar_surfaces are enabled (the second toggle lives under "Generate Material Layers" in the BIM_PT_camera panel, defaults to off).


Overall Pipeline

remove_coplanar_boundary_lines(root)
│
├── Parse SVG <g> groups → collect (group_el, mat_ids, face_mat, style, segs, guid)
│
└── For every pair (i, j) of groups in the same SVG layer:
    1. mat_keys_match(mat_i, mat_j, face_i, face_j)  — same styled face?
    2. style_i == style_j                             — same SVG stroke style?
    3. are_coplanar_and_adjacent(guid_i, guid_j)      — adjacent in 3D?
    4. Bilateral segment matching                      — find shared SVG segments
    5. Remove shared segments; add back split remnants

are_coplanar_and_adjacent — Decision Pipeline

The function returns one of several values (all truthy except False):

Return value Meaning
False Not adjacent or not coplanar — skip pair
True Adjacent, coplanar, on adjacent parallel surfaces
"same_surface" Adjacent, coplanar, on the exact same surface (identical planes)
"contained_b" Pair passes via AABB containment — b (j-element) is inner
"contained_a" Pair passes via AABB containment — a (i-element) is inner

Step-by-step guards

1. Object lookup / non-mesh fallback

If either GUID resolves to no Blender object, or if either object is not a MESH, assume adjacent (True). Non-mesh objects have no geometry to test.

2. AABB guard (fast rejection)

For each world axis, if the two bounding boxes are separated (one element's min > the other's max + tol), return False immediately. Avoids expensive vertex iteration for elements that are clearly far apart.

tol = 0.01 m (3D geometry tolerance, separate from SVG tolerance).

3. Proximity check (vertexvertex then vertexedge)

Why two stages? Vertex-to-vertex handles the common case (shared corner). Vertex-to-edge handles containment: when a smaller element (e.g. a pilaster) is embedded inside a larger one, the inner element's corners lie on the edges of the outer element — never at its corner vertices. Checking only vertexvertex would incorrectly reject these pairs.

# Fast path
has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b)
# Containment fallback
if not has_shared:
    has_shared = vertex_near_edges(verts_b, obj_a, tol_sq) \
              or vertex_near_edges(verts_a, obj_b, tol_sq)

point_to_seg_dist_sq(p, a, b) projects p onto segment ab, clamps t to [0, 1], returns squared distance.

4. AABB containment — skip normal check

Why? The dominant-normal heuristic (step 5) picks the normal of the largest polygon. For a short, flat inner element (e.g. a thin wall stub, or a layer plate), the largest polygon is the top face → normal points in Z, not along the wall face direction. The dot-product check then fails even though the elements genuinely share a coplanar face.

Guard: Before the normal check, test whether one AABB fully encloses the other (within tol). If so, return the appropriate "contained_a" or "contained_b" sentinel and skip steps 57.

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

"contained_b" = aabb_contains(corners_a, corners_b) → b is inside a
"contained_a" = aabb_contains(corners_b, corners_a) → a is inside b

Safety: Two elements at different depth layers (which the normal check was protecting against) can never have one AABB fully nested inside the other — so this shortcut is safe to take.

5. Dominant normal check

dot = abs(n_a.dot(n_b))
if dot <= 1.0 - 3.8e-5:   # ~0.5° tolerance
    return False

Uses the normal of the largest polygon (not area-weighted average). Area-weighted average fails for slabs because top and bottom faces cancel.

6. Face-plane check (parallel offset walls)

Why? Parallel normals alone don't guarantee the elements share a plane. Two walls at an L-corner may both face +Y but be at different Y positions.

Fix: Project all vertices of both elements onto n_a (always n_a, not each onto its own normal — see below). Require at least one scalar position from A to be within tol of one from B.

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}
same_plane = any(abs(pa - pb) < tol for pa in plane_pos_a for pb in plane_pos_b)

Critical detail — both sets projected onto n_a: If the two normals are anti-parallel (+Y vs Y), projecting each onto its own normal produces values with opposite signs that never match, even though both faces are on the same plane. Projecting both onto n_a collapses the sign difference.

7. Depth check (same floor level)

Projects all vertices onto the camera look-direction (_cam_look). Requires the depth ranges to overlap by more than tol. This catches same-wall-type elements on different floor levels whose XY positions happen to coincide.

8. Same-surface vs adjacent-surface sentinel

After all guards pass, the return value encodes how coplanar the pair is:

coplanar_result = "same_surface" if plane_pos_a == plane_pos_b else True
  • plane_pos_a == plane_pos_b (identical sets) → elements are on the exact same surface — same material-layer plane positions along the normal. Example: two wall panels side-by-side on the same face.
  • Otherwise → elements are on adjacent parallel surfaces — they share one plane position at their interface (e.g. the outer face of wall A = inner face of wall B at their junction). Example: back-to-back walls at a corner.

This distinction drives the ivs_equal guard decision in the bilateral loop.


Bilateral Segment Matching

Segment grouping

Segments are grouped by axis-aligned line key using seg_line_key:

key = ("h", round(y / TOL))   # horizontal
key = ("v", round(x / TOL))   # vertical
# Diagonal → None (ignored)

TOL = 0.01 SVG units. All segments within TOL/2 of the same line get the same key. Intervals along the running axis are merged with ivs_union before comparison.

Matched pair flow

For each line key shared by both groups:

  1. Compute shared = ivs_intersect(union_i, union_j).
  2. If no intersection → skip.
  3. Apply ivs_equal guard (see below).
  4. Remove all original paths at that key from both groups.
  5. Add back rem_i = ivs_subtract(union_i, shared) and rem_j = ivs_subtract(union_j, shared) as new <path> elements.

ivs_equal guard — offset wall protection

Problem solved: Two walls meeting at an L-corner, back-to-back on adjacent parallel planes. In SVG they each have a segment on the shared horizontal line (the building corner), but their running-direction extents are offset — one starts where the other ends, or they partially overlap. Without a guard, the overlapping portion would be removed, incorrectly eliminating a real architectural boundary line.

Rule: Only remove the shared portion if it covers the full running-direction extent of at least one of the two elements on that line.

if not (ivs_equal(shared, union_i) or ivs_equal(shared, union_j)):
    continue  # offset overlap — real boundary, keep it

When bypassed:

  • _is_contained ("contained_a" / "contained_b" pairs): the partial overlap between an outer element's long edge and a physically-contained inner element's short edge is a genuine shared interface, not an offset-wall corner.
  • _is_same_surface ("same_surface" pairs): wall panels on the exact same surface may have SVG segments that are offset in the running direction (due to panel arrangement) yet are still a genuine shared interface.

_bilateral_had_explicit_keys flag

Problem solved: After bilateral finds common line keys but skips them all (offset-overlap guard), matched remains 0. The unilateral fallback then fires with matched == 0 and removes the same boundary segments the bilateral correctly rejected.

Fix: Set _bilateral_had_explicit_keys = True whenever a shared key has a non-empty intersection (regardless of whether ivs_equal passes). Gate the unilateral fallback on not _bilateral_had_explicit_keys.

Unilateral bbox fallback

When no bilateral key match is found and no explicit common keys were found, fall back to checking whether any segment of either element lies on the bbox boundary of the other element and the other element extends past that edge. This handles cases where one element has an implicit shared edge (no explicit path at that position).

Not applied to contained pairs (_is_contained) — the bilateral overlap check is sufficient there, and the unilateral would over-remove.


Known Unresolved Case: 2NR8CMK21CwO / 0XMcotQ9nDsx

This AABB-contained pair has one bilateral match (0.099 SVG units) but the user sees a remaining visible internal boundary. Analysis showed:

  • Element i (2NR8CMK21CwO) is in the lower-left quadrant of j's plan area.
  • Shared keys v/11339 and h/8659 have perfectly adjacent (non-overlapping) intervals — correctly not removed.
  • i has two ONLY-I horizontal segments (h/8649 at y≈86.49 and h/9534 at y≈95.34) spanning i's full x-width within j's plan area. These are internal dividing lines that bilateral cannot find because j has no segment there.

To fully resolve this, a "segment enclosed in outer bbox" removal pass would be needed for contained pairs. This was not implemented because removing all of i's enclosed segments also removes i's left-edge segment at v/11339 (the shared outer boundary), creating visual gaps where j does not have a corresponding covering segment. The correct fix requires knowing which of i's segments are on the outer boundary of the combined element vs which are purely internal — this requires either filled-area reasoning or explicit face topology, neither of which is currently available in the SVG post-processing pass.


Prop / UI Wiring

File Change
prop.py join_coplanar_surfaces: BoolProperty(default=False) on camera props
ui.py Toggle shown indented under generate_material_layers (only when layers enabled)
operator.py Both BISECT and OPENCASCADE code paths gate on generate_material_layers and join_coplanar_surfaces

Helper Function Reference

Function Purpose
ivs_union(ivs) Merge overlapping (lo, hi) intervals into sorted non-overlapping list
ivs_intersect(a, b) Pairwise intersection of two interval lists
ivs_subtract(ivs, sub) Remove sub intervals from ivs, returning remainder
ivs_equal(a, b) True if two interval lists are equal within TOL
seg_line_key(seg) Returns ("h", int_key) or ("v", int_key) for axis-aligned segments
seg_interval(seg) Returns (lo, hi) running-direction interval of a segment
seg_axis_coord(seg, kind) Returns fixed coordinate (x for vertical, y for horizontal)
segs_bbox(segs) AABB (min_x, max_x, min_y, max_y) of a parsed segment list
seg_on_boundary_of(seg, bbox_self, bbox_other) True if seg lies on the shared interface edge between two bbox'd elements
make_seg(kind, coord, lo, hi) Construct ((x0,y0),(x1,y1)) from axis/coord/interval
parse_line(d) Parse SVG path d attribute in "Mx,y Lx,y" format
dominant_world_normal(obj) World-space normal of the largest polygon in a mesh
point_to_seg_dist_sq(p, a, b) Squared perpendicular distance from point to segment
vertex_near_edges(verts, obj, tol_sq) True if any vertex in verts lies within tol_sq of any edge of obj
aabb_contains(outer, inner) True if inner bbox fits entirely within outer bbox (with tol)

Test Cases Resolved on This Branch

Pair View Expected Root cause Fix
08yv0FO / 39Iwvbn Plan No join Offset unions on adjacent-plane walls ivs_equal guard
1dM2d4kqjFH8 / 2lwj_pzhrADf Plan Join Full union match; was broken by ivs_equal overshoot Verify guard passes when unions are equal
08yv0FO / 39Iwvbn (unilateral) Plan No join After bilateral skip, unilateral fallback fired _bilateral_had_explicit_keys suppression
2CH9svq8r2gfCQ / 2u$gvViprDhe Plan Join Contained pair; ivs_equal blocked partial overlap _is_contained bypass
2NR8CMK21CwO / 0XMcotQ9nDsx Plan Partial join Contained pair; only 1 of 5 keys has overlapping intervals (0.099 SVG units) Partial (see unresolved case above)
2Op$pYptHFmf / 3nXW_ntDPAPv Plan Join Same-surface panels; ivs_equal blocked offset-but-genuine overlap _is_same_surface bypass