Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/layer-wall-watertight-section.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 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,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);
}
});
});
68 changes: 43 additions & 25 deletions packages/drawing-2d/src/polygon-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,51 +176,69 @@ 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;

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;
}

Expand Down
70 changes: 45 additions & 25 deletions rust/geometry/src/router/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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),
Comment thread
louistrue marked this conversation as resolved.
) {
(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() {
Expand Down
68 changes: 42 additions & 26 deletions rust/geometry/tests/material_layers_local_frame_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading