From ac3ceeb32c5db5d72a7ba2694d9ee192a459e819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:16:18 +0200 Subject: [PATCH 1/5] fix(geometry): material-layer walls emit a watertight skin, not a doubled "ghost face" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A material-layer wall was sliced into per-layer slabs, each CAPPED at its interface planes to be an independently closed solid. Every SHARED interface was therefore capped twice — a coincident, oppositely-wound, full-cross-section sheet. The wall still rendered solid (the interior caps are backface-culled) but the emitted mesh was non-watertight (degree-4 interface edges) and ~3x the triangles: the "ghost face" reported on opening-cut layered walls. AC-20 #37781: 272 tris / 73 degree-4 edges -> 160 tris / 0 degree-4, watertight. Slice without capping, carving each band off a running REMAINDER so both sides of every interface come from the same clip of the same mesh (matching tessellation, so the bands weld with no T-junctions). The union of the open bands is exactly the wall's watertight outer skin, partitioned per material. The 2D section consumed the capped slabs to reconstruct per-layer fills, so the @ifc-lite/drawing-2d PolygonBuilder loop builder is now bidirectional: each open band assembles into a ring whose implicit head->tail chord re-creates the interface line the cap used to draw. Per-layer section fills are unchanged (verified by an open-band reconstruction test). --- .changeset/layer-wall-watertight-section.md | 5 ++ .../drawing-2d/src/polygon-builder.test.ts | 47 +++++++++++++ packages/drawing-2d/src/polygon-builder.ts | 68 +++++++++++------- rust/geometry/src/router/layers.rs | 70 ++++++++++++------- .../tests/material_layers_local_frame_test.rs | 68 +++++++++++------- 5 files changed, 182 insertions(+), 76 deletions(-) create mode 100644 .changeset/layer-wall-watertight-section.md diff --git a/.changeset/layer-wall-watertight-section.md b/.changeset/layer-wall-watertight-section.md new file mode 100644 index 000000000..a20d503f7 --- /dev/null +++ b/.changeset/layer-wall-watertight-section.md @@ -0,0 +1,5 @@ +--- +"@ifc-lite/drawing-2d": patch +--- + +Reconstruct per-layer section fills from open (cap-free) material-layer bands. The geometry slicer no longer caps the layer interface planes — capping doubled each shared interface into a coincident, non-watertight "ghost face" sheet and ~tripled the triangle count on layered walls. With the interfaces left open, the 2D section's polygon loop builder is now bidirectional so each open band closes at the interface chord, keeping per-layer fills identical. diff --git a/packages/drawing-2d/src/polygon-builder.test.ts b/packages/drawing-2d/src/polygon-builder.test.ts index 36ea1f6d2..2b91c0d97 100644 --- a/packages/drawing-2d/src/polygon-builder.test.ts +++ b/packages/drawing-2d/src/polygon-builder.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { PolygonBuilder } from './polygon-builder.js'; +import { polygonSignedArea } from './math.js'; import type { CutSegment } from './types.js'; /** Build the 4 cut segments of an axis-aligned rectangle [x0,x1]×[y0,y1]. */ @@ -91,3 +92,49 @@ describe('PolygonBuilder — material-layer colour split', () => { for (const p of polygons) expect(p.color).toBeDefined(); }); }); + +describe('PolygonBuilder — open-band reconstruction (cap-free layer slabs)', () => { + /** The 3 cut segments of a layer band whose interface side (x = `xCut`) is + * OPEN — the section shape of a material-layer slab now that the slicer no + * longer caps the interface plane. `outerX` is the band's wall-face side. */ + function openBand( + outerX: number, + xCut: number, + entityId: number, + color: [number, number, number, number], + ): CutSegment[] { + const mk = (ax: number, ay: number, bx: number, by: number): CutSegment => ({ + p0: { x: ax, y: ay, z: 0 }, p1: { x: bx, y: by, z: 0 }, + p0_2d: { x: ax, y: ay }, p1_2d: { x: bx, y: by }, + entityId, ifcType: 'IfcWall', modelIndex: 0, color, + }); + return [ + mk(outerX, 0, outerX, 1), // wall-face edge + mk(outerX, 0, xCut, 0), // bottom strip (open end at xCut) + mk(outerX, 1, xCut, 1), // top strip (open end at xCut) + ]; + } + + it('closes each open band at the interface chord → one filled polygon per layer', () => { + // 2-layer wall sectioned: RED band [0,1] open at x=1, BLUE band [2,1] open at + // x=1 — the shared interface. A forward-only loop builder strands these and + // emits nothing; the bidirectional builder assembles each U and the implicit + // head→tail chord (x=1) re-creates the interface the removed cap used to draw. + const segments = [ + ...openBand(0, 1, 100, RED), + ...openBand(2, 1, 100, BLUE), + ]; + + const polygons = new PolygonBuilder().buildPolygons(segments); + + expect(polygons).toHaveLength(2); + const colors = polygons.map((p) => p.color); + expect(colors).toContainEqual(RED); + expect(colors).toContainEqual(BLUE); + // Each layer is a unit square (area 1): the open contours were closed, not dropped. + for (const p of polygons) { + const area = Math.abs(polygonSignedArea(p.polygon.outer)); + expect(area).toBeCloseTo(1.0, 5); + } + }); +}); diff --git a/packages/drawing-2d/src/polygon-builder.ts b/packages/drawing-2d/src/polygon-builder.ts index 899dac91b..83995d063 100644 --- a/packages/drawing-2d/src/polygon-builder.ts +++ b/packages/drawing-2d/src/polygon-builder.ts @@ -176,16 +176,24 @@ export class PolygonBuilder { } /** - * Build a single closed loop starting from a segment + * Build a single loop starting from a segment. + * + * BIDIRECTIONAL: the chain is extended from BOTH ends (append at the tail, + * prepend at the head) until neither end finds a connecting segment. A purely + * forward walk strands segments when it starts mid-chain — fatal for an OPEN + * contour, which is exactly what a material-layer band is now that the slicer + * no longer caps the interface planes (the cap was a doubled, non-watertight + * 3D sheet). A cap-free band's section is a U (outer face + the two end + * strips); extending from both ends assembles all of it, and the implicit + * head→tail closing chord of the returned ring IS the interface line the cap + * used to draw — so per-layer section fills are unchanged. Genuinely closed + * cross-sections still close here (tail meets head) and return identically. */ private buildSingleLoop(segments: Segment2D[], startIdx: number): Point2D[] | null { - const points: Point2D[] = []; const startSeg = segments[startIdx]; startSeg.used = true; - points.push(startSeg.start); - let currentEnd = startSeg.end; - const loopStart = startSeg.start; + const points: Point2D[] = [startSeg.start, startSeg.end]; const maxIterations = segments.length; let iterations = 0; @@ -193,34 +201,44 @@ export class PolygonBuilder { while (iterations < maxIterations) { iterations++; - // Check if we've closed the loop - if (point2DDistance(currentEnd, loopStart) < this.tolerance) { + const head = points[0]; + const tail = points[points.length - 1]; + + // Closed ring: the tail has come back to the head. Drop the duplicate + // endpoint and return the closed loop (the pre-existing behaviour). + if (points.length >= 3 && point2DDistance(tail, head) < this.tolerance) { + points.pop(); return points; } - // Find next connecting segment - const nextIdx = this.findConnectingSegment(segments, currentEnd); - if (nextIdx === -1) { - // Can't close loop - mark remaining as unused and return partial - // This can happen with open geometry or numerical issues - break; + // Prefer extending the tail forward. + const tailIdx = this.findConnectingSegment(segments, tail); + if (tailIdx !== -1) { + const seg = segments[tailIdx]; + seg.used = true; + const next = + point2DDistance(seg.start, tail) < this.tolerance ? seg.end : seg.start; + points.push(next); + continue; } - const nextSeg = segments[nextIdx]; - nextSeg.used = true; - - // Determine which end connects - if (point2DDistance(nextSeg.start, currentEnd) < this.tolerance) { - points.push(nextSeg.start); - currentEnd = nextSeg.end; - } else { - points.push(nextSeg.end); - currentEnd = nextSeg.start; + // Otherwise extend the head backward. + const headIdx = this.findConnectingSegment(segments, head); + if (headIdx !== -1) { + const seg = segments[headIdx]; + seg.used = true; + const prev = + point2DDistance(seg.start, head) < this.tolerance ? seg.end : seg.start; + points.unshift(prev); + continue; } + + // Neither end extends: an OPEN contour (a cap-free layer band, or genuinely + // open geometry). Return it; the signed-area / fill close it implicitly with + // the head→tail chord. + break; } - // Loop didn't close - return points anyway for potential use - // Some entities may have open cross-sections return points.length >= 3 ? points : null; } diff --git a/rust/geometry/src/router/layers.rs b/rust/geometry/src/router/layers.rs index 21ac8e86d..e8d14a8c5 100644 --- a/rust/geometry/src/router/layers.rs +++ b/rust/geometry/src/router/layers.rs @@ -21,7 +21,6 @@ use super::GeometryRouter; use crate::csg::{ClippingProcessor, Plane}; -use crate::processors::cap_half_space_clip; use crate::material_layer_index::{LayerAxis, LayerBuildup, LayerInfo}; use crate::mesh::{SubMesh, SubMeshCollection}; use crate::{Mesh, Point3, Result, Vector3}; @@ -523,39 +522,60 @@ fn slice_mesh_into_layers( let clipper = ClippingProcessor::new(); let mut out = SubMeshCollection::new(); + // Carve each layer's band off a running REMAINDER at the interface planes, + // and DO NOT cap the cut. Two design choices, one fix: + // + // - No cap. Capping closed every slab, so each SHARED interface became a + // doubled, coincident, oppositely-wound full-cross-section sheet: the wall + // rendered solid (the interior caps are backface-culled) but the emitted + // mesh was non-watertight (degree-4 interface edges) and ~3x the triangles + // — the "ghost face" on opening-cut layered walls. Uncapped, each band is + // the wall's outer skin within its layer range; the union of the bands is + // exactly the wall's watertight outer shell, partitioned per material. The + // interface is no longer a 3D sheet; the 2D section re-closes each band's + // open contour at the interface chord (its loop builder is bidirectional, + // see `drawing-2d` `PolygonBuilder`), so per-layer section fills are intact. + // + // - Progressive carve, not a fresh clone per band. Both sides of every + // interface are produced by the SAME clip of the SAME remainder, so their + // cut tessellations are identical and the bands weld edge-for-edge (no + // T-junctions, no hairline cracks). Clipping independent clones instead let + // a twice-clipped middle band diverge from its neighbour at the second + // interface, leaving open T-junction edges. + // + // `clip_mesh` keeps the half-space the plane normal points INTO and builds a + // fresh `Mesh` (origin [0,0,0]); the input mesh + planes are in the element's + // local frame (#1114), so the origin is restored on each band below. + let mut remainder = mesh.clone(); + for (i, layer) in visual_layers.iter().enumerate() { - let after_prev: Option<&Plane> = if i == 0 { None } else { planes.get(i - 1) }; let before_next: Option<&Plane> = if i + 1 == visual_layers.len() { None } else { planes.get(i) }; - let mut slab = mesh.clone(); - - // Each interface clip is CAPPED so the slab is a closed solid — a real - // material layer with faces at both interfaces — not just the wall's - // outer shell sliced into bands. Without the cap the layers read as - // hollow in 3D (colour on the exterior only) and a section finds no - // filled per-layer regions to draw. - if let Some(plane) = after_prev { - if let Ok(mut clipped) = clipper.clip_mesh(&slab, plane) { - cap_half_space_clip(&mut clipped, plane.point, plane.normal); - slab = clipped; - } - } - if let Some(plane) = before_next { - let flipped = Plane::new(plane.point, -plane.normal); - if let Ok(mut clipped) = clipper.clip_mesh(&slab, &flipped) { - cap_half_space_clip(&mut clipped, flipped.point, flipped.normal); - slab = clipped; + let mut slab = match before_next { + Some(plane) => { + let flipped = Plane::new(plane.point, -plane.normal); + // band = remainder below the interface; remainder = above it. + match ( + clipper.clip_mesh(&remainder, &flipped), + clipper.clip_mesh(&remainder, plane), + ) { + (Ok(band), Ok(rest)) => { + remainder = rest; + band + } + // Degenerate interface clip: emit the whole remainder for this + // layer rather than dropping geometry, and stop carving. + _ => std::mem::replace(&mut remainder, Mesh::new()), + } } - } + // Last layer: everything left in the remainder. + None => std::mem::replace(&mut remainder, Mesh::new()), + }; - // `clip_mesh` builds a fresh `Mesh` (origin [0,0,0]), dropping the local - // frame: the input mesh and the cut planes are both relative to - // `mesh.origin` (#1114), so the clipped slab is too — carry the origin - // forward or every sliced wall renders at the world origin (misplaced). slab.origin = mesh.origin; if !slab.is_empty() { diff --git a/rust/geometry/tests/material_layers_local_frame_test.rs b/rust/geometry/tests/material_layers_local_frame_test.rs index 8e0364766..0d2fe9ecc 100644 --- a/rust/geometry/tests/material_layers_local_frame_test.rs +++ b/rust/geometry/tests/material_layers_local_frame_test.rs @@ -110,36 +110,52 @@ fn slices_correctly_under_per_element_local_frame() { ); } - // Every slab must be a CLOSED solid (the cut faces capped) — otherwise the - // layers read as hollow shell bands in 3D and a section finds no filled - // region. Watertight ⇒ every edge shared by exactly two triangles; checked - // by welded position (the mesh is flat-shaded, so vertex indices aren't - // shared across faces). - for sub in &collection.sub_meshes { - assert_eq!(boundary_edge_count(&sub.mesh), 0, "each sliced layer must be a closed, capped solid"); - } + // The slabs are NOT capped at the shared interfaces. Capping closed each slab + // but doubled every interface into a coincident, oppositely-wound full-section + // sheet — the "ghost face": non-watertight (degree-4 edges) and ~3x the + // triangles. Instead the slabs are open bands whose UNION is the wall's + // watertight outer skin: every edge shared by exactly two triangles, none by + // four. (The 2D section re-closes each band's open contour at the interface + // chord; see the `drawing-2d` PolygonBuilder bidirectional loop builder.) + let (open, doubled) = union_edge_stats(&collection.sub_meshes); + assert_eq!( + open, 0, + "the union of the layer bands must be watertight (no open edges), got {open}" + ); + assert_eq!( + doubled, 0, + "no interface may be a doubled coincident sheet (no degree-4 edges), got {doubled}" + ); } -/// Count edges used by exactly one triangle (open boundary), welding vertices by -/// rounded world position so flat-shaded duplicates don't read as gaps. -fn boundary_edge_count(mesh: &ifc_lite_geometry::Mesh) -> usize { +/// Weld every sub-mesh of a sliced element by rounded WORLD position (origin + +/// position; flat-shaded, so positions are not index-shared) and return +/// `(open_edges, degree>=4_edges)` for the UNION. `open == 0` ⇒ watertight; +/// `degree>=4 == 0` ⇒ no doubled coincident interface sheet (the ghost face). +fn union_edge_stats(subs: &[ifc_lite_geometry::mesh::SubMesh]) -> (usize, usize) { use std::collections::HashMap; - let key = |i: usize| -> (i64, i64, i64) { - let q = |v: f32| (v as f64 * 1.0e4).round() as i64; - ( - q(mesh.positions[i * 3]), - q(mesh.positions[i * 3 + 1]), - q(mesh.positions[i * 3 + 2]), - ) - }; + let q = |v: f32, o: f64| ((v as f64 + o) * 1.0e4).round() as i64; let mut edges: HashMap<[(i64, i64, i64); 2], u32> = HashMap::new(); - for tri in mesh.indices.chunks_exact(3) { - let v = [tri[0] as usize, tri[1] as usize, tri[2] as usize]; - for &(a, b) in &[(v[0], v[1]), (v[1], v[2]), (v[2], v[0])] { - let (ka, kb) = (key(a), key(b)); - let e = if ka <= kb { [ka, kb] } else { [kb, ka] }; - *edges.entry(e).or_insert(0) += 1; + for sub in subs { + let m = &sub.mesh; + let o = m.origin; + let key = |i: usize| -> (i64, i64, i64) { + ( + q(m.positions[i * 3], o[0]), + q(m.positions[i * 3 + 1], o[1]), + q(m.positions[i * 3 + 2], o[2]), + ) + }; + for tri in m.indices.chunks_exact(3) { + let v = [tri[0] as usize, tri[1] as usize, tri[2] as usize]; + for &(a, b) in &[(v[0], v[1]), (v[1], v[2]), (v[2], v[0])] { + let (ka, kb) = (key(a), key(b)); + let e = if ka <= kb { [ka, kb] } else { [kb, ka] }; + *edges.entry(e).or_insert(0) += 1; + } } } - edges.values().filter(|&&c| c == 1).count() + let open = edges.values().filter(|&&c| c == 1).count(); + let doubled = edges.values().filter(|&&c| c >= 4).count(); + (open, doubled) } From 317680d29722c21a89f2c5819ba5c51cb8074789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:31:34 +0200 Subject: [PATCH 2/5] fix(drawing-2d): fill interior layers of 3+ layer walls in section (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: the watertight-bands change dropped the section fill of an INTERIOR layer of a 3+ layer wall. Such a band has no wall face — its plan section is two disconnected end strips that no single chain can close — so the bidirectional loop builder, which closes a 2-layer U-band fine, emitted nothing for the core. The PolygonBuilder now collects band fragments that are too short to close on their own and stitches them end-to-end at the interface chords, only closing a chain on itself once no other fragment remains (merging across-first matters: an interior band is thin, so its interface chord is longer than the band thickness, and a naive nearest-endpoint close would collapse the band). Fragments that already close into a non-degenerate loop on their own (a 2-layer U-band, the finish-on-both-faces case) stay separate, so existing per-loop behaviour is unchanged. Scoped to multi-material (per-layer) groups only. Adds a 3-layer regression test (the core layer fills with the correct area); all 46 drawing-2d tests pass. --- .changeset/layer-wall-watertight-section.md | 2 +- .../drawing-2d/src/polygon-builder.test.ts | 34 ++++++ packages/drawing-2d/src/polygon-builder.ts | 106 +++++++++++++++--- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/.changeset/layer-wall-watertight-section.md b/.changeset/layer-wall-watertight-section.md index a20d503f7..7cb505cdf 100644 --- a/.changeset/layer-wall-watertight-section.md +++ b/.changeset/layer-wall-watertight-section.md @@ -2,4 +2,4 @@ "@ifc-lite/drawing-2d": patch --- -Reconstruct per-layer section fills from open (cap-free) material-layer bands. The geometry slicer no longer caps the layer interface planes — capping doubled each shared interface into a coincident, non-watertight "ghost face" sheet and ~tripled the triangle count on layered walls. With the interfaces left open, the 2D section's polygon loop builder is now bidirectional so each open band closes at the interface chord, keeping per-layer fills identical. +Reconstruct per-layer section fills from open (cap-free) material-layer bands. The geometry slicer no longer caps the layer interface planes — capping doubled each shared interface into a coincident, non-watertight "ghost face" sheet and ~tripled the triangle count on layered walls. With the interfaces left open, the 2D section's polygon builder is now bidirectional (each open band closes at the interface chord) and, for 3+ layer walls, stitches the disconnected end strips of an interior layer (which has no wall face) back into a closed fill at the interface chords — so every layer keeps its section fill. diff --git a/packages/drawing-2d/src/polygon-builder.test.ts b/packages/drawing-2d/src/polygon-builder.test.ts index 2b91c0d97..767d93355 100644 --- a/packages/drawing-2d/src/polygon-builder.test.ts +++ b/packages/drawing-2d/src/polygon-builder.test.ts @@ -137,4 +137,38 @@ describe('PolygonBuilder — open-band reconstruction (cap-free layer slabs)', ( expect(area).toBeCloseTo(1.0, 5); } }); + + it('fills an INTERIOR layer of a 3+ layer wall (disconnected end strips stitched)', () => { + // Wall x∈[0,10], thickness split into 3 layers: outer [0,1], CORE [1,3], + // inner [3,4]. The core band has no wall face — its plan section is only the + // two END strips (x=0 and x=10), disconnected. A per-band loop builder drops + // it (the regression Codex flagged on #1311); stitching the fragments at the + // y=1 and y=3 interface chords recovers the core fill. + const seg = ( + ax: number, ay: number, bx: number, by: number, + c: [number, number, number, number], + ): CutSegment => ({ + p0: { x: ax, y: ay, z: 0 }, p1: { x: bx, y: by, z: 0 }, + p0_2d: { x: ax, y: ay }, p1_2d: { x: bx, y: by }, + entityId: 1, ifcType: 'IfcWall', modelIndex: 0, color: c, + }); + const GREEN: [number, number, number, number] = [0, 1, 0, 1]; + const segments = [ + // outer RED band [0,1]: wall face + 2 end strips (a closeable U) + seg(0, 0, 10, 0, RED), seg(0, 0, 0, 1, RED), seg(10, 0, 10, 1, RED), + // CORE GREEN band [1,3]: ONLY the two end strips — disconnected + seg(0, 1, 0, 3, GREEN), seg(10, 1, 10, 3, GREEN), + // inner BLUE band [3,4]: wall face + 2 end strips + seg(0, 4, 10, 4, BLUE), seg(0, 3, 0, 4, BLUE), seg(10, 3, 10, 4, BLUE), + ]; + + const polygons = new PolygonBuilder().buildPolygons(segments); + + expect(polygons).toHaveLength(3); + const area = (c: [number, number, number, number]) => + Math.abs(polygonSignedArea(polygons.find((p) => p.color === c)!.polygon.outer)); + expect(area(GREEN)).toBeCloseTo(20.0, 5); // core: 10 (length) × 2 (thickness) + expect(area(RED)).toBeCloseTo(10.0, 5); + expect(area(BLUE)).toBeCloseTo(10.0, 5); + }); }); diff --git a/packages/drawing-2d/src/polygon-builder.ts b/packages/drawing-2d/src/polygon-builder.ts index 83995d063..f2e293e36 100644 --- a/packages/drawing-2d/src/polygon-builder.ts +++ b/packages/drawing-2d/src/polygon-builder.ts @@ -131,8 +131,9 @@ export class PolygonBuilder { used: false, })); - // Build closed loops - const loops = this.buildLoops(segments2D); + // Build closed loops. Multi-material (per-layer) groups additionally STITCH + // disconnected open band segments at the interface chords (see `buildLoops`). + const loops = this.buildLoops(segments2D, color !== undefined); if (loops.length === 0) return []; @@ -154,21 +155,94 @@ export class PolygonBuilder { } /** - * Build closed loops from segments using a greedy chain-building algorithm + * Build closed loops from segments using a greedy chain-building algorithm. + * + * `stitchOpen` (multi-material / per-layer groups only): an INTERIOR layer band + * of a 3+ layer wall has no wall face — its plan section is two disconnected + * end strips that no single chain can close. Such fragments are collected and + * stitched end-to-end at the interface chords (`stitchOpenChains`) so the core + * layer still fills. Chains that close into a non-degenerate loop on their own + * (a 2-layer U-band, the finish-on-both-faces case) stay separate, preserving + * existing per-loop behaviour. */ - private buildLoops(segments: Segment2D[]): Loop[] { + private buildLoops(segments: Segment2D[], stitchOpen: boolean): Loop[] { const loops: Loop[] = []; + const fragments: Point2D[][] = []; - // Keep building loops until no more unused segments while (true) { - // Find first unused segment const startIdx = segments.findIndex((s) => !s.used); if (startIdx === -1) break; - const loop = this.buildSingleLoop(segments, startIdx); - if (loop && loop.length >= 3) { - const area = polygonSignedArea(loop); - loops.push({ points: loop, area }); + const chain = this.buildSingleLoop(segments, startIdx); + if (!chain) continue; + + const standalone = + chain.points.length >= 3 && + (chain.closed || Math.abs(polygonSignedArea(chain.points)) > 1e-9); + + if (standalone) { + loops.push({ points: chain.points, area: polygonSignedArea(chain.points) }); + } else if (stitchOpen) { + fragments.push(chain.points); // too short/degenerate to close alone — stitch it + } + // else (single-material, sub-loop fragment): dropped, as before. + } + + if (stitchOpen && fragments.length > 0) { + loops.push(...this.stitchOpenChains(fragments)); + } + + return loops; + } + + /** + * Stitch open band fragments (each a polyline) into closed loops by joining the + * nearest endpoints ACROSS fragments, only closing a chain on itself once no + * other fragment is left to attach. Merging across-first is essential: an + * interior band is thin, so its interface chord (along the wall length) is + * longer than the band thickness — a naive "close the nearest endpoints" rule + * would collapse the band instead of spanning it. + */ + private stitchOpenChains(fragments: Point2D[][]): Loop[] { + const loops: Loop[] = []; + const used = new Array(fragments.length).fill(false); + + for (let s = 0; s < fragments.length; s++) { + if (used[s]) continue; + let chain = fragments[s].slice(); + used[s] = true; + + // Attach the nearest remaining fragment to the tail until none are left. + for (;;) { + const tail = chain[chain.length - 1]; + let best = -1; + let reverse = false; + let bestDist = Infinity; + for (let i = 0; i < fragments.length; i++) { + if (used[i]) continue; + const f = fragments[i]; + const dStart = point2DDistance(f[0], tail); + const dEnd = point2DDistance(f[f.length - 1], tail); + if (dStart < bestDist) { + bestDist = dStart; + best = i; + reverse = false; + } + if (dEnd < bestDist) { + bestDist = dEnd; + best = i; + reverse = true; + } + } + if (best === -1) break; + used[best] = true; + const next = reverse ? fragments[best].slice().reverse() : fragments[best].slice(); + if (point2DDistance(chain[chain.length - 1], next[0]) < this.tolerance) next.shift(); + chain = chain.concat(next); + } + + if (chain.length >= 3 && Math.abs(polygonSignedArea(chain)) > 1e-9) { + loops.push({ points: chain, area: polygonSignedArea(chain) }); } } @@ -189,7 +263,10 @@ export class PolygonBuilder { * used to draw — so per-layer section fills are unchanged. Genuinely closed * cross-sections still close here (tail meets head) and return identically. */ - private buildSingleLoop(segments: Segment2D[], startIdx: number): Point2D[] | null { + private buildSingleLoop( + segments: Segment2D[], + startIdx: number, + ): { points: Point2D[]; closed: boolean } | null { const startSeg = segments[startIdx]; startSeg.used = true; @@ -208,7 +285,7 @@ export class PolygonBuilder { // endpoint and return the closed loop (the pre-existing behaviour). if (points.length >= 3 && point2DDistance(tail, head) < this.tolerance) { points.pop(); - return points; + return { points, closed: true }; } // Prefer extending the tail forward. @@ -234,12 +311,11 @@ export class PolygonBuilder { } // Neither end extends: an OPEN contour (a cap-free layer band, or genuinely - // open geometry). Return it; the signed-area / fill close it implicitly with - // the head→tail chord. + // open geometry). break; } - return points.length >= 3 ? points : null; + return points.length >= 2 ? { points, closed: false } : null; } /** From 4d0294ac320230a24aa7924d22b7b6a252deab07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:08 +0200 Subject: [PATCH 3/5] fix(drawing-2d): solid per-layer section cap on opening-cut walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #1311 each material layer is emitted as an OPEN band (watertight union, no doubled interface sheet), so the 3D section cap re-closes each band at its interface chords. On a wall with a door/window opening, an interior layer (no broad face) sections into 4+ disconnected vertical strips, and the greedy nearest-endpoint stitch hopped one strip to the strip ACROSS the opening — one self-overlapping polygon that bridged the void and failed to triangulate. The cut then read HOLLOW, which is what "the whole wall looks hollow in 3D section view" was. Close open bands along the interface LINES instead: detect the band's principal (length) axis via the endpoint covariance (robust to rotated walls), cluster endpoints onto the <=2 parallel interface lines, and pair them CONSECUTIVELY along each line (a scanline rule). That closes every solid chunk and leaves the opening between chunks empty. Ambiguous, near-square endpoint clouds fall back to the previous stitch, so no case regresses. Tests: new polygon-builder-opening suite (2-layer + opening, 3-layer interior core + opening, rotated wall); full drawing-2d 49/49 green. --- .changeset/layer-wall-watertight-section.md | 2 + .../src/polygon-builder-opening.test.ts | 144 ++++++++++++++++ packages/drawing-2d/src/polygon-builder.ts | 154 +++++++++++++++++- 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 packages/drawing-2d/src/polygon-builder-opening.test.ts diff --git a/.changeset/layer-wall-watertight-section.md b/.changeset/layer-wall-watertight-section.md index 7cb505cdf..264248ea9 100644 --- a/.changeset/layer-wall-watertight-section.md +++ b/.changeset/layer-wall-watertight-section.md @@ -3,3 +3,5 @@ --- Reconstruct per-layer section fills from open (cap-free) material-layer bands. The geometry slicer no longer caps the layer interface planes — capping doubled each shared interface into a coincident, non-watertight "ghost face" sheet and ~tripled the triangle count on layered walls. With the interfaces left open, the 2D section's polygon builder is now bidirectional (each open band closes at the interface chord) and, for 3+ layer walls, stitches the disconnected end strips of an interior layer (which has no wall face) back into a closed fill at the interface chords — so every layer keeps its section fill. + +Harden that reconstruction on OPENING-cut walls so the 3D section cap covers every layer (no more wall-reads-hollow in section view). An opening splits each layer into disconnected solid chunks; the old greedy nearest-endpoint stitch hopped an interior layer's strip to the strip ACROSS the opening, emitting one self-overlapping polygon that bridged the void and failed to fill. Closure now runs along the interface lines (the principal/length axis of the band, so it is robust to rotated walls): endpoints are paired CONSECUTIVELY along each interface line, which closes each solid chunk and leaves the opening between chunks empty. Ambiguous layouts fall back to the previous stitch, so no case is made worse. diff --git a/packages/drawing-2d/src/polygon-builder-opening.test.ts b/packages/drawing-2d/src/polygon-builder-opening.test.ts new file mode 100644 index 000000000..824239f57 --- /dev/null +++ b/packages/drawing-2d/src/polygon-builder-opening.test.ts @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Per-layer section-cap reconstruction on OPENING-CUT walls. + * + * Since #1311 the slicer emits each material layer as an OPEN band (the shared + * interface planes are no longer capped, so the union stays watertight). The + * 2D/3D section cap has to re-close each layer band at the interface chords. + * A door/window opening splits every layer into disconnected solid chunks; the + * reconstruction must close each chunk WITHOUT bridging the opening, otherwise + * the 3D section cap is wrong/missing and the wall reads as hollow. + */ + +import { describe, it, expect } from 'vitest'; +import { PolygonBuilder } from './polygon-builder.js'; +import { polygonSignedArea } from './math.js'; +import type { CutSegment, Point2D, DrawingPolygon } from './types.js'; + +const RED: [number, number, number, number] = [1, 0, 0, 1]; // outer layer +const GREEN: [number, number, number, number] = [0, 1, 0, 1]; // core layer +const BLUE: [number, number, number, number] = [0, 0, 1, 1]; // inner layer + +/** + * Cut segments of ONE material layer of a wall, plan-cut. x = wall length, + * y = wall thickness. `chunks` are the solid x-intervals that survive the + * opening(s). `faceLo`/`faceHi` say whether this layer owns a broad wall face + * at y=yLo / y=yHi (true only for the outermost / innermost layer); an interior + * layer owns neither, so its section is just the chunk side-walls (jambs + wall + * ends) — two disconnected vertical strips per chunk. + */ +function layerBand( + chunks: Array<[number, number]>, + yLo: number, + yHi: number, + faceLo: boolean, + faceHi: boolean, + entityId: number, + color: [number, number, number, number], +): CutSegment[] { + const segs: CutSegment[] = []; + const seg = (ax: number, ay: number, bx: number, by: number) => + segs.push({ + p0: { x: ax, y: ay, z: 0 }, p1: { x: bx, y: by, z: 0 }, + p0_2d: { x: ax, y: ay }, p1_2d: { x: bx, y: by }, + entityId, ifcType: 'IfcWall', modelIndex: 0, color, + }); + for (const [a, b] of chunks) { + seg(a, yLo, a, yHi); // side wall at x=a (wall end or opening jamb) + seg(b, yLo, b, yHi); // side wall at x=b + if (faceLo) seg(a, yLo, b, yLo); // broad face at y=yLo + if (faceHi) seg(a, yHi, b, yHi); // broad face at y=yHi + } + return segs; +} + +/** Ray-cast point-in-polygon (outer ring only) — for asserting an opening is empty. */ +function pointInOuter(pt: Point2D, poly: Point2D[]): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const pi = poly[i], pj = poly[j]; + if (pi.y > pt.y !== pj.y > pt.y && + pt.x < ((pj.x - pi.x) * (pt.y - pi.y)) / (pj.y - pi.y) + pi.x) { + inside = !inside; + } + } + return inside; +} + +const coversPoint = (polys: DrawingPolygon[], pt: Point2D) => + polys.some((p) => pointInOuter(pt, p.polygon.outer)); + +const areaOf = (polys: DrawingPolygon[]) => + polys.reduce((s, p) => s + Math.abs(polygonSignedArea(p.polygon.outer)), 0); + +const byColor = (polys: DrawingPolygon[], c: [number, number, number, number]) => + polys.filter((p) => p.color && p.color.every((v, i) => v === c[i])); + +describe('PolygonBuilder — opening-cut multilayer walls', () => { + // Wall x∈[0,10], opening x∈[4,6]; solid chunks LEFT [0,4] and RIGHT [6,10]. + const LEFT: [number, number] = [0, 4]; + const RIGHT: [number, number] = [6, 10]; + const inOpening: Point2D = { x: 5, y: 0.4 }; // dead centre of the opening + + it('2-layer wall: each layer fills both chunks, opening stays empty', () => { + // outer RED [0,0.2] owns the y=0 face; inner BLUE [0.2,0.4] owns the y=0.4 face. + const segments = [ + ...layerBand([LEFT, RIGHT], 0, 0.2, true, false, 1, RED), + ...layerBand([LEFT, RIGHT], 0.2, 0.4, false, true, 1, BLUE), + ]; + const polys = new PolygonBuilder().buildPolygons(segments); + + expect(byColor(polys, RED)).toHaveLength(2); + expect(byColor(polys, BLUE)).toHaveLength(2); + // 4 chunks × (4 length × 0.2 thick) = 3.2 + expect(areaOf(polys)).toBeCloseTo(3.2, 5); + expect(coversPoint(polys, inOpening)).toBe(false); + }); + + it('3-layer wall: INTERIOR core fills both chunks and does NOT bridge the opening', () => { + // This is the regression: the core layer (no broad face) is four disconnected + // vertical strips; a greedy nearest-endpoint stitch joins them ACROSS the + // opening into one self-overlapping polygon, so the cut reads hollow. + const segments = [ + ...layerBand([LEFT, RIGHT], 0.0, 0.2, true, false, 2, RED), // outer + ...layerBand([LEFT, RIGHT], 0.2, 0.6, false, false, 2, GREEN), // core (interior) + ...layerBand([LEFT, RIGHT], 0.6, 0.8, false, true, 2, BLUE), // inner + ]; + const polys = new PolygonBuilder().buildPolygons(segments); + + const core = byColor(polys, GREEN); + expect(core).toHaveLength(2); // one fill per solid chunk + // each core chunk: 4 length × 0.4 thick = 1.6 + expect(areaOf(core)).toBeCloseTo(3.2, 5); + // the opening must NOT be filled by ANY layer + expect(coversPoint(polys, inOpening)).toBe(false); + // every layer present, each as two chunks + expect(byColor(polys, RED)).toHaveLength(2); + expect(byColor(polys, BLUE)).toHaveLength(2); + }); + + it('rotated 3-layer wall: interface closure still tracks the (rotated) length axis', () => { + // Rotate the whole section 30° so the interface lines are not axis-aligned — + // the closure must follow the principal (length) axis, not world X/Y. + const t = Math.PI / 6, cos = Math.cos(t), sin = Math.sin(t); + const rot = (s: CutSegment): CutSegment => { + const r = (p: { x: number; y: number }) => ({ x: p.x * cos - p.y * sin, y: p.x * sin + p.y * cos }); + return { ...s, p0_2d: r(s.p0_2d), p1_2d: r(s.p1_2d) }; + }; + const segments = [ + ...layerBand([LEFT, RIGHT], 0.0, 0.2, true, false, 3, RED), + ...layerBand([LEFT, RIGHT], 0.2, 0.6, false, false, 3, GREEN), + ...layerBand([LEFT, RIGHT], 0.6, 0.8, false, true, 3, BLUE), + ].map(rot); + const polys = new PolygonBuilder().buildPolygons(segments); + + expect(byColor(polys, GREEN)).toHaveLength(2); + expect(areaOf(byColor(polys, GREEN))).toBeCloseTo(3.2, 4); + // opening centre, rotated into the same frame + const oc = { x: 5 * cos - 0.4 * sin, y: 5 * sin + 0.4 * cos }; + expect(coversPoint(polys, oc)).toBe(false); + }); +}); diff --git a/packages/drawing-2d/src/polygon-builder.ts b/packages/drawing-2d/src/polygon-builder.ts index f2e293e36..c815a7a5b 100644 --- a/packages/drawing-2d/src/polygon-builder.ts +++ b/packages/drawing-2d/src/polygon-builder.ts @@ -189,12 +189,164 @@ export class PolygonBuilder { } if (stitchOpen && fragments.length > 0) { - loops.push(...this.stitchOpenChains(fragments)); + loops.push(...this.closeOpenBands(fragments)); } return loops; } + /** + * Close disconnected open layer-band fragments into per-layer fill loops. + * + * Prefers the interface-aware closure ({@link closeAlongInterfaces}): a layer + * band is bounded on its non-face sides by the planes it shares with adjacent + * layers, so every open endpoint lies on one of (at most) two parallel + * INTERFACE lines. Pairing endpoints CONSECUTIVELY along each line (a scanline + * rule) closes each solid chunk and leaves any opening BETWEEN chunks empty. + * Falls back to the legacy nearest-endpoint stitch only when the geometry is + * too ambiguous to resolve that way (a near-square endpoint cloud), so a wall + * we cannot disambiguate is no worse than before, never worse. + */ + private closeOpenBands(fragments: Point2D[][]): Loop[] { + const viaInterface = this.closeAlongInterfaces(fragments); + return viaInterface ?? this.stitchOpenChains(fragments); + } + + /** + * Interface-aware closure of open band fragments. Returns `null` (caller falls + * back) when the layout is ambiguous: a near-isotropic endpoint cloud, an + * odd number of crossings on an interface line, or a chain that fails to close. + * + * Why this beats {@link stitchOpenChains}: a 3+ layer wall's INTERIOR layer has + * no broad face, so under an opening its section is four+ disconnected vertical + * strips. The greedy "attach the nearest endpoint" rule hops one strip to the + * strip across the opening and emits one self-overlapping polygon that bridges + * the void — the 3D section cap then reads hollow. Closing along the interface + * lines instead pairs (strip, strip) within each solid chunk and never spans + * the opening. + */ + private closeAlongInterfaces(fragments: Point2D[][]): Loop[] | null { + const polylines = fragments.filter((f) => f.length >= 2); + if (polylines.length < 2) return null; + + // Two open endpoints per polyline. endIndex = poly*2 + which (0 head, 1 tail). + const endCount = polylines.length * 2; + const endPoint = (idx: number): Point2D => { + const poly = polylines[idx >> 1]; + return (idx & 1) === 0 ? poly[0] : poly[poly.length - 1]; + }; + + // Interface lines run along the band's LENGTH — the principal axis of the + // endpoint cloud (robust to a rotated wall). `P` is the perpendicular along + // which the (≤2) interface lines are offset. + const cloud: Point2D[] = []; + for (let i = 0; i < endCount; i++) cloud.push(endPoint(i)); + const axis = this.principalAxis(cloud); + if (!axis) return null; + const { L, P } = axis; + const along = (p: Point2D, a: Point2D) => p.x * a.x + p.y * a.y; + + // Cluster endpoints onto interface lines (≤2) by splitting the sorted P + // coordinates at their single dominant gap (the layer thickness). + const order = [...Array(endCount).keys()].sort( + (a, b) => along(endPoint(a), P) - along(endPoint(b), P), + ); + const pOf = (i: number) => along(endPoint(i), P); + const span = pOf(order[order.length - 1]) - pOf(order[0]); + let splitAt = -1; + if (span > this.tolerance) { + let gap = -Infinity; + for (let k = 1; k < order.length; k++) { + const g = pOf(order[k]) - pOf(order[k - 1]); + if (g > gap) { gap = g; splitAt = k; } + } + if (gap < span * 0.25) splitAt = -1; // not clearly bimodal ⇒ one line + } + const clusters: number[][] = + splitAt > 0 ? [order.slice(0, splitAt), order.slice(splitAt)] : [order]; + + // Pair endpoints consecutively along each interface line. + const pair = new Int32Array(endCount).fill(-1); + for (const cluster of clusters) { + if (cluster.length % 2 !== 0) return null; // unpaired crossing ⇒ ambiguous + cluster.sort((a, b) => along(endPoint(a), L) - along(endPoint(b), L)); + for (let k = 0; k + 1 < cluster.length; k += 2) { + pair[cluster[k]] = cluster[k + 1]; + pair[cluster[k + 1]] = cluster[k]; + } + } + for (let i = 0; i < endCount; i++) if (pair[i] < 0) return null; + + // Walk polylines, hopping across the interface chords, into closed loops. + const loops: Loop[] = []; + const usedPoly = new Array(polylines.length).fill(false); + for (let start = 0; start < polylines.length; start++) { + if (usedPoly[start]) continue; + const ring: Point2D[] = []; + let poly = start; + let enterWhich = 0; // enter at head, walk to tail + let closed = false; + for (let guard = 0; guard <= polylines.length; guard++) { + usedPoly[poly] = true; + const line = polylines[poly]; + const ordered = enterWhich === 0 ? line : [...line].reverse(); + for (const q of ordered) { + if (ring.length === 0 || point2DDistance(ring[ring.length - 1], q) > this.tolerance) { + ring.push(q); + } + } + const exit = poly * 2 + (enterWhich === 0 ? 1 : 0); + const next = pair[exit]; + if (next === start * 2) { closed = true; break; } + const nextPoly = next >> 1; + if (usedPoly[nextPoly]) break; + poly = nextPoly; + enterWhich = next & 1; + } + // A half-open ring would render as a bad cap — bail and let the caller fall + // back rather than emit it. + if (!closed) return null; + if (ring.length >= 3 && Math.abs(polygonSignedArea(ring)) > 1e-9) { + loops.push({ points: ring, area: polygonSignedArea(ring) }); + } + } + return loops.length > 0 ? loops : null; + } + + /** + * Principal (major) axis `L` and its perpendicular `P` of a 2D point cloud, + * via the covariance eigenvectors. Returns `null` when the cloud is + * near-isotropic (no dominant direction), so the interface closure can defer + * to the fallback instead of trusting a meaningless axis. + */ + private principalAxis(pts: Point2D[]): { L: Point2D; P: Point2D } | null { + const n = pts.length; + if (n < 2) return null; + let mx = 0, my = 0; + for (const p of pts) { mx += p.x; my += p.y; } + mx /= n; my /= n; + let sxx = 0, syy = 0, sxy = 0; + for (const p of pts) { + const dx = p.x - mx, dy = p.y - my; + sxx += dx * dx; syy += dy * dy; sxy += dx * dy; + } + const tr = sxx + syy; + if (tr < EPSILON) return null; + const disc = Math.sqrt(Math.max(0, (tr * tr) / 4 - (sxx * syy - sxy * sxy))); + const l1 = tr / 2 + disc; // major eigenvalue + const l2 = tr / 2 - disc; // minor eigenvalue + if (l1 < EPSILON || l2 / l1 > 0.7) return null; // near-isotropic ⇒ ambiguous + let lx: number, ly: number; + if (Math.abs(sxy) > EPSILON) { + lx = l1 - syy; ly = sxy; + } else { + lx = sxx >= syy ? 1 : 0; ly = sxx >= syy ? 0 : 1; + } + const len = Math.hypot(lx, ly) || 1; + const L = { x: lx / len, y: ly / len }; + return { L, P: { x: -L.y, y: L.x } }; + } + /** * Stitch open band fragments (each a polyline) into closed loops by joining the * nearest endpoints ACROSS fragments, only closing a chain on itself once no From c167c827b3b2aa78005e5c9fb31b376eb14ecc6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:15:28 +0200 Subject: [PATCH 4/5] feat(drawing-2d): opaque base-cap backstop so section cuts never read hollow The per-layer cap is reconstructed from open (cap-free) layer bands, so an exotic wall the reconstruction cannot disambiguate can still leave a gap and the 3D section cut reads see-through. Add a backstop that guarantees a solid cut regardless. For every MULTI-material entity, also build its full closed cross-section (buildBasePolygons): combining all of an entity's cut segments drops the open interfaces and leaves the watertight outer skin, which closes into the solid chunks with no interface stitching needed. This base is carried in a NEW Drawing2D.layerBaseCutPolygons that ONLY the 3D section overlay reads, so the flat 2D drawing, SVG export, and measure/snap paths are untouched. The overlay triangulates polygons in array order into one draw, so the viewer prepends the (colourless => opaque uniform cap fill) base polygons before the per-layer colours: the colours show where they reconstruct, and solid cut material shows everywhere else. Net effect: per-layer colour where possible, never a hollow-looking cut. Tests: buildBasePolygons suite (full section per layered entity, opening stays empty, none for single-material); full drawing-2d 51/51 green; drawing-2d + changed viewer files typecheck clean. --- .changeset/layer-wall-watertight-section.md | 2 ++ .../src/components/viewer/useRenderUpdates.ts | 14 +++++++- apps/viewer/src/hooks/useDrawingGeneration.ts | 9 +++++ packages/drawing-2d/src/drawing-generator.ts | 5 +++ .../src/polygon-builder-opening.test.ts | 33 +++++++++++++++++ packages/drawing-2d/src/polygon-builder.ts | 35 +++++++++++++++++++ packages/drawing-2d/src/types.ts | 15 ++++++++ 7 files changed, 112 insertions(+), 1 deletion(-) diff --git a/.changeset/layer-wall-watertight-section.md b/.changeset/layer-wall-watertight-section.md index 264248ea9..7d51e8f70 100644 --- a/.changeset/layer-wall-watertight-section.md +++ b/.changeset/layer-wall-watertight-section.md @@ -5,3 +5,5 @@ Reconstruct per-layer section fills from open (cap-free) material-layer bands. The geometry slicer no longer caps the layer interface planes — capping doubled each shared interface into a coincident, non-watertight "ghost face" sheet and ~tripled the triangle count on layered walls. With the interfaces left open, the 2D section's polygon builder is now bidirectional (each open band closes at the interface chord) and, for 3+ layer walls, stitches the disconnected end strips of an interior layer (which has no wall face) back into a closed fill at the interface chords — so every layer keeps its section fill. Harden that reconstruction on OPENING-cut walls so the 3D section cap covers every layer (no more wall-reads-hollow in section view). An opening splits each layer into disconnected solid chunks; the old greedy nearest-endpoint stitch hopped an interior layer's strip to the strip ACROSS the opening, emitting one self-overlapping polygon that bridged the void and failed to fill. Closure now runs along the interface lines (the principal/length axis of the band, so it is robust to rotated walls): endpoints are paired CONSECUTIVELY along each interface line, which closes each solid chunk and leaves the opening between chunks empty. Ambiguous layouts fall back to the previous stitch, so no case is made worse. + +Add an opaque base-cap backstop so a 3D section cut can NEVER read see-through, even on a wall the per-layer reconstruction cannot resolve. For each multi-material entity the builder also emits its full closed cross-section (the watertight union of the bands always closes, so this needs no interface stitching), carried in a new `Drawing2D.layerBaseCutPolygons` that ONLY the 3D section overlay consumes (the flat 2D drawing, SVG export, and measure/snap paths are untouched). The overlay draws this opaque base first and the per-layer colours over it, so the colours show where they reconstruct and solid cut material shows everywhere else. diff --git a/apps/viewer/src/components/viewer/useRenderUpdates.ts b/apps/viewer/src/components/viewer/useRenderUpdates.ts index 2a2924a04..8a33fcf8a 100644 --- a/apps/viewer/src/components/viewer/useRenderUpdates.ts +++ b/apps/viewer/src/components/viewer/useRenderUpdates.ts @@ -90,7 +90,18 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void { if (!renderer || !isInitialized) return; if (activeTool === 'section' && drawing2D && drawing2D.cutPolygons.length > 0 && show3DOverlay) { - const polygons: CutPolygon2D[] = drawing2D.cutPolygons.map((cp) => ({ + // Opaque base cross-sections FIRST (one per multi-material entity, built + // from the watertight union so they always close). The overlay triangulates + // polygons in array order into one draw, so these render BEHIND the per- + // layer colours below; where a layer's cap could not be reconstructed the + // base keeps the cut solid instead of see-through (the "wall looks hollow in + // section view" backstop). Colourless → uniform opaque cap fill. + const basePolygons: CutPolygon2D[] = (drawing2D.layerBaseCutPolygons ?? []).map((cp) => ({ + polygon: cp.polygon, + ifcType: cp.ifcType, + expressId: cp.entityId, + })); + const layerPolygons: CutPolygon2D[] = drawing2D.cutPolygons.map((cp) => ({ polygon: cp.polygon, ifcType: cp.ifcType, expressId: cp.entityId, @@ -99,6 +110,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void { // build-up. Undefined for single-material polygons → uniform cap style. color: cp.color, })); + const polygons: CutPolygon2D[] = [...basePolygons, ...layerPolygons]; const lines: DrawingLine2D[] = drawing2D.lines .filter((line) => showHiddenLines || line.visibility !== 'hidden') diff --git a/apps/viewer/src/hooks/useDrawingGeneration.ts b/apps/viewer/src/hooks/useDrawingGeneration.ts index 04f8aa555..aa51f0752 100644 --- a/apps/viewer/src/hooks/useDrawingGeneration.ts +++ b/apps/viewer/src/hooks/useDrawingGeneration.ts @@ -982,10 +982,19 @@ export function useDrawingGeneration({ } // Create hybrid drawing + // Keep the opaque layer-base backstop (3D overlay only) in step with the + // filtered per-layer fills, so an entity replaced by a symbolic rep does + // not leave a base with no colour fills behind it. + const filteredLayerBasePolygons = result.layerBaseCutPolygons?.filter( + (poly: { entityId?: number }) => + poly.entityId === undefined || !entitiesWithRelevantSymbols.has(poly.entityId), + ); + const hybridDrawing: Drawing2D = { ...result, lines: combinedLines, cutPolygons: filteredCutPolygons, + layerBaseCutPolygons: filteredLayerBasePolygons, bounds: { min: { x: isFinite(minX) ? minX : result.bounds.min.x, y: isFinite(minY) ? minY : result.bounds.min.y }, max: { x: isFinite(maxX) ? maxX : result.bounds.max.x, y: isFinite(maxY) ? maxY : result.bounds.max.y }, diff --git a/packages/drawing-2d/src/drawing-generator.ts b/packages/drawing-2d/src/drawing-generator.ts index 3db9c52cb..c113b1413 100644 --- a/packages/drawing-2d/src/drawing-generator.ts +++ b/packages/drawing-2d/src/drawing-generator.ts @@ -170,6 +170,10 @@ export class Drawing2DGenerator { report('polygons', 0); const cutPolygons = this.polygonBuilder.buildPolygons(cutSegments); + // Opaque base cross-section per multi-material entity, for the 3D overlay to + // draw behind the per-layer fills (never see-through). Kept out of + // `cutPolygons` so the flat 2D drawing / export / measure paths are untouched. + const layerBaseCutPolygons = this.polygonBuilder.buildBasePolygons(cutSegments); report('polygons', 1); @@ -362,6 +366,7 @@ export class Drawing2DGenerator { config, lines: allLines, cutPolygons, + layerBaseCutPolygons, projectionPolygons: [], // TODO: implement projection polygon extraction bounds, stats: { diff --git a/packages/drawing-2d/src/polygon-builder-opening.test.ts b/packages/drawing-2d/src/polygon-builder-opening.test.ts index 824239f57..5ed353b04 100644 --- a/packages/drawing-2d/src/polygon-builder-opening.test.ts +++ b/packages/drawing-2d/src/polygon-builder-opening.test.ts @@ -142,3 +142,36 @@ describe('PolygonBuilder — opening-cut multilayer walls', () => { expect(coversPoint(polys, oc)).toBe(false); }); }); + +describe('PolygonBuilder.buildBasePolygons — opaque section backstop', () => { + const LEFT: [number, number] = [0, 4]; + const RIGHT: [number, number] = [6, 10]; + + it('builds the full closed cross-section per layered entity (both chunks, opening empty)', () => { + // Same 3-layer opening wall. The base ignores the per-layer split: combining + // all bands drops the open interfaces and leaves the watertight outer skin, + // which closes into the two solid chunks — no interface stitching needed. + const segments = [ + ...layerBand([LEFT, RIGHT], 0.0, 0.2, true, false, 7, RED), + ...layerBand([LEFT, RIGHT], 0.2, 0.6, false, false, 7, GREEN), + ...layerBand([LEFT, RIGHT], 0.6, 0.8, false, true, 7, BLUE), + ]; + const base = new PolygonBuilder().buildBasePolygons(segments); + + expect(base).toHaveLength(2); // one per solid chunk + for (const p of base) { + expect(p.isLayerBase).toBe(true); + expect(p.color).toBeUndefined(); // colourless ⇒ opaque uniform fill + } + // full wall section = 2 chunks × (4 length × 0.8 thickness) = 6.4 + expect(areaOf(base)).toBeCloseTo(6.4, 5); + expect(coversPoint(base, { x: 5, y: 0.4 })).toBe(false); // opening stays empty + // and it DOES cover where a layer fill belongs, so a missing layer reads solid + expect(coversPoint(base, { x: 2, y: 0.4 })).toBe(true); + }); + + it('emits no base for a single-material entity (its normal fill is already solid)', () => { + const segments = layerBand([[0, 10]], 0, 0.3, true, true, 8, RED); + expect(new PolygonBuilder().buildBasePolygons(segments)).toHaveLength(0); + }); +}); diff --git a/packages/drawing-2d/src/polygon-builder.ts b/packages/drawing-2d/src/polygon-builder.ts index c815a7a5b..0db525b2a 100644 --- a/packages/drawing-2d/src/polygon-builder.ts +++ b/packages/drawing-2d/src/polygon-builder.ts @@ -77,6 +77,41 @@ export class PolygonBuilder { return polygonArrays.flat(); } + /** + * Build the OPAQUE BASE cross-section for every MULTI-material entity — its + * full solid section, ignoring the per-layer colour split. + * + * Combining all of one entity's cut segments drops the (open) interface + * boundaries and leaves only the wall's outer skin, which — because #1311 made + * the union of layer bands watertight — is a set of CLOSED rings (the solid + * chunks, with openings as holes/separate rings). So this needs no interface + * stitching: the ordinary closed-loop builder resolves it robustly. Drawn + * behind the per-layer fills in the 3D overlay, it guarantees a cut never reads + * hollow even where the per-layer reconstruction has to fall back. Single- + * material entities are skipped — their normal `cutPolygons` already fill solid. + */ + buildBasePolygons(segments: CutSegment[]): DrawingPolygon[] { + const byEntity = new Map(); + for (const seg of segments) { + const key = makeEntityKey(seg.modelIndex, seg.entityId); + let bucket = byEntity.get(key); + if (!bucket) { bucket = []; byEntity.set(key, bucket); } + bucket.push(seg); + } + + const out: DrawingPolygon[][] = []; + for (const entitySegments of byEntity.values()) { + // Only entities that cut into >1 material get per-layer fills (hence a base). + const colors = new Set(entitySegments.map((s) => colorKey(s.color))); + if (colors.size < 2) continue; + // Colourless build ⇒ closed-loop path (the combined section is closed). + const base = this.buildColorGroupPolygons(entitySegments, undefined) + .map((p) => ({ ...p, isLayerBase: true })); + if (base.length > 0) out.push(base); + } + return out.flat(); + } + /** * Build polygons for a single entity. * diff --git a/packages/drawing-2d/src/types.ts b/packages/drawing-2d/src/types.ts index f8304fce5..b9dfcd9c0 100644 --- a/packages/drawing-2d/src/types.ts +++ b/packages/drawing-2d/src/types.ts @@ -185,6 +185,14 @@ export interface DrawingPolygon { * element; absent for single-material elements (keep the existing * per-`ifcType` / per-entity fill). */ color?: [number, number, number, number]; + /** Set on the OPAQUE BASE polygon emitted for a multi-material entity: the + * entity's full closed cross-section (built from the watertight union of all + * its layer bands, so it always closes). It is drawn BEHIND the per-layer + * colour fills in the 3D section overlay as a backstop, so a layer the + * reconstruction cannot resolve still shows solid cut material instead of a + * see-through hole. Carried only in `Drawing2D.layerBaseCutPolygons` (never in + * `cutPolygons`), so the flat 2D drawing / export / measure paths never see it. */ + isLayerBase?: boolean; } // ═══════════════════════════════════════════════════════════════════════════ @@ -262,6 +270,13 @@ export interface Drawing2D { /** Cut polygons (for hatching) */ cutPolygons: DrawingPolygon[]; + /** Opaque base cross-sections (one set per multi-material entity) drawn behind + * the per-layer fills in the 3D section overlay so a cut never reads hollow. + * Consumed ONLY by the 3D overlay; absent from `cutPolygons` so the flat 2D + * drawing, SVG export, and measure/snap paths are unaffected. Empty/omitted + * when no multi-material (layered) element is cut. */ + layerBaseCutPolygons?: DrawingPolygon[]; + /** Projection polygons (visible surfaces beyond cut) */ projectionPolygons: DrawingPolygon[]; From ffe5ac50dca65e7348784c81af6a983393464711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Tr=C3=BCmpler?= <78563314+louistrue@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:47:53 +0200 Subject: [PATCH 5/5] fix(renderer): draw material-layer slices double-sided so walls aren't hollow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multilayer walls read HOLLOW in normal (uncut) 3D, not only in section. Root cause: the renderer backface-culled material-layer slices (the culledPipeline, cullMode 'back'), assuming their winding was reliably outward. That held for the OLD closed per-layer slabs, where the cull hid the coincident interface caps that would otherwise z-fight. Since #1311 the slabs are OPEN bands whose union is the wall's watertight outer skin (no caps), and IFC winding is not reliably outward, so culling dropped every inward-wound face and punched holes into the skin — the wall looked like a thin see-through shell. Render layer slices DOUBLE-SIDED like all other IFC geometry: every face of the watertight skin draws, so the wall reads solid. With no coincident caps left there is nothing to z-fight, so the dedicated culled pipeline and the isLayer batch flag that selected it are removed. GEOM_CLASS_LAYER_SLICE stays — it now only marks per-layer section fills, not culling. renderer + spatial typecheck clean; drawing-2d 51/51. element.rs change is the stale-comment update only (no code change). --- .changeset/layer-wall-watertight-section.md | 3 ++ packages/renderer/src/index.ts | 34 +++++++++------------ packages/renderer/src/pipeline.ts | 19 ------------ packages/renderer/src/scene.ts | 9 ------ packages/renderer/src/types.ts | 6 ---- rust/processing/src/element.rs | 15 ++++----- 6 files changed, 25 insertions(+), 61 deletions(-) diff --git a/.changeset/layer-wall-watertight-section.md b/.changeset/layer-wall-watertight-section.md index 7d51e8f70..ee642424e 100644 --- a/.changeset/layer-wall-watertight-section.md +++ b/.changeset/layer-wall-watertight-section.md @@ -1,5 +1,6 @@ --- "@ifc-lite/drawing-2d": patch +"@ifc-lite/renderer": patch --- Reconstruct per-layer section fills from open (cap-free) material-layer bands. The geometry slicer no longer caps the layer interface planes — capping doubled each shared interface into a coincident, non-watertight "ghost face" sheet and ~tripled the triangle count on layered walls. With the interfaces left open, the 2D section's polygon builder is now bidirectional (each open band closes at the interface chord) and, for 3+ layer walls, stitches the disconnected end strips of an interior layer (which has no wall face) back into a closed fill at the interface chords — so every layer keeps its section fill. @@ -7,3 +8,5 @@ Reconstruct per-layer section fills from open (cap-free) material-layer bands. T Harden that reconstruction on OPENING-cut walls so the 3D section cap covers every layer (no more wall-reads-hollow in section view). An opening splits each layer into disconnected solid chunks; the old greedy nearest-endpoint stitch hopped an interior layer's strip to the strip ACROSS the opening, emitting one self-overlapping polygon that bridged the void and failed to fill. Closure now runs along the interface lines (the principal/length axis of the band, so it is robust to rotated walls): endpoints are paired CONSECUTIVELY along each interface line, which closes each solid chunk and leaves the opening between chunks empty. Ambiguous layouts fall back to the previous stitch, so no case is made worse. Add an opaque base-cap backstop so a 3D section cut can NEVER read see-through, even on a wall the per-layer reconstruction cannot resolve. For each multi-material entity the builder also emits its full closed cross-section (the watertight union of the bands always closes, so this needs no interface stitching), carried in a new `Drawing2D.layerBaseCutPolygons` that ONLY the 3D section overlay consumes (the flat 2D drawing, SVG export, and measure/snap paths are untouched). The overlay draws this opaque base first and the per-layer colours over it, so the colours show where they reconstruct and solid cut material shows everywhere else. + +Fix multilayer walls reading HOLLOW in normal (uncut) 3D, not just in section. The renderer backface-culled material-layer slices on the assumption their winding was reliably outward — correct for the OLD closed per-layer slabs (the cull hid their coincident interface caps). Since the slabs became open bands whose union is the wall's watertight outer skin (no caps), and IFC winding is not reliably outward, culling dropped inward-wound faces and punched holes, so the wall looked like a thin see-through shell. Layer slices now render DOUBLE-SIDED like all other IFC geometry: every face of the watertight skin draws, so the wall reads solid. With no coincident caps left there is nothing to z-fight, so the cull that motivated the special pipeline is removed (the `GEOM_CLASS_LAYER_SLICE` tag stays — it now only marks per-layer section fills). diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index a61e6fda8..4c51ff497 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -1823,24 +1823,19 @@ export class Renderer { pass.drawIndexed(batch.indexCount); }; - // Render opaque batches first with opaque pipeline. Material-layer - // slices (isLayer) draw separately with the BACKFACE-CULLING - // pipeline — their thin coincident interior caps would otherwise - // z-fight into a hollow shimmer; culling drops them (winding is - // reliable) so the build-up reads as a clean solid. + // Render opaque batches with the opaque (double-sided) pipeline. + // Material-layer slices render double-sided like all other IFC + // geometry. They USED to be backface-culled to hide the coincident + // interface caps of the old CLOSED per-layer slabs; since #1311 the + // slabs are open bands whose UNION is the wall's watertight outer + // skin (no caps ⇒ no coincident faces to z-fight). IFC winding is + // not reliably outward, so culling those bands dropped inward-wound + // faces and punched holes — the wall read HOLLOW even uncut. + // Double-siding draws every face of the watertight skin ⇒ solid. pass.setPipeline(this.pipeline.getPipeline()); for (const batch of opaqueBatches) { - if (batch.isLayer) continue; renderBatch(batch); } - const layerOpaqueBatches = opaqueBatches.filter((b) => b.isLayer); - if (layerOpaqueBatches.length > 0) { - pass.setPipeline(this.pipeline.getCulledPipeline()); - for (const batch of layerOpaqueBatches) { - renderBatch(batch); - } - pass.setPipeline(this.pipeline.getPipeline()); - } // GPU-instancing pass — repeated geometry collated by the producer // into one template + a per-occurrence instance buffer (mat4 + @@ -1951,13 +1946,12 @@ export class Renderer { ); if (isTransparent) { pass.setPipeline(this.pipeline.getTransparentPipeline()); - } else if (subBatch.isLayer) { - // Material-layer slice sub-batch: backface-cull so - // the thin coincident caps don't z-fight (see the - // full-batch path above). - pass.setPipeline(this.pipeline.getCulledPipeline()); - opaqueSubBatches.push(subBatch); } else { + // Opaque (incl. material-layer slices): double-sided. + // Layer slices are NOT culled — since #1311 they are + // open watertight-skin bands with unreliable winding, + // so culling punched holes (wall read hollow). See the + // full-batch path above. pass.setPipeline(this.pipeline.getPipeline()); opaqueSubBatches.push(subBatch); } diff --git a/packages/renderer/src/pipeline.ts b/packages/renderer/src/pipeline.ts index b3ace2d64..3fead60b0 100644 --- a/packages/renderer/src/pipeline.ts +++ b/packages/renderer/src/pipeline.ts @@ -20,7 +20,6 @@ export class RenderPipeline { private device: GPUDevice; private webgpuDevice: WebGPUDevice; private pipeline: GPURenderPipeline; - private culledPipeline!: GPURenderPipeline; // Opaque pipeline with backface culling — material-layer slices only (their winding is reliable) private instancedPipeline!: GPURenderPipeline; // GPU-instancing: template (slot 0) + per-instance buffer (slot 1) private instancedTransparentPipeline: GPURenderPipeline | null = null; // instanced pipeline with alpha blend (lens/x-ray/compare overlays); lazily built, null if unbuilt/rejected private makeInstancedTransparentPipeline: (() => GPURenderPipeline) | null = null; // deferred factory (see constructor) @@ -244,19 +243,6 @@ export class RenderPipeline { // (built after transparentPipelineDescriptor below) reuses it verbatim. const instancedVertexStage = instancedVertex; - // Backface-culled clone of the opaque pipeline, used ONLY for material- - // layer slices. Those are thin watertight outward-wound solids stacked - // with coincident interface caps; drawn double-sided (cullMode 'none') - // the back-facing cap of each pair z-fights its neighbour into a hollow- - // looking shimmer. Their winding is reliable (positive signed volume), so - // culling back faces (default frontFace 'ccw') drops the interior caps - // and the build-up reads as a clean solid. General IFC geometry stays on - // the non-culled `pipeline` because its winding is not reliable. - this.culledPipeline = this.device.createRenderPipeline({ - ...pipelineDescriptor, - primitive: { topology: 'triangle-list', cullMode: 'back' }, - } as GPURenderPipelineDescriptor); - // Create selection pipeline descriptor const selectionPipelineDescriptor: GPURenderPipelineDescriptor = { layout: pipelineLayout, @@ -664,11 +650,6 @@ export class RenderPipeline { return this.pipeline; } - /** Backface-culled opaque pipeline — material-layer slices only. */ - getCulledPipeline(): GPURenderPipeline { - return this.culledPipeline; - } - /** GPU-instancing pipeline (template vertex buffer at slot 0 + per-instance buffer at slot 1). */ getInstancedPipeline(): GPURenderPipeline { return this.instancedPipeline; diff --git a/packages/renderer/src/scene.ts b/packages/renderer/src/scene.ts index 53b3602be..18ae57255 100644 --- a/packages/renderer/src/scene.ts +++ b/packages/renderer/src/scene.ts @@ -1666,14 +1666,6 @@ export class Scene { ], }); - // Backface-cull this batch iff EVERY source mesh is a material-layer slice - // (geometryClass 3). Mixed-class buckets (a non-layer element that happens - // to share the exact colour) stay double-sided — culling them could drop - // faces if their winding is unreliable; the layer slices' winding is not. - const isLayer = - meshDataArray.length > 0 && - meshDataArray.every((m) => (m.geometryClass ?? 0) === 3); - return { id: this.nextBatchId++, colorKey: bucketKey ?? this.colorKey(color), @@ -1688,7 +1680,6 @@ export class Scene { // Per-batch local frame: positions are stored relative to this; the draw // loop applies model = translate(origin) so they land in world space. origin: merged.origin, - isLayer, }; } diff --git a/packages/renderer/src/types.ts b/packages/renderer/src/types.ts index ce1cfbe28..8e6fd5ed6 100644 --- a/packages/renderer/src/types.ts +++ b/packages/renderer/src/types.ts @@ -75,12 +75,6 @@ export interface BatchedMesh { * space (world = origin + position). Keeps f32 vertex coords element-small at * building/georef scale (no fan collapse). [0,0,0] = absolute (legacy). */ origin?: [number, number, number]; - /** True when every source mesh in this batch is a material-layer slice - * (geometryClass 3). Those slices are watertight, outward-wound thin solids - * whose interior coincident caps z-fight when drawn double-sided; the draw - * loop renders this batch with the BACKFACE-CULLING pipeline so only the - * visible build-up surfaces rasterise (clean solid, no hollow shimmer). */ - isLayer?: boolean; } // Section plane for clipping diff --git a/rust/processing/src/element.rs b/rust/processing/src/element.rs index 09f9530c1..bc3d47aea 100644 --- a/rust/processing/src/element.rs +++ b/rust/processing/src/element.rs @@ -252,13 +252,14 @@ fn produce_inner( .get(&job.id) .is_some_and(|openings| !openings.is_empty()); - // Material-layer wall: its per-layer slices are thin coincident-faced solids - // that z-fight into a hollow-looking shell when drawn double-sided. They have - // verified-correct outward winding, though, so we tag them GEOM_CLASS_LAYER_SLICE - // and the renderer draws them BACKFACE-CULLED — the build-up stays visible on - // the faces/edges, but the interior coincident caps (which would z-fight) are - // never rasterised, so the wall reads as a clean solid. The 2D/section cut - // (which never culls) consumes the same class for its per-layer fills. + // Material-layer wall: tag its per-layer slices GEOM_CLASS_LAYER_SLICE so the + // 2D/section cut can split the cut into per-layer fills (one sub-mesh = one + // layer = one colour). Since #1311 the slices are OPEN bands whose union is + // the wall's watertight outer skin (no coincident interface caps), and the + // renderer draws them DOUBLE-SIDED like all other IFC geometry — IFC winding + // is not reliably outward, so the previous backface-culling of these slices + // dropped inward-wound faces and made the wall read hollow. The tag no longer + // drives any culling; it is purely the per-layer-fill marker. let layer_class = if router.is_material_layer_sliceable(job.id) { GEOM_CLASS_LAYER_SLICE } else {