Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/layer-wall-watertight-section.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@ifc-lite/drawing-2d": patch
"@ifc-lite/renderer": patch
Comment on lines +2 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump the wasm package for the slicer change

When this changeset is published, only @ifc-lite/drawing-2d and @ifc-lite/renderer receive new versions, but the mesh-slicing fix lives in rust/geometry/src/router/layers.rs and is shipped to npm through @ifc-lite/wasm. Consumers can therefore get the new renderer that deliberately stops culling layer slices while still resolving the old wasm runtime that emits capped, coincident layer slabs, reintroducing the z-fighting/hollow wall case this PR fixes. Add a patch bump for @ifc-lite/wasm (and the dependent geometry package if releases rely on it) so the renderer and wasm geometry stay in lockstep.

Useful? React with 👍 / 👎.

---

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).
14 changes: 13 additions & 1 deletion apps/viewer/src/components/viewer/useRenderUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
Expand Down
9 changes: 9 additions & 0 deletions apps/viewer/src/hooks/useDrawingGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions packages/drawing-2d/src/drawing-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -362,6 +366,7 @@ export class Drawing2DGenerator {
config,
lines: allLines,
cutPolygons,
layerBaseCutPolygons,
projectionPolygons: [], // TODO: implement projection polygon extraction
bounds,
stats: {
Expand Down
177 changes: 177 additions & 0 deletions packages/drawing-2d/src/polygon-builder-opening.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
81 changes: 81 additions & 0 deletions packages/drawing-2d/src/polygon-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]. */
Expand Down Expand Up @@ -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);
});
});
Loading
Loading