ifc_lite_geometry/zone_split.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Cut one element into one solid per location zone (issue #2508 item 2).
6//!
7//! #2515 answers "how much of this wall is in Takt A" with a number, by
8//! integrating the zone's indicator function over the element's own boundary.
9//! That is the right tool for a quantity and it produces no geometry at all:
10//! there is nothing to look at, select, or export as a per-section model. This
11//! module produces the geometry, on the explicit request of a user who asked
12//! for it.
13//!
14//! ## Why this is a kernel call and not new geometry code
15//!
16//! Clipping a solid to a convex box is an INTERSECTION against a convex
17//! operand, which is strictly easier than the general difference the exact
18//! kernel already runs on every opening void of every model. So each piece is
19//! one `boolean(host, box, Intersection)`, and the remainder is one
20//! `difference_all` against every box at once. Nothing here re-derives cutting,
21//! capping or classification.
22//!
23//! ## Two decisions worth stating
24//!
25//! - **One arrangement per piece, N+1 in total, not N composed operations.**
26//! Composing (cut by A, then cut the rest by B, ...) feeds each step the
27//! PREVIOUS step's output, so every cut re-snaps and re-jitters seams the
28//! last one made. Each piece instead arranges the ORIGINAL host against one
29//! box, and the remainder arranges the original host against all the boxes
30//! together.
31//! - **f64 from end to end.** Operands stay `Tri` (f64) for the whole split and
32//! only reach `Mesh` (f32) at the caller's boundary. A per-piece f64 -> f32 ->
33//! f64 round trip is what turns a shared zone boundary into a crack.
34
35use crate::kernel::arrangement::{boolean, box_mesh, difference_all, union_all, BoolOp, Tri};
36use crate::kernel::mesh_bridge::orient_outward;
37use crate::kernel::signed_volume::signed_volume_of;
38
39/// A zone as the viewer authors it: an oriented box that rotates about the
40/// VERTICAL axis only. Coordinates are the caller's frame; the viewer passes
41/// its Y-up render frame, and nothing here assumes which axis is up except
42/// `rotation_axis`.
43#[derive(Clone, Copy, Debug)]
44pub struct ZoneBox {
45 pub center: [f64; 3],
46 /// FULL extents along the box's own local axes, matching the viewer's
47 /// `Zone.size` (not half-extents).
48 pub size: [f64; 3],
49 /// Rotation about the vertical axis, radians.
50 pub rotation_y: f64,
51}
52
53/// What a zone actually is: v1's oriented box, or the convex prism #2508 item 4
54/// adds. Both are convex, which is what keeps each piece one intersection.
55#[derive(Clone, Debug)]
56pub enum ZoneShape {
57 Box(ZoneBox),
58 /// A vertical prism over a CONVEX footprint in the caller's X/Z plane. The
59 /// convexity is the caller's guarantee (the viewer gates it at import);
60 /// a concave polygon fans into overlapping triangles and would cut wrong.
61 Prism {
62 footprint: Vec<[f64; 2]>,
63 min_y: f64,
64 max_y: f64,
65 },
66}
67
68impl ZoneShape {
69 fn to_tris(&self) -> Vec<Tri> {
70 match self {
71 ZoneShape::Box(b) => b.to_tris(),
72 ZoneShape::Prism { footprint, min_y, max_y } => prism_tris(footprint, *min_y, *max_y),
73 }
74 }
75
76 fn world_aabb(&self) -> ([f64; 3], [f64; 3]) {
77 match self {
78 ZoneShape::Box(b) => b.world_aabb(),
79 ZoneShape::Prism { footprint, min_y, max_y } => {
80 let mut lo = [f64::INFINITY, *min_y, f64::INFINITY];
81 let mut hi = [f64::NEG_INFINITY, *max_y, f64::NEG_INFINITY];
82 for p in footprint {
83 lo[0] = lo[0].min(p[0]);
84 hi[0] = hi[0].max(p[0]);
85 lo[2] = lo[2].min(p[1]);
86 hi[2] = hi[2].max(p[1]);
87 }
88 (lo, hi)
89 }
90 }
91 }
92}
93
94/// A convex footprint extruded between two heights: a triangle fan per cap and
95/// two triangles per side. Winding is CONSISTENT rather than provably outward,
96/// which is all the kernel needs -- `orient_outward` flips the whole operand if
97/// the signed volume comes out negative.
98fn prism_tris(footprint: &[[f64; 2]], min_y: f64, max_y: f64) -> Vec<Tri> {
99 let n = footprint.len();
100 if n < 3 {
101 return Vec::new();
102 }
103 let lo = |i: usize| [footprint[i][0], min_y, footprint[i][1]];
104 let hi = |i: usize| [footprint[i][0], max_y, footprint[i][1]];
105 let mut tris = Vec::with_capacity(4 * n);
106 for i in 1..n - 1 {
107 tris.push([lo(0), lo(i + 1), lo(i)]);
108 tris.push([hi(0), hi(i), hi(i + 1)]);
109 }
110 for i in 0..n {
111 let j = (i + 1) % n;
112 tris.push([lo(i), lo(j), hi(j)]);
113 tris.push([lo(i), hi(j), hi(i)]);
114 }
115 tris
116}
117
118impl ZoneBox {
119 /// The box as an outward-wound triangle soup in world coordinates.
120 fn to_tris(self) -> Vec<Tri> {
121 let h = [self.size[0] / 2.0, self.size[1] / 2.0, self.size[2] / 2.0];
122 let local = box_mesh([-h[0], -h[1], -h[2]], [h[0], h[1], h[2]]);
123 let (sin, cos) = self.rotation_y.sin_cos();
124 local
125 .into_iter()
126 .map(|t| {
127 t.map(|p| {
128 [
129 self.center[0] + p[0] * cos - p[2] * sin,
130 self.center[1] + p[1],
131 self.center[2] + p[0] * sin + p[2] * cos,
132 ]
133 })
134 })
135 .collect()
136 }
137
138 /// World-space axis-aligned bounds, for the cheap reject below.
139 fn world_aabb(self) -> ([f64; 3], [f64; 3]) {
140 let mut lo = [f64::INFINITY; 3];
141 let mut hi = [f64::NEG_INFINITY; 3];
142 for t in self.to_tris() {
143 for p in t {
144 for k in 0..3 {
145 lo[k] = lo[k].min(p[k]);
146 hi[k] = hi[k].max(p[k]);
147 }
148 }
149 }
150 (lo, hi)
151 }
152}
153
154/// One solid produced by the split.
155#[derive(Clone, Debug)]
156pub struct ZonePiece {
157 /// Index into the `zones` slice, or `None` for the remainder (the part of
158 /// the element inside no zone).
159 pub zone: Option<usize>,
160 pub tris: Vec<Tri>,
161 /// Enclosed volume of THIS piece, from the same divergence sum the kernel
162 /// uses for a whole element.
163 pub volume: f64,
164}
165
166/// The whole split of one element.
167#[derive(Clone, Debug)]
168pub struct ZoneSplit {
169 /// Non-empty pieces only, in zone order, remainder last.
170 pub pieces: Vec<ZonePiece>,
171 /// Enclosed volume of the input, so a caller can check the pieces against
172 /// it without re-deriving it from a different producer.
173 pub whole_volume: f64,
174 /// The remainder could not be built: the arrangement was left
175 /// non-conforming and `difference_all` refused rather than risk an
176 /// over- or under-cut.
177 ///
178 /// Reported SEPARATELY from [`ZoneSplit::sum_error_rel`] because the two
179 /// have opposite fixes. A raised sum means the zones overlap and the user
180 /// should redraw them; this means the part of the element inside NO zone is
181 /// missing from the result, which a caller must refuse outright -- publishing
182 /// the zone pieces alone silently deletes real volume from the model.
183 pub remainder_failed: bool,
184}
185
186impl ZoneSplit {
187 /// How far the pieces are from summing to the whole, RELATIVE to the whole.
188 ///
189 /// The invariant #2508 puts above every other for this feature. Exposed
190 /// rather than asserted here: a caller that wants to refuse an untrustworthy
191 /// split needs the number, and a caller displaying a warning needs it too.
192 ///
193 /// `whole_volume` is never zero here: [`split_mesh_by_zones`] returns
194 /// `None` for a host that encloses no volume instead of a split whose
195 /// every signal reads perfect.
196 pub fn sum_error_rel(&self) -> f64 {
197 let sum: f64 = self.pieces.iter().map(|p| p.volume).sum();
198 ((sum - self.whole_volume) / self.whole_volume).abs()
199 }
200}
201
202/// A piece whose volume is below this fraction of the whole is dropped.
203///
204/// The kernel can return a few slivers where two zone boxes share a boundary
205/// plane, and a piece of a nanolitre is not a piece: it is noise a user would
206/// have to identify and delete by hand. Matches the intent of the
207/// apportionment path's own negligible-share threshold, one level up.
208pub const NEGLIGIBLE_PIECE_REL: f64 = 1e-9;
209
210/// A host whose enclosed volume is at most this fraction of its bounding-box
211/// diagonal cubed encloses nothing: a double-sided sheet off the axis planes
212/// sums to rounding residue (about 1e-32 for a unit sheet), not to exactly
213/// zero. A real solid is many orders above it (a 1 mm by 1 m by 1 m plate,
214/// 1e-3 m3 against a diagonal cubed of about 2.83, is about 3.5e-4).
215pub const NO_VOLUME_REL: f64 = 1e-12;
216
217/// Split `host` into one solid per zone it reaches, plus the remainder.
218///
219/// `host` must be a closed orientable solid; the caller is responsible for that
220/// gate (in the viewer, the same `GeometryClosure` proof that gates a stated
221/// volume at all). Winding need not be outward: each operand is oriented before
222/// it enters the arrangement, exactly as `subtract` / `intersection` do.
223///
224/// Zones that cannot reach the host's bounds are skipped without touching a
225/// triangle, so the cost is `O(triangles x zones the element actually reaches)`.
226///
227/// Zones are expected to be pairwise non-overlapping, the same contract
228/// `subtract_many` states for its cutter group. Nothing forbids a user drawing
229/// overlapping zones, and the failure is not silent: overlapping pieces
230/// double-count, so [`ZoneSplit::sum_error_rel`] rises far above any floating
231/// point residue and the caller can refuse on it. That is the same signal the
232/// apportionment path reports as `overlapping`.
233///
234/// Returns `None` when `host` encloses no volume: no triangles, or a
235/// degenerate shell whose divergence sum is rounding residue (see
236/// [`NO_VOLUME_REL`]). There is nothing to split, and a `ZoneSplit` for it would carry one zero-triangle "remainder", a
237/// `sum_error_rel` of zero and `remainder_failed == false`: every signal a
238/// caller gates on reading as a perfect split of nothing. The wasm entry
239/// drops malformed triangles before calling here, so an all-malformed input
240/// arrives as an empty host.
241pub fn split_mesh_by_zones(host: &[Tri], zones: &[ZoneShape]) -> Option<ZoneSplit> {
242 let host = orient_outward(host.to_vec());
243 if host.is_empty() {
244 return None;
245 }
246 let whole_volume = signed_volume_of(&host);
247 let (host_lo, host_hi) = tris_aabb(&host);
248 let diagonal = (0..3).map(|k| (host_hi[k] - host_lo[k]).powi(2)).sum::<f64>().sqrt();
249 // A NaN volume fails `<=` and falls through to `sum_error_rel` as NaN.
250 if whole_volume.abs() <= diagonal.powi(3) * NO_VOLUME_REL {
251 return None;
252 }
253 let negligible = whole_volume.abs() * NEGLIGIBLE_PIECE_REL;
254
255 let mut pieces = Vec::new();
256 let mut reached: Vec<Vec<Tri>> = Vec::new();
257 let mut remainder_failed = false;
258 for (index, zone) in zones.iter().enumerate() {
259 let (lo, hi) = zone.world_aabb();
260 if (0..3).any(|k| lo[k] > host_hi[k] || hi[k] < host_lo[k]) {
261 continue;
262 }
263 let box_tris = orient_outward(zone.to_tris());
264 let piece = boolean(&host, &box_tris, BoolOp::Intersection);
265 let volume = signed_volume_of(&piece);
266 if piece.is_empty() || volume <= negligible {
267 // NOT subtracted from the remainder. A zone the element merely
268 // abuts takes nothing, so leaving it out cannot double-count: the
269 // sliver simply stays in the remainder and the sum stays exact.
270 // Subtracting it would instead feed the arrangement an
271 // exactly-coplanar operand with no intersection to show for it,
272 // which is the kernel's hardest case for no benefit.
273 continue;
274 }
275 reached.push(box_tris);
276 pieces.push(ZonePiece { zone: Some(index), tris: piece, volume });
277 }
278
279 if !reached.is_empty() {
280 // UNIONED first, then subtracted as ONE operand.
281 //
282 // `difference_all` requires its cutters to be pairwise disjoint, and a
283 // zone set that TILES shares boundary planes: two coincident,
284 // opposite-wound cutter faces at each shared plane are classified only
285 // against the host, so both survive into the result as a zero-volume
286 // membrane inside the remainder. The volume is unaffected (the pair
287 // cancels), which is exactly why no volume-based check can see it --
288 // but the remainder is published as a closed solid, and a consumer that
289 // renders or exports it would show a phantom wall at every zone
290 // boundary. `union_all` merges the tiles first and drops each
291 // co-oriented duplicate, so the difference sees one clean operand.
292 let operands: Vec<&[Tri]> = reached.iter().map(|t| t.as_slice()).collect();
293 let (cutter, conforming) = union_all(&operands);
294 // A non-conforming union is refused BEFORE the difference sees it: the
295 // cutter would already be torn, and `difference_all`'s own conformity
296 // gate is about ITS arrangement, not about the operand it was handed.
297 // `union_many` does trust a torn union, but only because its one caller
298 // verifies the subtract that follows; nothing verifies this one, so a
299 // remainder built on it would be the piece nobody checked.
300 //
301 // Either way the failure is SAID rather than left to `sum_error_rel`,
302 // which a caller cannot tell apart from overlapping zones and which
303 // would point them at redrawing zones that are not the problem.
304 let rest = if conforming { difference_all(&host, &[&cutter]) } else { None };
305 match rest {
306 Some((rest, _changed)) => {
307 let volume = signed_volume_of(&rest);
308 if !rest.is_empty() && volume > negligible {
309 pieces.push(ZonePiece { zone: None, tris: rest, volume });
310 }
311 }
312 None => remainder_failed = true,
313 }
314 } else {
315 // No zone reaches the element at all, so all of it is the remainder.
316 // Returning nothing here would state that the element vanished.
317 let volume = whole_volume;
318 pieces.push(ZonePiece { zone: None, tris: host, volume });
319 }
320
321 Some(ZoneSplit { pieces, whole_volume, remainder_failed })
322}
323
324fn tris_aabb(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
325 let mut lo = [f64::INFINITY; 3];
326 let mut hi = [f64::NEG_INFINITY; 3];
327 for t in tris {
328 for p in t {
329 for k in 0..3 {
330 lo[k] = lo[k].min(p[k]);
331 hi[k] = hi[k].max(p[k]);
332 }
333 }
334 }
335 (lo, hi)
336}
337
338#[cfg(test)]
339#[path = "zone_split_tests.rs"]
340mod tests;