diff --git a/.changeset/layer-wall-watertight-section.md b/.changeset/layer-wall-watertight-section.md new file mode 100644 index 000000000..ee642424e --- /dev/null +++ b/.changeset/layer-wall-watertight-section.md @@ -0,0 +1,12 @@ +--- +"@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. + +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/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 new file mode 100644 index 000000000..5ed353b04 --- /dev/null +++ b/packages/drawing-2d/src/polygon-builder-opening.test.ts @@ -0,0 +1,177 @@ +/* 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); + }); +}); + +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.test.ts b/packages/drawing-2d/src/polygon-builder.test.ts index 36ea1f6d2..767d93355 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,83 @@ 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); + } + }); + + 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 899dac91b..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. * @@ -131,8 +166,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,38 +190,274 @@ 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.closeOpenBands(fragments)); } return loops; } /** - * Build a single closed loop starting from a segment + * 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 buildSingleLoop(segments: Segment2D[], startIdx: number): Point2D[] | null { - const points: Point2D[] = []; + 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 + * 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) }); + } + } + + return loops; + } + + /** + * 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, + ): { points: Point2D[]; closed: boolean } | null { 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,35 +465,44 @@ export class PolygonBuilder { while (iterations < maxIterations) { iterations++; - // Check if we've closed the loop - if (point2DDistance(currentEnd, loopStart) < this.tolerance) { - return points; - } + const head = points[0]; + const tail = points[points.length - 1]; - // 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; + // 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, closed: true }; } - const nextSeg = segments[nextIdx]; - nextSeg.used = true; + // 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; + } - // 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). + break; } - // Loop didn't close - return points anyway for potential use - // Some entities may have open cross-sections - return points.length >= 3 ? points : null; + return points.length >= 2 ? { points, closed: false } : null; } /** 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[]; 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/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) } 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 {