Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .changeset/exit-cap-orientation-precut-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@ifc-lite/geometry': patch
---

Stop widening every window when the authoring tool already cut the hole into the wall.

`extend_opening_mesh_through_host` pushes an opening cutter's cap past a coincident host facet so a flush interface becomes a clean transversal crossing rather than a coplanar graze (issue #1007, host #1112). It qualified a cap on coincidence alone. On a host whose Brep already carries the hole — an Archicad wall exported with "Material Preservation: Explode where necessary", where the wall body lives on aggregated `IfcBuildingElementPart` layers and every layer's Brep has its own window — the cutter's side planes coincide with the hole's jambs, so both jambs read as caps and each was pushed 30% of the opening's span into the pier beside it. Every window came out scaled by exactly 1.600 with its centre unmoved, and the piers between them were eaten; on issue #3219's model that removed 19% to 27% of each wall layer that no authored opening ever occupied.

Coincidence is now necessary but not sufficient. A cap also has to be one the opening EXITS through, decided by the area-weighted orientation of the coincident facets under the cutter's cross-section footprint: with outward winding an exit facet faces away from the host material, while a jamb faces into the hole because the host continues past it. Area weighting keeps facet scatter and stray slivers from outvoting the real surface, and the footprint restriction keeps a large coplanar facet elsewhere on a multi-body host from doing the same. Caps that genuinely exit keep the identical push, so the clearance #1007 depends on is unchanged.

Orientation is read from the host's own signed volume rather than assumed. IFC winding is not reliably outward, and the host reaching this code has not been oriented yet, so an inward-wound body would otherwise lose the clearance push AND get the pier-widening back. A host whose winding is genuinely MIXED can still mis-tally at a cap; that direction is safe (the cap is skipped, costing a rim sliver, never an over-cut) and is tracked separately.

The decision moved to its own module, `router/voids/synthesis/exit_cap.rs`, so the four conditions and their reasons sit together instead of inline in the cutter-synthesis pass.

Verified against ifcopenshell 0.8.2 and manifold3d on the reporter's model, where the correct subtraction removes zero volume because the holes are already present: the four affected wall layers go from -18.8%, -18.6%, -27.0% and -8.3% volume error to within 0.1%. Four regression tests assert removed volume or cutter extent rather than mechanism, because the existing void tests ray-cast "the wall has a hole", which is monotone in the cut and cannot catch an over-cut. They also pin the 30% clearance from both sides for the first time; it previously had no test that could fail at either extreme.

Two thin `IfcCovering` layers on the same model stay wrong for an unrelated reason (multi-opening cuts tear thin shells open, independent of this pad and unchanged by it) and are tracked separately.
94 changes: 21 additions & 73 deletions rust/geometry/src/router/voids/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

//! Opening classification, merge/extend, and cutter-mesh synthesis.

mod exit_cap;

use super::geom::*;
use super::{GeometryRouter, OpeningType, NORMALIZE_EPSILON};
use crate::{Mesh, Point3, Vector3};
Expand Down Expand Up @@ -877,78 +879,24 @@ impl GeometryRouter {
}
let d = dir / len;

// Opening span along `d`.
let (mut omn, mut omx) = (f64::INFINITY, f64::NEG_INFINITY);
for c in opening_mesh.positions.chunks_exact(3) {
let s = c[0] as f64 * d.x + c[1] as f64 * d.y + c[2] as f64 * d.z;
omn = omn.min(s);
omx = omx.max(s);
}
let open_span = (omx - omn).abs();
if open_span < NORMALIZE_EPSILON {
// Cutter extents in the penetration frame, and which caps the opening
// actually EXITS through. Growing a cap the opening does not exit
// through removes host material no authored opening occupied (#3219),
// so the decision lives behind one call in `exit_cap`.
let Some(frame) = exit_cap::CutterFrame::new(opening_mesh, d) else {
return opening_mesh.clone();
}

// FLUSH-CAP DETECTION against the host SURFACE (not its AABB): is there a
// host triangle whose plane is ~parallel to a cap (normal·d ≈ ±1) and whose
// plane the cap's projection `omn`/`omx` sits ON (within `flush_band`)? Only
// then is that cap a real flush interface to extend. This is what tells a
// #1112 roof-opening cap (flush with a roof facet that is INTERIOR to the
// host's projected extent) apart from a wall #552611 horizontal slot whose
// caps float inside the wall (no host facet there) — extending the latter
// along its authored +Z extrusion would cut the wall in half.
let flush_band = open_span.max(1.0) * 1e-3; // 0.1% of opening depth, scale-rel
let (mut cap_min_flush, mut cap_max_flush) = (false, false);
// Farthest host surface coincident with each cap, along `d` (for the push).
let (mut host_at_min, mut host_at_max) = (omn, omx);
let vat = |i: u32| {
let b = i as usize * 3;
[
host_mesh.positions[b] as f64,
host_mesh.positions[b + 1] as f64,
host_mesh.positions[b + 2] as f64,
]
};
let vc = host_mesh.positions.len() / 3;
for t in host_mesh.indices.chunks_exact(3) {
if (t[0] as usize) >= vc || (t[1] as usize) >= vc || (t[2] as usize) >= vc {
continue;
}
let (a, b, c) = (vat(t[0]), vat(t[1]), vat(t[2]));
let e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let n = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
let nl = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if nl < 1e-12 {
continue;
}
// |n·d| ≈ 1 ⇒ host facet parallel to the caps (normal along the
// penetration axis). 0.985 ≈ 10° — absorbs the ~0.1° facet scatter and
// a tilted roof's facet wobble without admitting a perpendicular wall.
let nd = (n[0] * d.x + n[1] * d.y + n[2] * d.z) / nl;
if nd.abs() < 0.985 {
continue;
}
// the facet's offset along d (any vertex; it's ~constant on the facet)
let s = a[0] * d.x + a[1] * d.y + a[2] * d.z;
if (s - omn).abs() <= flush_band {
cap_min_flush = true;
host_at_min = host_at_min.min(s);
}
if (s - omx).abs() <= flush_band {
cap_max_flush = true;
host_at_max = host_at_max.max(s);
}
let open_span = frame.span();
if open_span < NORMALIZE_EPSILON {
return opening_mesh.clone();
}
if !cap_min_flush && !cap_max_flush {
return opening_mesh.clone(); // no flush cap ⇒ a clean transversal cut
let (omn, omx) = frame.caps();
let caps = exit_cap::detect(host_mesh, &frame);
if caps.min.is_none() && caps.max.is_none() {
return opening_mesh.clone(); // no exit cap => a clean transversal cut
}

// Push each FLUSH cap a clearance margin PAST its coincident host facet, so
// Push each EXIT cap a clearance margin PAST its coincident host facet, so
// the interface becomes a transversal crossing. The margin is NOT a hairline
// pad: a near-grazing exit (cap a few µm past a TILTED faceted surface)
// re-creates a coarse T-junction at the facet seam — two rim vertices a few
Expand All @@ -965,24 +913,24 @@ impl GeometryRouter {
// chamfer), 15 % → a near-grazing 1250:1 resonance, 30–40 % → ~25:1 clean.
// 30 % is the conservative floor of that clean band; it is still small in
// absolute terms (a few cm on a ~1 m-deep opening, ~9 cm on a 0.3 m window),
// fires ONLY on a detected flush cap (a floating wall-slot cap is untouched),
// fires ONLY on a detected exit cap (a floating wall-slot cap is untouched),
// pushes INTO the host away from neighbouring elements, and stays well short
// of the engulf guard. Verified: the whole rect-opening + #1007 + #960 suite
// stays green and `issue_1007_real_opening_no_bridge`'s footprint coverage
// stays 0 (no bridge).
let pad = (open_span * 0.30).max(0.01);
let push_back = if cap_min_flush { (omn - host_at_min).max(0.0) + pad } else { 0.0 };
let push_fwd = if cap_max_flush { (host_at_max - omx).max(0.0) + pad } else { 0.0 };
// Only the flush cap ring(s) move; interior loops are untouched (band = a
let push_back = caps.push_back(omn, pad);
let push_fwd = caps.push_fwd(omx, pad);
// Only the exit cap ring(s) move; interior loops are untouched (band = a
// quarter of the opening's own depth).
let band = (open_span * 0.25).max(1e-6);
let mut out = opening_mesh.clone();
for c in out.positions.chunks_exact_mut(3) {
let p = Point3::new(c[0] as f64, c[1] as f64, c[2] as f64);
let s = p.x * d.x + p.y * d.y + p.z * d.z;
let shift = if cap_min_flush && s <= omn + band {
let shift = if caps.min.is_some() && s <= omn + band {
-push_back
} else if cap_max_flush && s >= omx - band {
} else if caps.max.is_some() && s >= omx - band {
push_fwd
} else {
0.0
Expand Down
201 changes: 201 additions & 0 deletions rust/geometry/src/router/voids/synthesis/exit_cap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// 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/.

//! Which end caps of an opening cutter may be pushed through the host.
//!
//! `extend_opening_mesh_through_host` grows a cutter so a cap that sits flush
//! against a host surface becomes a clean transversal crossing instead of a
//! coplanar graze. Growing the WRONG cap removes host material no authored
//! opening ever occupied, so the decision is isolated here, behind one call.

use super::super::geom::{mesh_point, mesh_signed_volume, project_aabb_in_frame};
use super::super::{OpeningFrame, NORMALIZE_EPSILON};
use crate::{Mesh, Point3, Vector3};

/// A facet counts as parallel to a cap at |n·d| ≥ this. 0.985 ≈ 10°: absorbs
/// the ~0.1° facet scatter and a tilted roof's wobble without admitting a
/// perpendicular wall.
const CAP_PARALLEL_COS: f64 = 0.985;

/// Coincidence and footprint-overlap tolerances, as a fraction of the extent
/// they are measured against, so a large opening gets proportionally more
/// slack than a small one. Note the `.max(1.0)` floors below: BELOW one unit
/// of extent the tolerance stops scaling and is a flat 1e-3, so the same
/// geometry authored in metres and in millimetres does NOT get the same
/// relative slack.
const BAND_FRACTION: f64 = 1.0e-3;

/// A cutter's extents in the frame of its penetration axis: `lo`/`hi` hold
/// `(u, v, d)` minima and maxima, so `.z` bounds the cutter along the axis
/// (its two caps) and `.x`/`.y` are its cross-section footprint.
pub(super) struct CutterFrame {
frame: OpeningFrame,
lo: Point3<f64>,
hi: Point3<f64>,
}

impl CutterFrame {
/// `None` when the axis will not normalize or the mesh projects to a
/// non-finite box (`project_aabb_in_frame` validates both ends: a `+inf`
/// coordinate lands only in `hi`, so checking `lo` alone would let a
/// non-finite box through, #1259).
pub fn new(mesh: &Mesh, d: Vector3<f64>) -> Option<Self> {
let frame = OpeningFrame::from_depth(d)?;
let (lo, hi) = project_aabb_in_frame(
mesh,
&[frame.cross_a, frame.cross_b, frame.depth],
Vector3::zeros(),
)?;
Some(Self { frame, lo, hi })
}

/// Unit penetration axis.
pub fn depth(&self) -> Vector3<f64> {
self.frame.depth
}

/// Cutter offsets along the axis at the min and max cap.
pub fn caps(&self) -> (f64, f64) {
(self.lo.z, self.hi.z)
}

/// Cutter depth along the axis.
pub fn span(&self) -> f64 {
self.hi.z - self.lo.z
}

/// Tolerance for "this facet lies ON a cap plane", measured along the axis.
fn cap_band(&self) -> f64 {
self.span().max(1.0) * BAND_FRACTION
}

/// Tolerance for "this facet lies UNDER the footprint", measured ACROSS the
/// axis. Kept separate from [`Self::cap_band`]: a depth-derived slack doing
/// duty as a lateral one is a different quantity wearing the same name, and
/// on these cutters the two differ by an order of magnitude (a 44 m deep,
/// 3.4 m tall window opening).
fn footprint_band(&self) -> f64 {
(self.hi.x - self.lo.x)
.max(self.hi.y - self.lo.y)
.max(1.0)
* BAND_FRACTION
}

/// Whether a facet's cross-section overlaps the cutter's. Touching counts
/// as overlapping, the conservative direction: before footprint locality
/// existed every coincident facet voted, so admitting a flush-against-the-
/// edge facet preserves that behaviour.
fn footprint_overlaps(&self, tri: &[Point3<f64>; 3]) -> bool {
let band = self.footprint_band();
let spans = |ax: &Vector3<f64>, lo: f64, hi: f64| {
let (a, b, c) = (
tri[0].coords.dot(ax),
tri[1].coords.dot(ax),
tri[2].coords.dot(ax),
);
a.min(b).min(c) <= hi + band && a.max(b).max(c) >= lo - band
};
spans(&self.frame.cross_a, self.lo.x, self.hi.x)
&& spans(&self.frame.cross_b, self.lo.y, self.hi.y)
Comment on lines +97 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Test actual projected triangle overlap

For a coplanar oblique facet, overlap of the separate u and v intervals does not imply that the projected triangle intersects the cutter rectangle; for example, a triangle can lie wholly beyond a diagonal separating line while its bounding intervals overlap both axes. Such an off-footprint facet is still included in the area-weighted facing tally, so a sufficiently large remote facet can suppress a genuine exit or falsely turn a pre-cut jamb into one. Use a real projected triangle-versus-rectangle intersection test rather than only these two marginal interval checks.

Useful? React with 👍 / 👎.

}
}

/// The host surface offset along the axis at each cap the opening EXITS
/// through. `None` means the opening does not exit there, so that cap must not
/// move. Encoding it this way makes "not an exit cap, but here is its offset"
/// unrepresentable.
pub(super) struct ExitCaps {
pub min: Option<f64>,
pub max: Option<f64>,
}

impl ExitCaps {
/// How far to push the min cap back, given the caller's clearance `pad`.
pub fn push_back(&self, omn: f64, pad: f64) -> f64 {
self.min.map_or(0.0, |h| (omn - h).max(0.0) + pad)
}

/// How far to push the max cap forward, given the caller's clearance `pad`.
pub fn push_fwd(&self, omx: f64, pad: f64) -> f64 {
self.max.map_or(0.0, |h| (h - omx).max(0.0) + pad)
}
}

/// Decide each cap against the host SURFACE (not its AABB). A cap qualifies
/// only when a host facet is ~parallel to it, sits ON its plane, lies UNDER
/// the cutter's footprint, and faces the way an exit faces. All four matter.
///
/// Parallel-and-coincident alone tells a #1112 roof-opening cap (flush with a
/// roof facet INTERIOR to the host's projected extent) apart from a wall
/// #552611 horizontal slot whose caps float inside the wall with no host facet
/// there — extending the latter along its authored +Z extrusion would halve
/// the wall.
///
/// Footprint locality keeps a multi-body host honest. A large coplanar facet
/// off to one side (a plate beside the slab being pierced) would otherwise
/// outvote the genuine cap by area and suppress a push #1007 needs.
///
/// Orientation is the #3219 case. When the authoring tool already cut the hole
/// into the host — an Archicad wall exploded into layer parts, each Brep
/// carrying its own window — the cutter's side planes coincide with the hole's
/// JAMBS. Coincidence alone reads those as caps, and the push then drives each
/// one into the pier beside it, widening every window by 30% of its own width
/// per side. With outward winding an exit facet faces AWAY from the material,
/// so `-d` at the min cap and `+d` at the max cap; a jamb faces INTO the hole,
/// the opposite sign on both, because the host continues past it. The facing is
/// area-weighted rather than boolean so facet scatter and stray slivers cannot
/// outvote the real surface.
pub(super) fn detect(host: &Mesh, f: &CutterFrame) -> ExitCaps {
let (omn, omx) = f.caps();
let (d, band) = (f.depth(), f.cap_band());
// "Faces away from the material" is only "-d/+d" when the host is wound
// OUTWARD, and IFC winding is not reliably outward: a CW profile extruded
// along +Z, or a faceted brep with inconsistent face loops, yields an
// INWARD-wound closed solid (`kernel/mesh_bridge.rs`). The host is not
// oriented until AFTER the cut (`processing/src/element.rs` runs
// `orient_mesh_outward_verdict` on the result), so read the host's own
// convention from the sign of its signed volume instead of assuming one.
// Without this an inward-wound host loses the #1007 clearance push AND
// gets the #3219 pier-eating back, both silently.
let orient = if mesh_signed_volume(host) < 0.0 { -1.0 } else { 1.0 };

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 Derive winding from the local host component

On a multi-body host whose disconnected components have opposite windings, this uses the net signed volume of every component even though the later footprint test considers only local facets. An unrelated, larger inward-wound component can therefore make orient negative for an outward-wound pre-cut wall; its jamb normals are then classified as exits and receive the 30% extension, reintroducing the geometry over-cut this change is meant to prevent. The winding convention needs to come from the connected component containing the coincident cap facets rather than the whole merged mesh.

Useful? React with 👍 / 👎.

let (mut min_facing, mut max_facing) = (0.0f64, 0.0f64);
let (mut host_at_min, mut host_at_max) = (omn, omx);
for t in host.indices.chunks_exact(3) {
let (Some(a), Some(b), Some(c)) = (
mesh_point(host, t[0]),
mesh_point(host, t[1]),
mesh_point(host, t[2]),
) else {
continue;
};
// Unnormalized normal: its length is 2·area, so `n · d` IS the
// area-weighted facing with no second normalize.
let n = (b - a).cross(&(c - a));
let nl = n.norm();
if nl < NORMALIZE_EPSILON {
continue;
}
let facing = n.dot(&d);
if (facing / nl).abs() < CAP_PARALLEL_COS {
continue;
}
if !f.footprint_overlaps(&[a, b, c]) {
continue;
}
// the facet's offset along d (any vertex; it's ~constant on the facet)
let s = a.coords.dot(&d);
if (s - omn).abs() <= band {
min_facing += facing;
host_at_min = host_at_min.min(s);
}
if (s - omx).abs() <= band {
max_facing += facing;
host_at_max = host_at_max.max(s);
}
}
ExitCaps {
min: (min_facing * orient < 0.0).then_some(host_at_min),
max: (max_facing * orient > 0.0).then_some(host_at_max),
}
}
Loading
Loading