-
-
Notifications
You must be signed in to change notification settings - Fork 77
fix(geometry): material-layer walls emit a watertight skin, not a doubled "ghost face" #1311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ac3ceeb
fix(geometry): material-layer walls emit a watertight skin, not a dou…
louistrue 317680d
fix(drawing-2d): fill interior layers of 3+ layer walls in section (r…
louistrue 4d0294a
fix(drawing-2d): solid per-layer section cap on opening-cut walls
louistrue c167c82
feat(drawing-2d): opaque base-cap backstop so section cuts never read…
louistrue ffe5ac5
fix(renderer): draw material-layer slices double-sided so walls aren'…
louistrue File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
177 changes: 177 additions & 0 deletions
177
packages/drawing-2d/src/polygon-builder-opening.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this changeset is published, only
@ifc-lite/drawing-2dand@ifc-lite/rendererreceive new versions, but the mesh-slicing fix lives inrust/geometry/src/router/layers.rsand 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 👍 / 👎.