| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Extends the existing single-object 5-class SVG edge classification system (issue IfcOpenShell#3668) with a 6th class: an edge is "cross-coplanar" when its entire length lies on a coincident, same-style/material face of a *different* product -- a duplicate boundary between two elements' coincident surfaces, as opposed to a fold within one element's own geometry. This replaces the Python-side approach (join_coplanar_boundary_lines, operator.py) that this branch previously iterated on across several rounds of regressions -- all tracing to the same root cause: once two objects were linked as "in the same cluster" for merging, every edge of both became fair game for one shared merge decision, so a narrow, even correct, reason to link two objects ended up entangling their entire boundaries. Working at per-edge granularity on real, exact, un-projected 3D BRep faces (before HLR ever draws them) is structurally immune to that failure mode: linking two objects can never affect an edge that isn't itself genuinely coincident. Implementation: - New settings (ConversionSettings.h): SvgUseCrossCoplanarClassification (master enable, only takes effect alongside SvgUseEdgeClassification), SvgRenderCrossCoplanarEdges (emit toggle, default off), and SvgCrossCoplanarTolerance. - SvgSerializer::write(BRepElement*) resolves one representative style/material identity per product (geometry_data:: cross_coplanar_style_instance) -- deliberately per-product rather than per-face/per-layer, since per-item shapes are transformed and concatenated away by the time compound_local exists (IfcGeom::Representation::BRep::as_compound()). - prefiltered_hlr::items_ now carries that style alongside each product's shape (product_shape_list_t). - New prefiltered_hlr::find_cross_coplanar_matches(), run once at the start of build() (every product for the drawing/storey is already present in items_ by then): bbox-filtered pairwise face comparison (same style identity, parallel normals, coincident planes via BRepAlgoAPI_Common), classifying an edge as cross-coplanar only when its *entire* length lies in the overlap region -- a partially -overlapping edge is left untouched (v1 scope; true sub-edge splitting is a documented follow-up, not implemented here). Matched edges are removed from whatever base-class bucket they were already in (so an edge is never double-classified/drawn twice), then optionally re-added under the new class if render_cross_coplanar_edges_ is on. - Bonsai wiring (prop.py/ui.py/tool/drawing.py/operator.py) mirrors the existing issue IfcOpenShell#3668 property/EPset/UI pattern exactly. Verified via docker/ifcos_env (IfcConvert + the Python wrapper): toggle off is byte-identical to the existing baseline on the synthetic test model across all 4 drawings; toggle on removes 44 genuine duplicate edges across those drawings with zero false additions, while every previously-established protected case (notch, self-fold, full-duplicate pairs, false-positive pairs, the residual-sliver case, the contained-plug case) is completely unaffected. Against the real project's EXISTING EAST ELEVATION (855 elements), 726 genuine duplicates are removed with zero false additions, and the originally- reported corner case (a return wall end-on to the camera, coincidentally touching a perpendicular wall's broad face) is correctly left at its true minimal boundary rather than over-merged, because the new mechanism never makes that mistake in the first place. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The face-area-overlap test (BRepAlgoAPI_Common) used to decide whether two coplanar faces on different products should be matched was wrong for the majority of real "duplicate boundary" cases: two adjacent, non-overlapping faces (side-by-side slabs, a wall's void boundary against a plug's outer boundary) have zero area in common by construction, so the test silently rejected exactly the cases this feature exists to find. Replaced with a direct edge-to-edge coincidence test (collinearity + interval union over each face's boundary edges, including inner wires so void/hole boundaries are covered), operating on real 3D BRep edges. Also added material identity as a first-class, higher-priority comparison key alongside style: if either product resolves a material, both sides must resolve to the same material (style is not consulted); style is only compared when neither side has one. Previously two products sharing a rendering style but made of genuinely different materials could be wrongly matched. Material is resolved via a new schema-agnostic helper mirroring mapping::get_single_material_association()'s simple cases (a direct IfcMaterial, or the first layer of an IfcMaterialLayerSet/-SetUsage, regardless of how many layers the set has). Flipped svg-use-cross-coplanar-classification's default to on (still only takes effect when svg-use-edge-classification is also enabled). Known limitations, to be addressed in follow-up rounds: - Material/style is resolved once per product, not per face/layer. A multi-layer element (e.g. two layers, only one of which matches a neighbour's material) can produce a false positive on the non-matching layer's edge, since the single per-product material identity gates all of that product's face pairs uniformly. Will be resolved in v2 by resolving material per face instead of per product. - Only whole-edge coincidence is matched; an edge partially, but not fully, covered by a neighbour's edge is left entirely unclassified. Will be resolved in v3 by supporting sub-edge (partial interval) splitting. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Material/style was still resolved once per product ("whichever layer
resolves first"), so two products sharing a first-resolved layer material
but differing in another layer (e.g. both have a Corten 1 layer, but one's
other layer is Concrete 1 and the other's is Concrete 2) got every
coincident face pair between them classified cross-coplanar, including the
boundary where the differing layer actually touches.
Real geometric layer-splitting (enable-layerset-slicing /
AbstractKernel::apply_layerset) turned out to be unimplemented for the OCCT
kernel -- it throws not_implemented_error() and nothing overrides it, dead
by default since Bonsai never enables the setting. Rather than building out
that currently-nonexistent kernel feature, this resolves per-face material
via a lookup instead: IfcMaterialLayerSetUsage's own LayerSetDirection/
DirectionSense/OffsetFromReferenceLine/MaterialLayers attributes, combined
with the shape's own measured bounding-box extent along that axis to
calibrate the raw, unscaled layer thicknesses. A face's centroid is
projected onto the layering axis and binary-searched into the right layer,
giving that face's real material with a single dot product and no
geometry splitting, unit-scale assumption, or kernel changes required.
find_cross_coplanar_matches() now defers the material/style gate to this
per-face-pair resolution whenever either product is layered, falling back
to the existing whole-product gate unchanged for non-layered products.
Verified against the reported case (two layered slabs sharing a Corten 1
layer but differing in Concrete 1 vs Concrete 2): the Corten boundary now
matches, the Concrete boundary correctly doesn't. Full regression sweep
across all 4 synthetic drawings remains purely subtractive vs. baseline
(zero unexpected paths) with a few more genuine duplicates now caught
(cases with identical 2-layer stacks now match both layer boundaries, not
just one); Real-world project scratch check unchanged (2285/1546,
0 unexpected additions) -- no matching false-positive scenario existed
there, so this is a clean no-op as expected.
Still deferred: sub-edge/partial-coincidence splitting (v3), and
non-layer-set multi-material constructs (IfcMaterialProfileSet,
IfcMaterialConstituentSet).
Generated with the assistance of an AI coding tool.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The v2 per-face material lookup resolved a single material for a face via its centroid, which is only representative when that face is confined to one layer. A face that itself spans more than one layer (e.g. two visible faces of a layered slab/wall, or a face straddling an internal layer boundary) had its *entire* overlap with a neighbour judged by that one centroid sample: two products sharing one layer's material (e.g. both Corten 1) but differing in another (Concrete 1 vs Concrete 2) got every coincident face pair between them classified uniformly, including the boundary where the differing layer actually touches, and could also wrongly reject a genuinely-matching sub-range elsewhere on the same face. Pushes the decision down to accumulate_edge_coverage()'s own raw geometric overlap intervals: each interval is now split at every layer boundary either side contributes (new layer_boundary_ts() helper, projecting a layer_projection's internal boundaries onto the candidate edge's own t-axis), and each resulting sub-range is independently verified at its own midpoint via a new resolve_material_at_point() helper before being accepted into the edge's accumulated coverage. A sub-range where materials genuinely agree is kept even when a neighbouring sub-range of the same edge disagrees, and vice versa. The coarse per-face-pair centroid gate in find_cross_coplanar_matches() is removed entirely for the layered case, deferring to this per-sub-interval check; the non-layered fast path is unchanged. When neither side is layered, or a candidate edge doesn't cross a layer boundary at all (e.g. a horizontal edge on a vertically-layered slab), this degenerates to exactly one pass over the whole interval -- functionally identical to the v2 behaviour for every case that isn't affected by this bug. Verified via docker/ifcos_env: the reported case (two layered slabs sharing a Corten 1 backing layer but differing in Concrete 1 vs Concrete 2 on their visible face) now correctly splits -- the Corten boundary matches, the Concrete boundary is correctly rejected -- while the same pair's other, already-correct face is unaffected. Full 4-drawing synthetic regression sweep plus a real-world project (EXISTING EAST ELEVATION) sweep confirm every previously-established case (donut, angled, both layered/non-layered "extrusions" pairs, the tessellated different-material pair, the complex-wall partial-split case) remains correct, with additional genuine sub-interval splits now correctly applied to other pairs sharing this same "matches on one layer, differs on another" shape (a known shallow-angle sliver pair, and a 4-wall corner cluster) -- confirmed via each pair's actual IfcMaterialLayerSetUsage data, not just geometry. This branch carries over only the three C++ cross-coplanar commits from fix-3742-coplanar-svgfill-arrangement (cherry-picked cleanly onto current upstream v0.8.0, zero conflicts) plus this fix, deliberately leaving behind that branch's earlier, since-superseded Python-side join_coplanar_boundary_lines approach. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The now-superseded Python join_coplanar_boundary_lines had a MIN_VISIBLE_LENGTH = 0.1mm filter to drop degenerate sub-visual line segments before writing them to SVG. No equivalent existed anywhere in the current C++ pipeline, so floating-point noise (most visibly from the cross-coplanar sub-edge-splitting work, where two independently-computed cut points that are conceptually "the same" land a fraction of a millimetre apart, but also from ordinary HLR/edge-classification output unrelated to that feature) was reaching the final SVG as visible dots. Added the filter at the single choke point every edge passes through on its way to becoming SVG markup: SvgSerializer::write(path_object&, const TopoDS_Shape&, ...)'s per-edge loop. Skips the rest of the loop body entirely for a degenerate edge, without touching first/first_wire bookkeeping or bounding-box growth -- the next qualifying edge in the same wire naturally becomes the wire's effective start, and the existing `if (!path.empty())` guard already handles an entirely-degenerate wire producing no output at all. Getting the units right needed care: p1/p2 at this point are in model-space metres, not the final paper-space mm shown in the SVG -- a separate, deferred pass (SvgSerializer::resize()) only applies the scale_ * 1000 conversion afterwards, once the whole drawing's bounding box is known, by patching every already-buffered coordinate in place. An initial version of this fix wrongly compared the 0.1 threshold directly against raw metre-distances, which a full regression sweep caught before being reported as done: it silently deleted real geometry up to ~2.8mm of paper length in the synthetic test model. Fixed by reading scale_ (set earlier in the pipeline from the drawing's own EPset_Drawing.Scale, so already available at this point for the common explicit-scale case) and converting the edge's model-space distance to the same paper-space mm units the threshold is expressed in. When no explicit scale is set (resize()'s rarer auto-fit-to-page mode), the filter is skipped rather than guessed at, since the conversion factor genuinely isn't known yet. Verified via docker/ifcos_env: every one of the specifically-reported dot coordinates confirmed gone from NORTH SECTION. Full 4-drawing synthetic regression sweep plus a real-world project file's EXISTING EAST ELEVATION are purely subtractive versus the pre-fix baseline, zero unexpected additions, and nothing removed exceeds ~0.03mm (well under the 0.1mm threshold) in any drawing -- 207 genuine dots removed in that real-world elevation alone, with no legitimate geometry touched. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a new "mat-style-change" edge classification, additive to the existing cross-coplanar work, covering two cases where two coincident/coplanar surfaces differ in material or style rather than matching: - Case A (cross-product): two different products have genuinely coplanar, coincident faces, but their material/style differ. Previously silently left in whatever default classification the edge fell into. Reuses cross-coplanar's own coincidence-matching scaffolding (accumulate_mismatch_coverage(), a deliberate structural duplicate of accumulate_edge_coverage() so a future change to the match path can never accidentally affect the mismatch path or vice versa), accepting a sub-range precisely when both sides resolve a material/style identity and they differ. - Case B (intra-product): a single product's own face spans more than one layer of its own IfcMaterialLayerSetUsage (e.g. a section-cut face through a wall revealing its full material stack). Constructs new interior edges at each internal layer boundary the face's own extent crosses (layer_boundary_edges_for_face()), clipped to the face's true outline via the face's native (u,v) frame and BRepTopAdaptor_FClass2d. Wired up behind a new, off-by-default setting (SvgUseMatStyleChangeClassification / "svg-use-mat-style-change-classification", default false since it visibly reclassifies existing linework) with matching Bonsai property/UI/operator plumbing. Also fixes two bugs found while reviewing the new feature against real drawings: 1. classify_edge_from_faces()'s convex/concave sign test used only the first off-edge vertex of a face, with no fallback if that vertex happened to be near-coplanar (an unreliable, near-zero-dot sample). For a roof-type geometry with several near-coplanar triangulated vertices near a ridge/hip edge, an unlucky first vertex could flip the sign, which -- combined with the deliberately asymmetric ridge (45deg) vs valley (12deg) thresholds -- misclassified a genuinely sharp ridge as crease. Now scans every off-edge vertex and keeps the one with the largest |dot|, the most decisive/least ambiguous sample; behaviour- preserving for every edge where the first vertex was already decisive. 2. The cross-coplanar/mat-style-change matching in find_cross_coplanar_matches() validated a candidate face pair (A1/B1) purely on whether *their own* normals were parallel/antiparallel and coplanar. It never checked whether the faces beyond that boundary edge on each side -- A2 (product A's other real adjacent face at the edge) and B2 (product B's counterpart) -- were themselves consistent. A1/B1 being a genuine, exact touching boundary doesn't imply A2/B2 are: two wedge-shaped slabs sitting flush side by side (confirmed coincident to ~1.7e-7 world units) can still have their own top surfaces tilted a couple of degrees apart, in which case the shared boundary edge marks a real discontinuity in the outer surface, not a hideable duplicate seam. Fixed by additionally requiring A2/B2 to pass the same coplanar test already used for A1/B1 (now a single named constant, kCoplanarNormalTolerance, replacing four independent copies of the same literal) before extending coverage to that edge; when they don't, the edge simply keeps whatever classify_edge_from_faces() already, independently computes for it. Verified this doesn't regress the original motivating case (two products butting end-to-end with antiparallel touching normals): its own A2/B2 pair is an exact flat run (dot product of 1.0), unaffected by the new gate. Verified via docker/ifcos_env across the 4-drawing synthetic regression suite (NORTH SECTION, SOUTH SECTION, PLAN_VIEW, REFLECTED_PLAN_VIEW) plus a real-world project file (EXISTING EAST ELEVATION), toggling the new classification off and on: off is byte-identical to today's behaviour (zero cross-coplanar/mat-style-change classes emitted, matching linework otherwise), on is purely additive/reclassifying with no crashes. The two previously-reported anomalies (a wrongly-outline edge that should read mat-style-change; a wrongly-cross-coplanar edge between two non-coplanar tilted surfaces) are both confirmed fixed against the exact reported GUIDs, in every affected drawing, while every previously-established legitimate match (including the antiparallel butt-joint case above) remains correctly classified. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two exactly-overlapping duplicate objects (same footprint and placement, differing only in material) had their true outer silhouette wrongly classified as mat-style-change instead of outline. The existing A2/B2 coplanarity gate is a one-hop check: it excludes an edge from coverage only if the faces beyond it also fail coplanarity. That works for genuine partial touches, but a full duplicate has every face matching, including whatever's beyond any given edge, so the gate trivially passes even at edges that are the shape's true silhouette with nothing beyond them at all. Add a bounding-box-equality pre-check (on the pristine boxes, before the tolerance-based Enlarge()) as a cheap, deliberately narrow proxy for "these two products are exact full duplicates" -- skips the whole face-pair loop for such a pair, letting every edge fall through to ordinary single-object classification. Does not attempt to catch partial-volume overlaps (bounding boxes differ there); the failure mode if the heuristic ever mismatches is conservative (coverage just doesn't fire, never a confidently-wrong edge). Verified against a bespoke test fixture's exact-duplicate-slab scenario plus the primary fixture's known-good touching pairs (wedge, Round B, wall) and the full synthetic drawing sweep -- no regressions. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
classify_edge_from_faces()'s convex/concave sign test picked the "most decisive" (largest |dot|) vertex from face f1 to test against f0's plane, scanning every vertex of f1. That's only valid when f1 is convex -- every vertex of a convex polygon lies on the same side of any line through one of its edges, so the sign is invariant to which vertex is picked. When f1 is non-convex (an L-shaped face, or a face with a notch cut into it by a boolean void), vertices reachable only by crossing a reflex corner -- or lying on a distant, unrelated part of the polygon -- give an inverted sign. Confirmed against real geometry (an L-shaped roof slab's bottom face, and a wall's notch cut by an IfcOpeningElement): in both cases the wrong-sign vertex also had the larger |dot|, so the old heuristic reliably picked the wrong answer, misclassifying genuinely convex/sharp edges as concave/crease. Add is_reflex_vertex() (in-plane turn test against the face's own normal) and wire_neighbors_of_edge() (locates the target edge within a properly wire-ordered traversal of the face's boundary via BRepTools_WireExplorer, returning each endpoint's wire-neighbor vertex and whether that endpoint is itself reflex). Restrict the decisive-vertex candidate pool to wire-neighbors reached via a non-reflex endpoint; fall back to the original whole-face scan if both endpoints are reflex or the edge can't be located on any wire. This is a no-op for every convex f1 (the common case) and byte-identical for triangular faces, since the sign is provably invariant to vertex choice there -- verified by hand against both confirmed real cases. Also widen kOutlineDotEps from 1e-5 to 1e-4: tessellation noise on a genuinely edge-on face produced a normal component of ~1.05e-5, just barely exceeding the old threshold and causing a false front/front pair that let a 90-degree fold through as sharp instead of outline. Verified against all three originally-reported miscategorisations plus two follow-up isolated test cases, by exact coordinate match. Full regression sweep (before/after diff, not just visual check): two synthetic drawings and all 4 bespoke fixture scenarios byte-identical; every other change matches exactly what the fix targets. A real-world project file's corrugated roof sheet picked up a consistent bonus correction (76 wrongly-crease peaks now sharp, 134 genuine valleys still correctly crease). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When two different products produce edges at (or extremely near) the same real-world location -- e.g. two touching objects whose shared boundary is visible from both sides -- which one paints on top in the final flat SVG used to be pure document order, not anything spatial or correctness-based. That order comes from IfcGeom::Iterator's multi-threaded worker-completion order (a race between threads finishing geometry conversion for different products); since SVG has no Z-index, whichever product happened to be written later simply painted over the other regardless of true camera-space depth. Real, correct, cross-product HLR already runs (every product sharing a drawing/storey shares one HLRBRep_Algo/PolyAlgo instance), so this was never a visibility bug -- both of two touching products' coincident edges are genuinely, correctly visible; the bug was purely which one got to paint last. Adds a coincident-edge deduplication pass in SvgSerializer::draw_hlr(), between HLR extraction and SVG emission, using two complementary detection mechanisms: - Exact-match: buckets every edge by a canonical (endpoint-order-independent) key from both endpoints, quantized to svg_cross_coplanar_tolerance_-scale precision. When a bucket's edges span more than one product AND more than one class-priority level (outline beats mat-style-change/cross-coplanar, which beat sharp/crease/flush/boundary), only the best-classified edges survive. - Collinear-overlap: catches a case exact-match misses -- one product's short edge fully nested inside another product's collinear, longer edge, sharing at most one endpoint. Buckets edges by direction only (a coarser, more robust prefilter than an earlier two-level direction+position hash, which silently missed a real case when two independently-computed collinear edges' quantized positions landed in adjacent, not identical, hash cells -- ordinary floating-point noise between separately-computed geometry), then applies the real (non-quantized) collinearity and parametric-overlap test within each bucket. The overlapped range on the lower-priority edge is trimmed via the existing cross_coplanar:: split_edge_by_coverage() machinery, reused as-is since it already operates on bare edges/intervals with no face-topology coupling. Both mechanisms deliberately leave same-class duplicates across products untouched (e.g. both sides of a legitimate seam independently computing cross-coplanar, or both sides of a real material transition computing mat-style-change) -- that's intentional (both meant to show together when cross-coplanar/mat-style-change rendering is enabled for QA, or both hidden together otherwise), not a paint-order bug to fix. An earlier version of the exact-match pass picked one arbitrary "winning" product per bucket regardless of whether classes actually differed, which wrongly collapsed legitimate same-class duplicates down to one and was caught via a full fixture regression sweep before being corrected. edge_to_line_seg() (built for pre-HLR real-face-topology edges, requiring a Geom_Line 3D curve) always returned none for HLR output, since those edges have no 3D curve attached yet at this point in draw_hlr() -- fixed by building the LineSeg directly from vertex positions instead, sufficient since HLR output for a BRep model is always straight polygonal segments. Verified against every originally-reported case (ORTHOGRAPHIC, ORTHOGRAPHIC-X, EAST SECTION, NORTH SECTION TILT) plus a full regression sweep: the primary synthetic fixture's remaining drawings, the original edge-classification Test.ifc, the bespoke fixture's 4 scenarios (including multi-neighbour coverage, the specific same-priority-overlap safety case), and a real-world project file -- every non-empty diff hand-verified as a genuine coincident/overlapping duplicate correctly resolved by class priority, none a regression. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HLR's joint visibility computation (every product sharing a drawing/storey shares one HLRBRep_Algo/PolyAlgo instance) sometimes marks a genuine, unambiguous single-object silhouette edge as hidden purely because it lies exactly coincident with a *different* product's planar face -- e.g. one object resting directly on another's sloped surface, where the touching boundary is an exact depth-tie, not real occlusion. Confirmed directly: such an edge is entirely absent from all four of HLRBRep_HLRToShape's visible-edge query buckets (VCompound/OutLineVCompound/Rg1LineVCompound/ RgNLineVCompound), i.e. genuinely hidden by HLR's own tie-breaking, not merely misrouted into an unqueried bucket. Adds restore_coincident_hidden_edges() in SvgSerializer.h, called from prefiltered_hlr::build() right after HLR extraction. For each classified single-object edge (outline/sharp/crease/flush -- cross-coplanar and mat-style-change are excluded, since foreign-face coincidence is the intentional definition of those classes) missing from HLR's visible output, restores it when both hold: - Its endpoints and midpoint lie exactly on some *other* product's planar face (point_on_foreign_face()), confirming the coincidence. - Nothing is genuinely nearer to the camera at that point (a line-of-sight ray-cast per candidate sample point, via IntCurvesFace_ShapeIntersector, against every product's solid -- including the edge's own product, since self-occlusion by a product's own other geometry is just as valid a reason to stay hidden as foreign occlusion). Two correctness fixes and one design refinement came out of QA against real rendered output, each confirmed with reproducible before/after evidence rather than assumed correct on first pass: - The ray-cast originally excluded the candidate edge's own product, so self-occlusion (a box's back wall behind its own front wall, any ordinary back-facing crease) could never be detected -- any such edge that also happened to satisfy the unrelated foreign-face-coincidence test was wrongly restored. Fixed by including self in the ray-cast; the point sitting exactly on its own product's surface lands at essentially its own depth, which the existing depth(q) < depth_p - tolerance comparison already treats as "not nearer," not a false positive. - IntCurvesFace_ShapeIntersector::Load() was rebuilt from scratch per (candidate edge x sample point x product), causing a ~30x slowdown on a real building (234s vs ~8s on a real-world project file). Fixed by loading one intersector per item once up front and reusing it via repeated Perform() calls with different rays, matching the class's intended usage. - A remaining false-positive class: two coplanar but *offset* objects side-by-side (not stacked in view depth), whose combined footprint visibly covers a point that no single object's solid genuinely occludes along a straight ray. The matched coincident face in every such case turned out to be edge-on to the camera (its plane contains, or is nearly parallel to, the view direction) -- a structural blind spot for any ray-vs-face test, since the ray can't produce a proper crossing intersection with a face it travels almost within. Confirmed empirically across all known cases: the genuine, restore-worthy match had a clearly camera-facing normal (~(0,0.97,0.24)); both false-positive cases matched a normal within ~3 degrees of perpendicular to the view direction. Fixed by rejecting a coincident-face match in point_on_foreign_face() when the face is edge-on (|face_normal . view_direction| < 0.05), leaving such a candidate ineligible for restoration via this mechanism. Verified against a full regression sweep (the primary synthetic fixture's 9 drawings, the original edge-classification Test.ifc, the bespoke fixture's 4 scenarios, and a real-world project file) plus all three known reproduction points by exact coordinates: the original missing-edge case still restores correctly, and both "shifted wall" false-positive cases found via direct user review of rendered SVGs are confirmed gone, not just reclassified. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds src/ifcopenshell-python/test/geom/test_svg_edge_classification.py: 7 passing scenarios covering cross-coplanar and mat-style-change baseline classification, Cluster A's exact-match dedup, Cluster B's depth-tie restoration, self-occlusion guard, and the full-duplicate-pair silhouette guard. All geometry is synthetic, built via low-level IFC entity creation; where a scenario originated from a real bug, its exact profile/placement numbers were extracted from the real fixture and hand-reconstructed (verified byte-for-byte identical), never a committed .ifc file. Each passing scenario was also checked against pre-fix behaviour by temporarily neutering the relevant fix and confirming the test actually fails. Two scenarios (Cluster A's collinear-overlap dedup, Cluster B's edge-on rejection guard) are marked skip with detailed investigation notes rather than shipped as fake/forced tests -- both real mechanisms, but a minimal repro wasn't isolated in the time available. Parked for now; drop them before the PR if still unresolved. While building the mat-style-change Case B test, found and documented the root cause of a known coupling bug: resolve_layer_projection() in SvgSerializer.cpp is gated behind svg_use_cross_coplanar_classification_ instead of (or in addition to) svg_use_mat_style_change_classification_, so Case B silently does nothing when cross-coplanar classification is off. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Case B (intra-product IfcMaterialLayerSetUsage layer-boundary edges) silently did nothing with use_mat_style_change alone: resolve_layer_projection() was only called when use_cross_coplanar_classification_ was also true. Widened that gate in SvgSerializer.cpp to fire on either flag, since Case B needs no cross-product comparison at all. Case A (cross-product material/style mismatch) had the opposite problem: find_cross_coplanar_matches()'s entry gate let the whole cross-product matching pass run from mat-style-change alone, even though Case A reuses that same pass and should require cross-coplanar too. Confirmed as a genuine, not just theoretical, regression risk: with only the Case B fix applied, layered products with mismatched top-layer materials wrongly produced cross-product mat-style-change edges without cross-coplanar enabled, since resolving layer_projection (now independent of the coplanar flag) let such pairs bypass the unrelated material/style-null rejection that had been accidentally masking this gap for plain-material products. Narrowed the entry gate back to requiring cross-coplanar, matching its own already-stale preceding comment. Corrected the intended, user-confirmed matrix is now: - coplanar off, mat-style off: neither - coplanar off, mat-style on: Case B only - coplanar on, mat-style off: cross-coplanar edges, no mat-style-change - coplanar on, mat-style on: cross-coplanar edges, Case A and Case B both Updated ConversionSettings.h's SvgUseMatStyleChangeClassification description to match (it previously and incorrectly claimed both cases were independent of cross-coplanar). Per TDD: test_mat_style_change_case_a_requires_cross_coplanar_too(_when_layered) and the now-corrected test_mat_style_change_case_b_intra_product_layer_boundary were written/modified first and confirmed to fail against the unfixed code for the expected reasons before the fix was applied. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
classify_edge_from_faces() and wire_neighbors_of_edge() in SvgSerializer.cpp had three separate bugs, all found investigating a real user-reported project drawing where several window frame edges rendered as `crease` instead of `sharp`: 1. The `back0 && back1` "seen through an opening" flip fired on any back-facing convex fold, with no check that the product actually has an opening. Now gated on a new `product_has_naked_edge` bool, computed once per product from the existing edge-face map. 2. wire_neighbors_of_edge()'s reflex-corner test assumed a wire always winds counter-clockwise relative to its face normal. On some faces this doesn't hold -- confirmed a plain rectangular quad with every vertex wrongly testing reflex. Fixed by self-correcting the effective normal per-wire from the wire's own aggregate signed turning. 3. The actual root cause of the reported symptom: the convexity sign test picks a "trustworthy candidate" vertex by walking further around a face's own boundary wire. That's only valid on a face's outer boundary -- on an inner (hole/void) boundary wire, the next vertex around the same rim lies on the far side of the empty hole, not on material, giving a dot product that's always the wrong sign regardless of the true 3D fold. This affects any product extruded from an IfcArbitraryProfileDefWithVoids profile (a genuine hole, not a notch). Fixed by pooling sign candidates from both faces meeting the edge, each tested against the other's normal, and preferring whichever came from an outer wire. Three new regression tests added to test_svg_edge_classification.py, including one built from the reporting user's own project geometry (profile points, placements, and a camera angle they saved specifically to inspect this bug) reconstructed via low-level IFC entity creation -- not a real project file, just its bare numeric geometry. All three were confirmed to fail against the pre-fix code for the right reason before the fix landed, per this branch's established TDD workflow. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Case B (intra-product layer-boundary lining) drew its internal Concrete/Insulation boundary line on any eligible face regardless of whether that face was actually a matched touching boundary with a neighbouring product's face -- duplicating what cross-coplanar/Case A already resolves for that same face pair, and showing up as a spurious edge (e.g. rendered vertical) at the touching interface. Moved Case B from a per-item pass (before other products are known) to its own pass in hlr_t::build(), run after every product sharing a drawing/storey has been added -- kept deliberately independent of find_cross_coplanar_matches()/use_cross_coplanar_classification_ so it still works with cross-coplanar off, per the matrix pinned by the P1-1 fix. A new find_coplanar_coincident_faces() reuses the same normal-parallel + plane-distance test find_cross_coplanar_matches() already uses (not reimplemented) to find matched neighbour faces; layer_boundary_edges_for_face() unions their outline crossings into its own cut-point set and drops any resulting sub-range whose midpoint lands on a matched face, so partial overlaps split correctly instead of being judged all-or-nothing. This surfaced a second bug: moving Case B's emission point changed its position in the classified-edges list relative to outline/sharp for the same product, flipping SVG paint order (no z-index -- later elements paint over earlier ones) and exposing several previously invisible mat-style-change/outline overlaps that had been accidentally masked by the old ordering. compute_coincident_edge_overlap_coverage() (Cluster A's collinear-overlap dedup) already trims exactly this kind of nested duplicate, but explicitly skipped same-product pairs since it was only ever built for the cross-product case; relaxed that guard rather than reintroducing an ordering dependency. Also closes P2-12 (outline vs. mat-style-change indeterminate overlay at 2CH9sv/2u$gv). Verified against the real fixture that reported the bug (byte-for-byte identical elsewhere) and against a broader before/after sweep of every drawing in both test fixtures; full regression suite passes with two new tests for the matched/partial-overlap split. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
restore_coincident_hidden_edges() (added in 0078c94872) restores a single-object edge HLR hid due to a depth-tie with a coincident foreign face -- but decided restoration on a whole-edge, all-or-nothing basis from only 3 fixed interior sample points (mid/q1@0.25/q3@0.75). Two distinct bugs came out of chasing real user-reported examples where a restored edge visibly overshot into an occluding object's footprint. Bug 1: sparse sampling causes wrong all-or-nothing decisions. When only part of a candidate edge is a genuine depth-tie and the rest is genuinely occluded by a nearer, unrelated object, the old 3-sample gate either restored the whole edge (if none of the 3 samples landed in the occluded portion, overshooting past the true occlusion boundary) or dropped the whole edge (if any sample landed in the occluded portion, wrongly losing the genuinely-restorable outer portions too). Fixed by replacing the 3-sample gate with a bounding-box-prefiltered, 31-point interior sample grid producing per-edge restorable sub-intervals (restorable_intervals()), merged via the existing cross_coplanar::ivs_union() and split into multiple restored sub-edges instead of one full-length edge when coverage isn't uniform. A full-coverage fast path keeps the common (fully- restorable) case byte-identical to the old single-push behaviour. point_on_foreign_face() gained a bounding-box-shortlisted candidate list so the denser sampling doesn't reintroduce the ~30x per-face-loop slowdown 0078c94872 already had to fix once. Bug 2, found chasing why the above didn't resolve a real user-reported overshoot: has_coincident_edge() and the new point_covered_by_visible() both compared a candidate's transformed points against visible_edges using full 3D Distance() -- but visible_edges' own vertices (HLR's reconstructed 2D screen-space output) always carry Z=0 in this projected space, while a freshly-projector.Transform()-ed candidate point retains a real, generally-nonzero depth component. Comparing full 3D distance against a phantom depth axis visible_edges never populates meant neither check could ever actually match, even for a point genuinely coincident on screen -- confirmed via debug instrumentation this literally never fired in practice, since has_coincident_edge was introduced. Concretely: a correctly-clipped native outline edge was already sitting in visible_edges the whole time; the "already visible, don't duplicate" check just could never see it, so the full original candidate got needlessly (and, since its own endpoint calculation differs slightly from HLR's own, visibly wrongly) restored on top of it every time this pass ran. Fixed by flattening both sides of the comparison to the screen plane (X, Y) before measuring distance. Verified against the real "coplanar join.ifc" EAST SECTION drawing (the user's own originally-reported example, GUIDs 1I9V3NogL069$EyK070JfW / 2lMPZETv5DOhI4hiZPs3ZP): the wrongly-restored, ~0.39-unit-overshooting duplicate is gone entirely, and the genuinely-correct native edge is unchanged -- output now matches the restore-pass-disabled baseline exactly. Full-fixture regression sweep (all 10 primary + 4 bespoke drawings, both classification flags on): no crashes, no degenerate/zero-length/NaN edges; outline counts decreased substantially on most drawings relative to the original baseline, confirming bug 2 had been causing systemic, fixture- wide spurious duplication, not just the one reported edge. New regression test test_restore_coincident_hidden_edges_partial_occlusion covers bug 1 (extends test_cluster_b_depth_tie_restoration's proven SlabA/SlabB depth-tie scene with a partial occluder). Bug 2 is verified directly against the real fixture rather than a synthetic test, documented in that same test's docstring -- this class of scenario (a candidate edge that HLR's own native computation also independently makes visible) has repeatedly resisted synthetic isolation elsewhere in this suite's own history (test_cluster_b_depth_tie_restoration, test_cluster_b_edge_on_ face_must_not_restore), and forcing one here risked a non-reproducing test. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
find_cross_coplanar_matches() can steal a product's genuine outline/sharp classification and replace it with cross-coplanar/mat-style-change even when the matched neighbour is actually hidden behind a third object from the current camera. Adding occlusion-awareness (a screen-space ray-cast per candidate match) surfaced six distinct, related bugs, found and fixed in sequence against both synthetic tests and a real building file: - Self-occlusion: the occlusion ray-cast didn't exclude the two products of the matched pair itself, so two directly-stacked, same-footprint objects could find each other as a false "occluder". - Whole-face vs. per-interval granularity: testing a single point (the whole face's average) let an unrelated object partially blocking one small portion of a long face wrongly reject coverage for the entire face. - IfcOpeningElement treated as a solid occluder, in both find_cross_coplanar_matches() and the separate, already-shipped restore_coincident_hidden_edges() -- a window/door void can never legitimately block anything. - Divergent per-side occlusion decisions: the per-interval probe offset toward "this face's own" interior differed between the two sides of a matched pair, so the same physical boundary could split at different points on each side and defeat the coincident-edge dedup pass. - Single-probe ray-cast grazing miss: a boundary point tested with one unoffset ray can miss a genuine occluder whose own footprint edge happens to coincide with that exact boundary, at a shallow camera angle. Fixed with a small, side-symmetric dual-nudge probe (toward both faces' own interiors, computed once per matched pair so both sides agree). Verified via the existing regression suite plus new tests, a full-fixture sweep, and direct investigation against a real building file using its own EPset_Drawing.Include filter for accurate, fast (~27s vs ~12min) iteration. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
restorable_intervals() decides which portion of a hidden edge to restore from a fixed 31-sample grid (32 bins): each restorable/occluded transition landed at the midpoint between two adjacent samples, length/32 apart. For a long edge this is a real, visible gap between the reported cutoff and the true geometric silhouette intersection with the occluding object, not just numerical noise -- confirmed against a real building file where a slab's own edge, correctly clipped by a wall standing on it, extended measurably past the wall's own top-edge outline. Adds refine_transition(): a 12-iteration bisection between a confirmed- restorable sample and its confirmed-occluded neighbour, using the same point-restorability test (factored out into is_restorable_at()), narrowing each transition to well under the geometric tolerance. Boundaries between two same-verdict neighbouring samples are left at the coarse midpoint -- those get absorbed by the interval union regardless of exact position, so refining them is unnecessary. test_restore_coincident_hidden_edges_partial_occlusion's hardcoded transition coordinates are re-captured to match the more precise cutoff (shifted by under one bin width, as expected). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compute_coincident_edge_best_priority()/compute_coincident_edge_overlap_ coverage() (Cluster A, a08873af07) decide which of two coincident-duplicate edges from different products paints on top by testing collinearity/overlap in the edges' true 3D coordinates. That's correct for genuine 3D coincidence, but these two functions exist to answer a screen-space question -- "which edge paints over the other in the final flat SVG" -- and two edges can land on nearly the same screen location while genuinely differing in true 3D depth (e.g. a wall's own slightly sloped/raking edge crossing a level slab edge). Confirmed directly against a real building file: three flagged wall edges each duplicated another product's already-correct outline edge on screen while differing from it by up to ~0.7 model units of true 3D depth -- the existing dedup pass correctly, but unhelpfully, rejected them as unrelated. Added project_to_view_plane(), used only by these two dedup functions. By the time draw_hlr() runs, hlr_items' edges are already expressed in the camera-aligned frame HLR was computed in -- X/Y are already the SVG plot coordinates and Z is purely depth-into-screen (see SvgSerializer::write()'s own comment on this convention) -- so "projection" here is just dropping Z, confirmed by comparing X/Y-only endpoint deltas directly against real rendered output. compute_coincident_edge_overlap_coverage() now builds a second, screen-projected LineSeg per entry for the collinearity/perp- distance/overlap tests, then rescales the overlap fraction back onto the original edge's true 3D length before recording it for clipping -- exact, not approximate, since parallel/orthographic projection preserves a segment's [0,1] parametrization. Verified against the real file: two flagged wall groups (each holding only spurious duplicate paths) now disappear entirely, matching the expected behaviour; a third improved from two long spurious edges to four tiny leftover fragments (not yet fully resolved); the winning-side product (a slab) is byte-identical to before, confirming no regression. Full pytest suite green, 14-drawing fixture sweep clean. No permanent synthetic regression test added -- several attempts at a minimal repro each triggered a different, dominant occlusion interaction instead of isolating this specific "coincident only in projection" scenario; relying on the real-file verification and unchanged synthetic suite instead, per this branch's own precedent for honestly parking a hard-to-isolate case. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_genuinely_occluded() (restore_coincident_hidden_edges()'s own occlusion check) tests a single, unoffset ray per sample point -- the same grazing-ray vulnerability find_cross_coplanar_matches()'s occlusion check had before its own multi-probe fix (P1-4d Bug 6): a ray whose own footprint edge happens to coincide with the tested point can drift past the occluder before reaching its base height, at a shallow camera angle. Not a straight reuse of that fix, since the two checks have different exclusion semantics: find_cross_coplanar_matches() excludes a known PAIR (idx_i/idx_j) since two products standing on/against each other must never count as occluding their own touching boundary. is_genuinely_occluded() has no such pair -- it deliberately excludes nothing by default, since a candidate's own other geometry legitimately occluding it (e.g. a box's back face behind its own front face) must stay hidden. point_on_foreign_face() now returns the matched face and its owning product (previously just a bool), so is_restorable_at() can nudge its own probe toward that face's own interior the same way, without disturbing the "no exclusion by default" design for the plain probe. Two regressions found and fixed while implementing this, via direct debug tracing against the real building file each time: - First attempt excluded only the foreign product from the nudged probe. This caused two previously-correct real-file outline edges to disappear entirely. Root cause: the nudge target (face_interior_point(), an average of a face's own coplanar vertices) moves the probe tangentially across the shared boundary rather than into the foreign product's volume -- but under an oblique (isometric) camera, that tangential move still produces a real depth change, and the now off-the-exact-boundary point could find the CANDIDATE's own nearby-but-not-actually-blocking geometry and wrongly self-occlude (the unnudged probe never has this problem, since a self-hit lands within tolerance of the boundary's own depth by construction). Fixed by excluding BOTH the candidate's own product and the foreign one from the nudged probe specifically, mirroring find_cross_coplanar_ matches()'s own idx_i/idx_j pair exclusion. Verified: full pytest suite green throughout (18 passed, 2 pre-existing skips). Full-fixture sweep (10 primary + 4 bespoke drawings): no crashes. Real-file re-render confirms both regressed edges restored byte-identical to the confirmed-good baseline, with no change to any already-fixed case. Does not resolve the one remaining unresolved real-file fragment (a large stray sharp-classed edge on 0MmPs4lGP0zgpUVE$tStUb) -- its own originating code path was never confirmed to go through restore_coincident_hidden_ edges() at all, so this fix's scope doesn't cover it; see the branch's own known-issues notes for the full investigation trail on that one. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Geom_Line was relied on transitively via another header's include order; make the dependency explicit so it isn't fragile to reordering. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
accumulate_edge_coverage()/mat_style_change::accumulate_mismatch_coverage() tested screen-space occlusion at a single midpoint per raw matched interval, accepting or rejecting the whole interval from that one sample. When occlusion genuinely varied along an edge's length, a long genuinely-visible stretch could be wiped out entirely because its one sample landed on a small real occluded patch -- reported by the user from real project drawings (large sub-edges wrongly falling back to outline while neighbouring tiny fragments, tested independently, survived). Adds cross_coplanar::occlusion_clear_subintervals(), porting the same coarse-sample + 12-iteration bisection-refine approach restore_coincident_hidden_edges()'s own restorable_intervals() already uses, so a raw interval is split into proper occlusion-clear sub-ranges instead of one all-or-nothing verdict. The finer sampling this introduces surfaced a second, related bug: the existing occlusion-awareness nudge (5% of distance to each face's own centroid, added in P1-4d to fix a grazing-miss false negative) has no relationship to how far away a genuinely separate object actually is. For a large face sitting only a few millimetres from an unrelated third product, 5% can be larger than that real gap, wrongly crossing into the unrelated object's own solid. A fixed nudge cap can't replace it either -- the reported real gap (~0.015 project units) is smaller than the several- centimetre drift the original grazing fix needed to tolerate, so no one constant is small enough to avoid the former and large enough to catch the latter (confirmed by direct calculation, not just reasoning). A first attempt measuring the nearest foreign object's distance along the nudge direction was built and tested (full before/after render of all 10 primary fixture drawings) to change nothing, since that tangential direction is generally not the direction the actual occlusion ray travels in. Fixed by gating the nudge on touches_foreign_face(): only trust a nudge-discovered occlusion when some foreign face's plane genuinely passes within tolerance of the original, unnudged boundary point -- the same coincidence test restore_coincident_hidden_edges()'s own point_on_foreign_face() already uses. A real touching occluder passes this; a separate object a few millimetres away doesn't. Verified: new regression test (TDD, confirmed red on unfixed code) plus full suite green except one pre-existing, unrelated stale test (test_restore_coincident_hidden_edges_partial_occlusion, a different function's hardcoded bisection coordinates, not touched here). Full before/after render of all 10 primary fixture drawings confirms every changed edge moves outline -> cross-coplanar/mat-style-change (or a sub-unit precision shift), never the reverse -- fixing several further latent instances beyond the two originally reported. User-confirmed via bonsai_test against the real fixture (PLAN_VIEW, ORTHOGRAPHIC-X, ORTHOGRAPHIC-X-X all matching known-good baselines). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
find_cross_coplanar_matches() only ever asked "is this specific shared boundary blocked" on a per-edge basis -- it had no notion of "is the object on the other side of this match, as a whole, visible in this drawing at all". A product that is itself almost entirely hidden behind other products can still have one localized seam that individually passes the existing per-point occlusion test (nothing else happens to stand in front of that particular line), even though the product contributes nothing else to the drawing. Reported by the user from a real project file: a wall 95%+ hidden behind a second wall and a slab still had its one surviving, same-material seam accepted as a genuine cross-coplanar match, which wrongly suppressed the plain outline corner between the two genuinely-visible, non-coplanar products (a wall and a slab meeting at a right angle -- never coplanar with each other at all). Adds a whole-object visibility pre-filter to find_cross_coplanar_matches(): before the main pairwise loop, each item's own edges are sampled (11 points per edge, majority vote, matching the same "don't trust one sample" lesson occlusion_clear_subintervals() already learned elsewhere) against every other item's occlusion intersector; a product with zero genuinely-visible edges is excluded from participating in matching entirely, on either side of any pair. Native single-object classification and restore_coincident_hidden_edges() are untouched. Two rounds of real-file-driven correction on the way to this: - A first cut used "<=1 unoccluded edge" as the threshold, reasoning a full BRep box has ~12 edges. Wrong: by the time this pass runs, items_'s shape has already been through this class's own prefiltering, so a fully-visible box can have as few as 4 edges total. That threshold broke a real, intended partial-match case in the existing suite. Tightened to "0 visible edges" -- the only threshold that can't suppress a genuinely partially-visible product. - The occlusion test itself needed the same grazing-ray nudge used elsewhere (is_occluded_for_pair/is_genuinely_occluded), but generalized: a sample point can genuinely sit on more than one other product's coincident face at once (confirmed real-file case: the hidden wall's problem edge touched both a large slab's top face AND the actual occluding wall's face). Taking only the first match by item index picked the irrelevant slab and missed the real occluder. Fixed by trying every genuinely-touching candidate's own centroid and OR-combining the results, rather than stopping at the first found. Verified: full existing suite green (19/20; the one failure is pre-existing, unrelated, already noted in this branch's prior commit). New regression test added (confirmed red without the pre-filter, green with it), though it validates the overall exclusion mechanism rather than surgically pinning the specific multi-candidate sub-bug -- see the test's own docstring for why a fully faithful synthetic repro of that exact grazing-ray scenario wasn't achieved in reasonable time. Re-verified directly against the real-world project file that motivated this: the reported junction now renders the expected plain outline instead of a spurious cross-coplanar edge, and 4 additional real drawings across plan/isometric/elevation/section projections render without exceptions. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"Use Edge Classification" and its children (crease/sharp/flush rendering, cross-coplanar, mat-style-change) remained visible and toggleable in the camera/drawing panel regardless of Linework Mode, even though they have zero effect under Freestyle: setup_serialiser() (and therefore the SVG serializer and every classification setting) is only ever invoked from generate_linework(), which only runs in the OpenCASCADE branch of the linework-mode dispatch. Freestyle mode routes entirely through generate_freestyle_linework(), a separate Blender-native path that never touches SvgSerializer. A user toggling these under Freestyle could reasonably expect them to do something. Nests the existing use_edge_classification block inside the panel's own `if props.linework_mode == "OPENCASCADE"` guard (ui.py), which already hides fill_mode/cut_mode the same way -- same convention, no new UI pattern. Deliberately not touching prop.py's persistence layer: an existing EPset_Drawing.UseEdgeClassification value (and friends) is preserved, not reset, when switching to Freestyle and back. Not touching any C++/SvgSerializer code, since the setting already has no effect there. Verified: black/ruff clean. No existing automated test covers this panel's draw() logic; recommend a manual check in Blender (toggle Linework Mode, confirm the row appears/disappears like Fill Mode/Cut Mode already do). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SvgSerializer::write()'s unify_inputs_ path (Bonsai's drawing operator always enables this via setUnifyInputs(True), so this runs on every real drawing, not just an opt-in case) called IfcGeom::util::unify() at a hardcoded 1.e-6 tolerance -- bare OCCT confusion-level, far below the ~0.1mm of sub-millimetre noise real IFC authoring/export round-trips routinely leave behind (a redundant near-collinear vertex, a boolean byproduct). At that tolerance, ShapeUpgrade_UnifySameDomain (inside unify()) essentially never merges the near-coplanar faces either side of such noise, so a single visually-straight edge ends up emitted as multiple collinear segments in the SVG output. Adds IfcGeom::util::heal_for_linework(): sews the shape first (faces that are geometrically coincident but topologically disjoint -- the normal result of concatenating independently-built representation items into one compound -- don't share edges, so unify() alone can never see them as mergeable), then unify()s, then falls back to ShapeFix_Shape if the result doesn't validate, and finally falls back to the original input shape if nothing validates. The SvgSerializer.cpp call site now passes svg_cross_coplanar_tolerance_ (0.1mm default) instead of the hardcoded value -- the same tolerance this module already trusts for "is this coincident?" decisions elsewhere, applied consistently rather than a second, unrelated, much stricter one. heal_for_linework() is only ever called on one product's own local shape (SvgSerializer::write(const IfcGeom::BRepElement*) is inherently per-element), so this can't erase cross-object boundary information the cross-coplanar classification pass depends on. New regression test: a wall profile with a single vertex offset 0.05mm off an otherwise dead-straight edge (bigger than the old tolerance, smaller than the new one) renders as two collinear segments unhealed, one clean edge healed. No connection found to an existing tracked issue for this specific fix after checking the branch's own known-issues backlog and memory -- recorded here for future reference in case that backlog needs a retroactive entry. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
918ddc1a9c (UnifyInputs sewing/tolerance change) broke what 257dcdba29 (the whole-object visibility pre-filter) had fixed: the healed geometry changed which raw edges survive, and the slab from that original report started being wrongly excluded from matching itself, alongside a second, more severe, previously-undiscovered flaw: the "<=1 unoccluded edge" rule (later "0") could not tell "swallowed entirely by one dominant neighbour" (should exclude) apart from "surrounded by many distinct neighbours around its own perimeter" (should NOT exclude, e.g. a slab touching a different wall on every side). Root-caused against five further real pairs the user supplied from the same project file, plus the existing synthetic suite, in three rounds: 1. Tracking WHICH item occludes each sampled edge point, not just whether it's occluded, and excluding only when that occluder set has at most one distinct member -- intended to separate "one dominant swallower" from "many legitimate neighbours". Fixed 3 of 5 real pairs; 2 remained wrongly included. 2. The remaining two turned out to share a for-loop bug, not a threshold problem: the edge-sampling loop stopped as soon as the unoccluded count crossed its threshold, so the accumulated occluder set silently depended on TopExp::MapShapesAndAncestors's own edge enumeration order rather than the product's real, complete neighbour set. Removed the early exit (every edge is now always evaluated -- cheap relative to the O(products^2) loop this pre-filter precedes, same trade-off this filter's own top comment already accepted). 3. With complete, order-independent data for all five real "exclude" cases plus the real "keep" slab, distinct-occluder count needed bounding on BOTH sides, not just capped: the five exclude-cases have 4-8 distinct occluders, the slab has 18 -- but a synthetic suite case (`test_cross_coplanar_match_must_not_steal_edge_from_occluded_ neighbour`'s `LowerWall`, occluded almost entirely by one unrelated stacked wall) has exactly 1. A single dominant occluder is an ordinary partial overlap, not the "swallowed by several neighbours at once" pattern the five real cases share. Excluding now requires at least 2 distinct occluders and at most 12, comfortably clear of both 1 and 18. Also fixed, found along the way: the occlusion test used for this pre-filter always excluded an item's own shape as a possible occluder of its own edges, so a native back-face edge hidden only by this same item's nearer geometry (never checked elsewhere either, matching is_genuinely_occluded()'s own established reasoning for not excluding self) could read as falsely visible. Turned out not to be the deciding factor for any of the real cases here, but is a real, separate correctness fix worth keeping. Verified: full suite green (20/21; the one failure is the same pre-existing, unrelated stale-coordinate case already called out in this branch's prior commits). All 5 real-file pairs now correctly resolve (fully excluded, or -- where the object has one genuinely visible boundary -- a plain outline instead of cross-coplanar/mat-style-change). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Preserving this as a rollback point before a larger redesign, not as a finished fix -- see the follow-up commit/branch history for context. find_cross_coplanar_matches() previously had no way to reject a face pair whose faces are coplanar and touching in 3D but where one side is a product's own permanently-invisible face (e.g. a wall's underside resting on the slab it stands on). Confirmed via direct debug instrumentation against a real building file that this produces spurious mat-style-change/ cross-coplanar edges along a wall's own footprint perimeter, independent of any hidden third object -- universal for any wall standing on any slab, and pervasive (45 of 75 products in one real drawing were affected). This guard rejects a candidate face pair outright if either face is clearly back-facing relative to the camera, reusing classify_edge_from_ faces()'s own front/back convention. It fixes the specific reported corner and shrinks (does not eliminate) the wider pattern. KNOWN REGRESSION: two products touching face-to-face necessarily have opposite-facing normals at that seam, so for any camera that isn't exactly edge-on to it, one side mechanically reads "facing away" even for a completely legitimate match -- this wrongly turns real cross-coplanar/ mat-style-change seams into plain outline in elevation views. The pair-level rejection is the wrong mechanism; a location-centric redesign (deciding per shared edge location, not per product pair) is planned as a follow-up to replace this entirely, including the whole-object visibility pre-filter from the immediately preceding commit. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Discovered while verifying an unrelated cross-coplanar redesign against a real building file's elevation views (a camera angle never previously exercised in this branch's testing -- only isometric views had been rendered against real project files so far). Confirmed via git checkout that this crash already exists on this exact commit, unrelated to any in-progress classification work. restore_coincident_hidden_edges() builds one IntCurvesFace_ShapeIntersector per item up front for reuse across every candidate edge/sample point. Certain real-world product shapes make OCCT's BVH/face-intersector construction throw Standard_NullObject from IntCurvesFace_ShapeIntersector:: Load(), which previously propagated uncaught and aborted the entire drawing -- one product's ill-formed geometry could take down a whole elevation render with no partial output at all. Wraps the Load()/AddClose() pair in try/catch and leaves intersector_loaded[idx] false on failure, which is already this function's own established mechanism for "don't ray-cast against this item" (used elsewhere for null shapes and IfcOpeningElement voids). That item simply stops participating in occlusion restoration for hidden-edge purposes instead of crashing the render. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces find_cross_coplanar_matches()'s old per-product-PAIR double loop
with a per-shared-EDGE-LOCATION mechanism: for every edge, gather every
other product with a genuinely collinear, overlapping edge over some
sub-range, and let one new function, resolve_edge_location_subrange(),
decide once what the camera actually sees there -- instead of separately
asking "should THIS pair match" for every pair sharing that location and
hoping the answers agree.
Replaces both of this branch's earlier, narrower patches, neither of which
generalized correctly:
- The whole-object visibility pre-filter (a fragile heuristic needing three
rounds of real-file threshold recalibration).
- The pair-level back-facing guard (rejected a candidate pair outright if
either face pointed away from the camera -- caused a real elevation-view
regression, since two products touching face-to-face always have
opposite-facing normals, so any non-edge-on camera reads one side as
"facing away" even for a completely legitimate match).
The new per-location decision, for one sub-range of one edge:
1. Gather every contributor's own adjacent faces into one candidate pool.
2. Keep only STRICTLY front-facing candidates -- both clearly back-facing
and edge-on are excluded, scoped only to this cross-product search
(never to a product's own native Stage-0 classification). A genuine
cross-coplanar/mat-style-change seam is decided by two SEPARATE
products' own genuinely-camera-visible faces continuing in the SAME
direction (e.g. two wall segments' own front faces) -- never by
whichever internal, opposite-facing faces happen to physically touch
where the solids meet (a wall's hidden end-cap, an underside resting on
whatever's below). Since two exactly-opposite-facing faces can never
both be front-facing to the same camera at once, this filter removes
that irrelevant pairing from contention without a separate case split.
3. Build a plane through the edge's own 3D line and the camera's view
direction, splitting surviving candidates into two camera-relative
sides ("left"/"right" are just the two signs of one dot product, not
literal screen position).
4. Classify each candidate by which side it falls on using a point local
to this specific edge location (not a whole-face average, which for a
large face -- a wall's entire main face, evaluated at one small seam
near its own edge -- can sit on the wrong side of a plane that's only
meaningful locally). Achieved via BRepClass_FaceClassifier picking
whichever of the two in-plane, edge-perpendicular directions is
genuinely inside the face -- cheaper than a real triangulation, same
answer for planar building faces.
5. Per side, the winner is whichever candidate's normal has the smallest
angle to that side's own outward plane normal, restricted to
(left, right) pairs from DIFFERENT owning products -- otherwise this
can rediscover a product's own native corner (confirmed against two
identical boxes stacked directly on each other, where the lower box's
own top/side corner has a smaller angle than the genuine cross-product
seam and would otherwise silently win).
6. Compare the two winners for SAME-direction coplanarity (never opposite
-- see step 2) and material/style at the sub-range's own midpoint into
match, mismatch, or none.
7. On match/mismatch, convert the sub-range into every contributor's own
edge parametrization, not just the two winners' -- every product
sharing this edge location must agree, since only one line can appear
there.
Sub-edge-level throughout, same as the code it replaces: collinear edges
of different lengths only ever share their genuinely-overlapping portion,
and any resulting sliver shorter than the matching tolerance is dropped,
so floating-point edge-endpoint noise can't produce a degenerate
zero-length "dot" in the drawing.
Verified: full suite green (21/22; the one failure is the same
pre-existing, unrelated stale-coordinate case already called out in this
branch's prior commits). Confirmed against a real building file that the
elevation-view regression the pair-level back-facing guard caused is
fixed, and empirically confirmed (implementing without it, then rerunning
the existing occlusion tests unmodified) that this design needs no
pre-HLR occlusion-awareness of its own -- real HLR, running after
classification, resolves true third-party occlusion on its own, more
precisely than the old pre-HLR approximation ever did.
KNOWN INCOMPLETE: verified against a synthetic multi-view test project
(camera angles from plan/section through full 3D isometric) that this
still misses a small number of genuine matches in oblique isometric-style
views specifically -- a handful of products each losing exactly one
cross-coplanar/mat-style-change edge among several correctly-found ones
for the same product pair, not yet root-caused. Axis-aligned plan/section
views and one fully-embedded "plug filling a notch" test case (the
original motivating scenario for steps 2 and 4 above) are confirmed
exact matches against the known-good reference renders.
Generated with the assistance of an AI coding tool.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
point_on_foreign_face() (used to decide whether a hidden edge is a genuine HLR depth-tie worth restoring) already excluded edge-on matched faces but accepted back-facing ones as equally valid "coincident foreign face" candidates. A back-facing match is the end-cap/underside case: real geometry that genuinely touches the candidate edge, but can never be what the camera actually sees there, for the same reason it's now excluded from cross-coplanar matching itself (this branch's per-shared-edge-location redesign, previous commit). Before that redesign, this was invisible: the old, facing-agnostic cross-coplanar matcher classified end-cap pairs as cross-coplanar/ mat-style-change outright, and this function already skips edges in those classes, so they never reached this test. Once the matcher correctly stopped doing that, those edges fell back to native "outline" classification and reached restore_coincident_hidden_edges() for the first time -- exposing a real, previously-masked bug. Confirmed via direct debug instrumentation against a real building file: two products touching end-to-end along their full shared edge (opposite- facing normals) had one product's edge found "coincident" with the neighbour's own back-facing face. Since every sample point lands exactly on that shared boundary, the ray-cast occlusion test never gets a chance to detect that the neighbour's actual solid bulk, just off that exact line, does occlude the surrounding area -- the whole edge was wrongly restored as a visible outline. Fix: drop the abs() so both edge-on and back-facing matches are excluded, requiring the coincident face be genuinely front-facing. Verified: full suite unchanged (21/22, same pre-existing unrelated failure). Against a real multi-view synthetic test file, this fixed all seven independently hand-verified cases of a hidden object's edge wrongly surviving as visible, with several drawings' total discrepancy count against a pre-redesign reference dropping by half or more and zero regressions in previously-correct drawings. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The post-HLR screen-space dedup (compute_coincident_edge_best_priority()/ compute_coincident_edge_overlap_coverage() in SvgSerializer.cpp) has always made a plain `outline` edge win any coincident-edge collision, on the confirmed premise that outline is a product's own true silhouette and should beat an incorrect cross-product coincidence. That premise breaks down when the colliding `outline` edge is itself just an independently- computed HLR duplicate fragment of the same corner an already-verified cross-coplanar/mat-style-change match covers: the dedup was quietly deleting the correct classified edge in favour of the duplicate, in both same-product and cross-product forms of the collision, confirmed via direct debug instrumentation against the synthetic coplanar join.ifc fixture's NORTH SECTION TILT drawing (all 4 of its own cross-coplanar/ mat-style-change test pairs affected). Two separate, narrowly-scoped exceptions: - cross-coplanar always wins over outline in this specific collision, unconditionally, in both the exact-endpoint-key mechanism (compute_coincident_edge_best_priority(), via a new companion exception set since its aggregate min-priority model can't otherwise express "outline loses to X but still beats everything else") and the collinear-overlap mechanism (compute_coincident_edge_overlap_coverage()). A cross-coplanar edge is the result of an explicit, already-verified touching-boundary decision -- strictly more informed than a same-line outline classification that happens to coincide with it. - mat-style-change gets the same exact-key exception (equally safe there), but its overlap-coverage exception is narrower: gated on the SAME outline edge also having a confirmed, genuinely-collinear overlap with a cross-coplanar edge somewhere in its own bucket. Case B's own same- product nesting (a layer-boundary edge legitimately nested inside part of a much longer outline edge) relies on outline continuing to win, and Case B never produces a cross-coplanar collision at all -- so this gate reliably tells the two apart. An earlier, ungated version of this exception was confirmed to also fire for genuine Case B edges in an unrelated test pair, adding spurious duplicates there; this fixed it. Verified: full synthetic suite unchanged (21 passed, 2 skipped, same 1 pre-existing unrelated failure). Full 10-drawing sweep against known-good improved from 42 to 34 total per-product class-count diffs (2 to 3 fully clean drawings), with no drawing regressing except one already-parked, accepted-as-intractable GUID pair in ORTHOGRAPHIC-X (documented in the project's own known-issues backlog, not fixed here per explicit user direction not to risk this fix for it). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two different products can each independently draw their own copy of the same physical edge (e.g. a shared cross-coplanar boundary) -- both copies are individually correct, but drawing both is visibly wrong once stroke opacity drops below 100%. Quantified as common: ~7% of classified edges are an exact same-class duplicate, another ~7% partially overlap a same-class edge, across a synthetic fixture and 8 real drawings. New bpy-free module bonsai/bim/module/drawing/svg_dedup.py, merge_duplicate_edges(root): collects classified <path> elements by their class string across every product <g ifc:guid>, clusters same-class edges via a tolerant parametric collinear-overlap test (perpendicular-distance + parameter-range-overlap, banded to 5x the base tolerance to absorb floating-point noise between independently-computed "duplicate" edges), keeps the longest member of each cluster whole and trims every other member down to its own non-overlapping remainder (deleting it outright if nothing survives). Edits/removes the original <path> in place under its own real product's own <g> -- never a synthetic merged element, so ifc:guid/product class traceability is fully preserved, unlike merge_linework_and_add_metadata()'s own existing polygon-merge phase (a deliberately different trade-off for wall/slab cut-fills, not appropriate to replicate here). Runs as a fixed-point loop (capped at 5 passes) since trimming one cluster can occasionally reveal a new overlap between two other members' own leftover remainders. An initial version used shapely.LineString.intersection().length for the overlap test; this proved unreliable against real data -- two independently-computed copies of the same physical edge are collinear only up to floating-point noise, and GEOS treats two almost-but-not- exactly-parallel segments as crossing at a single point rather than overlapping. Against a real fixture with 20 known duplicate/overlap groups, that version resolved only 2. Replaced with plain parametric math (no shapely dependency at all), which resolved all 20, and all-but-one across 8 real drawings before the fixed-point loop resolved the last one too -- confirmed via a union-find-based measurement script, 0 residual duplicate/overlap groups across all 9 fixtures after one pass. Wired into operator.py's generate_linework() at both cut_mode branches (BISECT/OPENCASCADE), immediately after merge_linework_and_add_metadata() (needed for its "cut"/"projection" <g>-class labelling), gated by a new EPset_Drawing boolean MergeDuplicateEdges (camera property merge_duplicate_edges, default OFF, only takes effect when use_edge_classification is also on) -- existing drawings are unaffected unless a user opts in. Full prop.py/ui.py/tool/drawing.py read-side wiring added, mirroring the existing classification-toggle pattern. Tests: test/bim/module/drawing/test_svg_dedup.py, 6 cases, plain pytest (no Blender) -- loads svg_dedup.py directly by file path rather than via the bonsai.bim.module.drawing package, since importing anything under bonsai.bim first runs bonsai/bim/__init__.py, which imports bpy unconditionally. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Leftover debug-instrumentation dependency from tracing a bug fixed earlier on this branch (0952a226ae) -- zero std::cout/cerr usage anywhere in the file. Found during item 8's pass-2 code review (pre-merge checklist). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The comment claimed this function reuses find_cross_coplanar_matches()'s own normal-parallel + plane-distance test. No longer true since the cross_coplanar::resolve_edge_location_subrange() redesign folded that test into its own per-location candidate search rather than leaving it as a standalone reusable function -- this is, and always has been since that redesign, an independent implementation. Corrected the comment so a future reader doesn't assume a tolerance/heuristic change to cross_coplanar automatically applies here. Found during item 8's pass-2 code review (pre-merge checklist). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "heal a genuinely degenerate/self-intersecting shape before handing it to a boolean op" try/BRepCheck_Analyzer/ShapeFix_Shape pattern was duplicated near-identically at both of its call sites (the halfspace-cut loop's BRepAlgoAPI_Cut, and the section-cut path's BRepAlgoAPI_Section). Extracted into one shared repair_if_invalid() helper. Found during item 8's pass-3 code review (pre-merge checklist). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_restorable_at() was transforming the sample point unconditionally before checking whether point_on_foreign_face() even found a match, wasting the transform on the common non-restorable-candidate path (no coincident foreign face at all -- the majority case, since only a minority of hidden edges are genuine depth-ties). Reordered to only transform once a match is confirmed. Found during item 8's pass-3 code review (pre-merge checklist). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The halfspace-cut loop's silent `continue` on BRepAlgoAPI_Cut failure/null-shape had no diagnostic, unlike this same function's own outer catch block. Added a logger_.Warning() call on both drop paths so a missing part is at least visible. Deliberately did NOT add a fallback to the uncut original part -- a prior attempt at recovering more here (via heal_for_linework()'s fuller repair fallback for the same failure class) silently reconstructed an entire cut-away opening on a real test box, a confirmed regression already documented in this same code's own comments. Logging visibility only, not a behavior change. Found during item 8's pass-3 code review (pre-merge checklist). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
subtraction_settings_ == ALWAYS (what every real Bonsai drawing uses) leaves a genuine, intentional naked edge along the cut seam of any product it halfspace-cuts -- there is no capping face there by design. product_has_naked_edge couldn't tell that seam apart from a real architectural opening, so it came back true for essentially every section-cut product, wrongly re-enabling classify_edge_from_faces()'s back-facing "seen through an opening" flip purely from being cut, not from having an opening -- reproducing the exact flush-window-frame flip bug that flip was built to stop, specifically in section/ elevation views. Now excludes naked edges lying on the drawing's own cut/projection plane from the signal, since a genuine opening would only coincide with it by implausible coincidence. Known-issues backlog item 45. Scope deliberately limited to this proven cut-plane trigger, not the broader per-product-vs-per-fold granularity gap the same item also raises, per explicit user decision. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wire_neighbors_of_edge()'s winding self-correction summed cross(edge_in, edge_out).n directly, proportional to sin(turn_angle)*|edge_in|*|edge_out| -- length-weighted, not a true turning-number invariant. For a simple polygon the unweighted sum of signed turning angles (equivalently, the sign of the shoelace/ signed-area formula) is a reliable winding-sense invariant regardless of edge length or reflex-vertex count; the length-weighted sum has no such guarantee and can land on the wrong sign for a wire with several reflex corners of uneven edge length (crenellated parapets, star/ gear-shaped profiles). Confirmed via direct instrumentation that the two formulas disagree in sign on a real 10-vertex star column's own OCCT wire (old=-2.11, new=+1.47). Known-issues backlog item 46. Shipped without a dedicated end-to-end regression test: extensive attempts (a crenellated-parapet-wall scene, a star column swept across 48 camera angles) found no case where the sign disagreement changes final SVG output, despite the confirmed disagreement on the underlying computation -- per explicit user sign-off on this effort-vs-coverage tradeoff. See the known-issues backlog for the full investigation, so a future session doesn't repeat the same search without a new angle on why it didn't reproduce. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
classify_edge_from_faces()'s outer-vs-inner-wire candidate preference unconditionally outranked any inner-wire candidate regardless of |dot| magnitude. A face with a genuine boolean notch (not a hole) where the outer-wire neighbour happens to be near-coplanar (small, noisy |dot|) while the notch's own inner-wire vertex would give a large, decisive, correct-sign |dot| still lost to the noisier outer signal. Not a plain magnitude race between outer and inner candidates: for a genuine hole (the case 7c74a1a56f fixed), the cap face's own inner (hole-rim) wire gives an always-wrong-sign dot that can also have the larger magnitude, so a naive tie-break would reintroduce that bug whenever the wall's own correct outer candidate happens to be weak too. Only falls back to inner candidates when the outer signal is itself near-zero relative to the edge's own length -- narrower than "outer lost a magnitude race," closer to the actual notch case. Verified against the existing hole-rim regression test (still passes) plus the full geom/ suite (still green). Known-issues backlog item 50. Shipped without a dedicated end-to-end regression test (construction would need precisely-tuned notch geometry) per explicit user sign-off, same disposition as item 46. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compute_coincident_edge_overlap_coverage()'s real_scale_i/ real_scale_j rescale a screen-space overlap fraction onto each edge's true 3D length by dividing by its own projected (screen-space) length. That projected length was only floored at Precision::Confusion() (~1e-7), not the pass's own matching tolerance (~1e-4) -- a long 3D edge viewed at a grazing-but-not-quite -edge-on angle could produce a projected length in that gap, small enough to amplify ordinary screen-space noise into a wildly incorrect real-3D clip range fed to split_edge_by_coverage(). Below `tolerance` the two projected endpoints are already indistinguishable at this pass's own resolution, so there is no reliable screen-space line to rescale from regardless -- same conservative fallback (exact-key-only matching) the curved-edge case just above it already uses. Known-issues backlog item 49. Shipped without a dedicated end-to-end regression test (construction would need a precisely-tuned grazing camera angle) per explicit user sign-off, same disposition as item 46. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
merge_duplicate_edges() clustered and deleted/trimmed <path> elements across both "cut" and "projection" <g> groups indiscriminately, but operator.py's SHAPELY fill-boundary extraction (which runs immediately after) only scans "projection"-classed groups. If a genuine duplicate pair spanned one product's "cut" copy and another's "projection" copy of the same boundary edge, and the cut-side copy happened to be longer (kept as survivor per _resolve_cluster()'s length-based rule), the projection-side copy the SHAPELY boundary scan actually needs would get deleted. Scoped the initial class-keyed bucketing to (class, cut-or-projection) instead of class alone, so a cut-side and projection-side copy never cluster together at all -- same-group-kind duplicates across different products still dedup exactly as before. Known-issues backlog item 52. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_set_or_split_edge_path()'s multi-piece remainder branch created
sibling <path> elements with etree.Element("path") -- no SVG
namespace -- while every reader in the pipeline (including this
module's own next-pass query) expects the namespaced tag. Such a
sibling is invisible to findall() on the same in-memory tree,
defeating both the fixed-point loop's own convergence claim and the
SHAPELY boundary scan (known-issues item 52).
Currently dormant, not a live bug: a separate investigation proved a
2+-piece remainder is mathematically unreachable via
merge_duplicate_edges()'s only call path today, given
_resolve_cluster()'s length-descending processing order. Real
landmine for any future change to that order, or any new caller of
_set_or_split_edge_path() -- fixed regardless of current
reachability, and the new test calls the function directly (bypassing
the unreachable public entry point) to cover it.
Known-issues backlog item 53.
Generated with the assistance of an AI coding tool.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
merge_duplicate_edges(root) was called with no tolerance argument, so the dedup pass always used svg_dedup.py's hardcoded 1e-3 SVG-unit default regardless of the drawing's own cross_coplanar_tolerance (EPset_Drawing.CrossCoplanarTolerance). A user who loosens the C++-side tolerance to handle noisier geometry got no corresponding effect here -- edges that are "duplicates" by the classification pass's own looser tolerance could still fail _collinear_overlap()'s fixed band and ship undeduped. cross_coplanar_tolerance is a model-space (metres) tolerance while merge_duplicate_edges()'s own tolerance is in SVG paper-space units, so this isn't a direct pass-through -- scaled by * self.scale * 1000, this same class's own established model-to-drawing-unit conversion (see drawing_to_model_co()'s inverse of it just below). Known-issues backlog item 54. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fixed-point loop re-walked the whole SVG tree and re-clustered every (class, cut-or-projection) bucket from scratch on every one of up to 5 passes, tracked by a single global changed bool with no per-bucket dirty-tracking -- same shape as the earlier C++-side performance findings (items 11/34/41). A bucket's own trimming can only ever reveal a new overlap within that SAME bucket (buckets are entirely disjoint by class/group), never in a different one, so an unchanged bucket is guaranteed identical on every later pass regardless. _run_one_pass() now returns the set of bucket keys that actually changed, and the caller restricts the next pass to just those -- the tree still has to be walked once per pass (lxml has no cheaper way to find this pipeline's own paths), but unchanged buckets skip the expensive segment-parsing and O(n^2) clustering work. Known-issues backlog item 55 (performance bullet). No behavior change -- verified against the full existing test_svg_dedup.py suite (9 passed). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
occlusion_clear_subintervals() (SvgSerializer.h) had zero callers anywhere in the codebase -- its call sites were removed by the ff2af2d72b redesign, but the ~85-line function and its own comment (which still claimed to fix a "confirmed real-file regression") were left behind, misleading to a future reader since none of that logic actually runs. Also swept ~8 comments elsewhere in SvgSerializer.h and in test_svg_edge_classification.py that named other now-deleted sibling functions (accumulate_edge_coverage(), mat_style_change:: accumulate_mismatch_coverage(), replace_matched_edges()) in present tense as if they still exist and are called today. Qualified each as historical/superseded rather than removing the surrounding explanation, since the design rationale they document is still accurate for the current code, just fed by a different source now. Left test-file mentions of touches_foreign_face() alone -- those describe what a specific historical commit did to already-captured test values, not a claim about current code. Known-issues backlog item 31. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_single_material_association() and resolve_layer_projection() both walked the same product's HasAssociations inverse to find the one IfcRelAssociatesMaterial -- the loop body was line-for-line identical, differing only in the final type check each caller does afterward. Extracted the shared walk into single_material_association(), leaving each caller's own downstream branching (what kind of material construct it resolved to) untouched. Known-issues backlog item 32. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_svg_edge_classification.py (79bd464bb4) was missing the AGENTS.md-required AI-disclosure header carried by every other file in the same batch (svg_dedup.py/test_svg_dedup.py both have it), and wasn't black-formatted. Known-issues backlog item 55 (mechanical bullets). Purely mechanical -- no test assertions or fixture values changed, full geom/ suite still green (27 passed, 2 skipped). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resolve_edge_location_subrange()'s degenerate-side test (does a candidate face's own interior sit exactly on the dividing plane between two contributing products?) used Precision::Confusion() (OCCT's fixed ~1e-7) while every other check in the same function uses the passed-in tol (svg-cross-coplanar-tolerance, default 1e-4 = 0.1mm) -- an undocumented ~1000x inconsistency with no known justification beyond "this is what the generated code produced and it works". A/B-verified against two real project files (a 6-drawing representative subset of a full architectural project, and the dedicated synthetic fixture built for this exact feature): zero differences on the architectural project; one real, localized change on the synthetic fixture, filling a genuine coverage gap between two coplanar slabs' own edges in ORTHOGRAPHIC-X (previously neither product's edge covered a ~4mm span at their shared seam). No new gaps or overlaps introduced elsewhere. Known-issues backlog item 35. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
is_genuinely_occluded() ray-cast against every loaded intersector in the drawing, unfiltered. On a real project fixture's heaviest drawing this was ~84% of total render time (~59M ray-casts, one drawing). Adds nearby_along_ray(), a bbox-vs-bbox shortlist whose box extends along view_direction (matching the ray's own test range) rather than reusing the local, edge-endpoint-scoped box used elsewhere for coincidence tests -- an earlier attempt at this reused that local box and was reverted after under-restoring real depth-ties. Verified against both real fixtures: byte-identical SVG output pre/post, existing suite green, 3.8-5.8x speedup on axis-aligned drawings (isometric views see less benefit, expected given the shortlist's own geometry). Also precomputes point_on_foreign_face()'s per-candidate-edge foreign face list once instead of recomputing face normals on every sample point -- correct and avoids a known-anti-pattern shape, though measured to have no impact on real-fixture timing once the ray-cast above is fixed. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After eae24a0363 sped up is_genuinely_occluded()'s ray-cast, this function's own per-candidate-edge work became the dominant remaining cost (30-88% of total render time depending on view angle). Farms out the outer per-(product, class)-bucket loop across worker threads via a shared atomic work index (not a static chunk split -- bucket cost varies too much with candidate-edge count for that to balance well), falling back to the existing sequential path below a small bucket count where thread spawn/join isn't clearly worth it. The one real hazard: IntCurvesFace_ShapeIntersector::Perform() is not safe to call concurrently on a shared instance (confirmed against the vendored OCCT header -- it writes into private, non-atomic result state that NbPnt()/Pnt() then read). Fixed with one mutex per intersector, guarding Perform() together with the results it just wrote -- a single global mutex was rejected (would serialize every occlusion check); per-thread-duplicated intersectors were tried and also rejected after a real A/B showed no measurable benefit over the mutex (the earlier apparent "contention" turns out to be ordinary compute/memory-bandwidth-bound parallel scaling, not lock waiting -- duplicating the data doesn't fix that). Verified: deterministic proof of the race fix (forced the threaded path on even for small test fixtures; 15+ runs each saw hundreds of genuine contended lock acquisitions with zero corruption or test failures). Byte-identical SVG output on both real fixtures (all 10 drawings of the small synthetic one, the curated 6-drawing subset of the large architectural one). Real, production-accurate speedup (matching Bonsai's own threaded geometry-iteration call) on the heaviest documented drawing: ~26% total render time (29.4s -> 21.8s). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cross-coplanar/mat-style-change classification toggles this branch added push their own lines past black's 120-column limit; wrap them the same way black already wraps every other call in this file. Known-issues backlog item 9 (pre-PR linter pass). Purely mechanical, no behavior change. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cross-coplanar and mat-style-change edges need default.css rules. There is one active set with my best guess at usefulness. They need an architect to define them better. There is another set, commented out, that are used to aid visual debugging. Bright ugly colours that make it very easy to spot when something is incorrect.
|
Test file and renders: 7894_Join coplanar SVG projection cells using normal vector evaluation.zip Based on the originals theoryshaw provides in his comment. |
Sorry, something went wrong.
Detects pairs of planar faces belonging to different IFC products that genuinely cross each other in 3D (a diagonal brace piercing a wall, for example) and classifies the crossing line as a new "face-intersection" SVG edge class, following cross-coplanar's own classification -> HLR -> render pipeline (issue IfcOpenShell#3742 follow-on). A sibling to cross-coplanar, not an extension of it: cross-coplanar only matches coincident edges between parallel faces, so this needed its own non-parallel face-pair matching (BRepAlgoAPI_Section, trimmed to both faces' real boundaries, with SetNonDestructive(true) since candidate faces are legitimately reused across multiple pairs) and its own fix for a doubled-line risk: two products sharing a genuine crossing would otherwise each draw their own copy of it, since the existing cross-product dedup logic deliberately doesn't collapse same-class duplicates. Resolved by attributing each crossing to a single product via a deterministic GlobalId tie-break. Classification defaults on, rendering defaults off (mirroring cross-coplanar's own convention), since this pass's per-face-pair cost is higher than cross-coplanar's own edge-collinearity check and shouldn't become a default performance tax without real-fixture timing data to justify it. Threading is deliberately deferred: OCCT 7.8.1 (this repo's pinned version) has no documented safety guarantee for concurrent BRepAlgoAPI_Section calls, only a forum-documented mitigation (SetNonDestructive(true), applied here regardless). Verified: new and all 24 pre-existing edge-classification tests pass; default (render-off) output is edge-set-identical to the pre-change baseline on every real-fixture drawing; forcing rendering on finds real genuine crossings in both real fixtures, each correctly attributed to exactly one product with no doubled lines. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-on: face-intersection edge classificationAdded a third sibling edge class alongside cross-coplanar/mat-style-change: face-intersection, for pairs of planar faces belonging to different products that genuinely cross each other in 3D (e.g. a diagonal brace piercing a wall) — as opposed to being coincident/parallel, which stays cross-coplanar's job. Computed via BRepAlgoAPI_Section, trimmed to both faces' real boundaries, with a deterministic GlobalId-based tie-break so a genuine crossing shared between two products is only ever drawn once, never doubled. Same conventions as the existing classes: classification on by default, rendering off by default (svg-use-face-intersection-classification / svg-render-face-intersection-edges / svg-face-intersection-tolerance), fully additive — output is unchanged with rendering off. Verified against the same real fixture files used elsewhere in this PR, plus new unit tests. |
Sorry, something went wrong.
Collapses face-intersection's two boolean settings (svg-use-face-intersection-classification / svg-render-face-intersection-edges) into one. Unlike cross-coplanar and mat-style-change, this pass has no outline-trimming interaction for a classification-on/render-off state to preserve -- it never wins a coincident-duplicate-edge priority contest, since it's deliberately left unranked/lowest-priority in coincident_edge_class_priority(). A classification-only state was therefore paying this pass's real per-face-pair BRepAlgoAPI_Section cost for zero visible effect on every drawing by default. Mirrors mat-style-change's own existing precedent in this same file, which already uses a single boolean for exactly this reason. Default changes from (classify=true, render=false) to a single false: the feature is now fully inert until explicitly enabled, rather than silently computing results nobody sees. Also reworks the Camera panel's edge-classification UI: fixes a leftover reference to `join_coplanar_surfaces`, a property from an earlier coplanar-feature iteration that no longer exists (was spamming the console with errors on every panel redraw), and moves every edge- classification property out of the main camera panel into its own collapsible `BIM_PT_edge_classification` sub-panel, grouped into "Enable additional features" / "Render out" / "Post Process" sections for readability. Verified: full edge-classification test suite (26 passed, 2 skipped) against a rebuilt wrapper; black/ruff clean. Mixed authorship: the setting-simplification (C++/Python wiring, ConversionSettings.h, tests) was generated with the assistance of an AI coding tool. The UI panel rework and bug fix were authored directly by the human contributor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Add cross-coplanar & material/style-change SVG edge classification (#3742)
Summary
Extends the existing single-object SVG edge classification system (#3668,
5 classes: outline/crease/valley/ridge/boundary) to also handle edges
shared between different touching elements. Previously, every product's
edges were classified independently, which produces two visible problems
in generated drawings:
walls butting together) each draw their own copy of the shared boundary
edge -- correct individually, but a visible double-line once any
transparency is applied.
material or style, or where a single element's own section-cut face
spans more than one material layer, there was previously no way to
mark that boundary as anything other than an ordinary edge -- so a
material change at a shared or internal surface was invisible in the
drawing.
This PR adds two new edge classes computed directly on the real 3D BRep
geometry (before HLR projection, not as an SVG post-process) --
cross-coplanar and mat-style-change -- plus an opt-in duplicate-edge
merge pass for drawings where two classes end up needing to be drawn at
the same location, a run of correctness/robustness fixes found while
building and reviewing this, and two performance fixes for larger
drawings.
Key changes
New edge classification
a coincident, same-material/style face of a different product -- the
genuinely-duplicate-boundary case above. Matching works at sub-edge
granularity: a partial overlap, or several products contributing to
different sections of the same edge, is split and classified per
sub-range rather than requiring the entire edge to match. Off by
default for rendering (svg-render-cross-coplanar-edges); the
underlying match pass can also gate other classification behaviour
independently (svg-use-cross-coplanar-classification).
situations -- two different products with a genuinely coincident face
but differing material/style (cross-product), and a single product's
own face spanning more than one layer of its material stack, e.g. a
section cut through a wall (intra-product). Off by default
(svg-use-mat-style-change-classification), since it visibly
reclassifies existing linework.
already used for the existing classification system, and are fully
additive: with both toggles off, output is byte-identical to current
behaviour.
Duplicate-edge merge post-process (opt-in)
A new, bpy-free post-process (svg_dedup.py) that clusters same-class
<path> elements by a tolerant parametric collinear-overlap test and
merges/trims duplicates down to one edge per cluster, operating on the
emitted SVG paths themselves rather than the 3D classification logic.
Off by default; only takes effect alongside edge classification.
Correctness & robustness fixes
A number of pre-existing and newly-introduced classification bugs were
found and fixed along the way -- sign-test/convexity errors, occlusion
and hidden-edge-restoration edge cases, degenerate-geometry guards
against real-world OCCT failures, and several narrower regressions
introduced and caught within this same branch's own review process.
These are individually small; the commit log is the source of truth for
what each one covers rather than repeating them here.
Performance
Two independent hot-path fixes for larger drawings, both measured against
a real architectural project file rather than synthetic data:
shortlist instead of testing against every loaded object in the
drawing -- 3.8-5.8x speedup on axis-aligned views (isometric/oblique
views benefit less, a known geometric limitation of the shortlist
shape).
work, which became the next dominant cost once the above landed.
Both are verified byte-identical in output to the pre-optimization
baseline -- pure performance work, no behaviour change.
Minor
hidden when Freestyle linework mode is active, since the two systems
aren't meant to be used together.
Testing
(test_svg_edge_classification.py) and the dedup post-process
(test_svg_dedup.py), both plain pytest, no Blender required for the
classification suite.
fixtures) at multiple points during development, including a full
before/after A/B on real drawings for the performance work.
Known limitations / out of scope
understands IfcMaterialLayerSetUsage (layered elements like walls and
slabs) -- IfcMaterialProfileSetUsage (structural members) and
IfcMaterialConstituentSet aren't covered yet.
faces and straight edges -- curved geometry is gracefully skipped, not
a crash.
AI-assisted development
This contribution was developed with the assistance of an AI coding tool
(Claude); every commit's own message discloses this in its body per
AGENTS.md.
A word from the human
I developed the original edge classification work which was entirely done
with AI. Likewise, this is entirely produced by AI with my guidance and
understanding (and yes, sometimes my frustration when I couldn't get
Claude to understand my points.)
Lots of visual inspections by me, and I'm pretty happy with the robustness.
Like anything this sensitive though, there may be things I didn't catch. It
is entirely opt-in though, so it will not break your current options.
I'd really appreciate some feedback on whether this works well for people
and, if not, small reproduction files that I can troubleshoot.
Note that you will need to update your projects css files to take full
advantage. I have included two sets of path.* css rules. One set are for
debug, one set are a first guess at what categories of lines should look
like. A professional set of css rules would be appreciated.
I've included the synthetic test files from @theoryshaw (hope he doesn't
mind) which includes a full set of the drawings so you can see better
than the above screenshot.
One last thing. I realise this is made on top of v0.8.0. I just want to get
it pushed. I will pull v0.9.0 and see if I can merge this PR into it. It
should be possible as the original edge classification was merged, and this
simply extends it.