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 pub fn sum_error_rel(&self) -> f64 {
193 if self.whole_volume.abs() <= f64::MIN_POSITIVE {
194 return 0.0;
195 }
196 let sum: f64 = self.pieces.iter().map(|p| p.volume).sum();
197 ((sum - self.whole_volume) / self.whole_volume).abs()
198 }
199}
200
201/// A piece whose volume is below this fraction of the whole is dropped.
202///
203/// The kernel can return a few slivers where two zone boxes share a boundary
204/// plane, and a piece of a nanolitre is not a piece: it is noise a user would
205/// have to identify and delete by hand. Matches the intent of the
206/// apportionment path's own negligible-share threshold, one level up.
207pub const NEGLIGIBLE_PIECE_REL: f64 = 1e-9;
208
209/// Split `host` into one solid per zone it reaches, plus the remainder.
210///
211/// `host` must be a closed orientable solid; the caller is responsible for that
212/// gate (in the viewer, the same `GeometryClosure` proof that gates a stated
213/// volume at all). Winding need not be outward: each operand is oriented before
214/// it enters the arrangement, exactly as `subtract` / `intersection` do.
215///
216/// Zones that cannot reach the host's bounds are skipped without touching a
217/// triangle, so the cost is `O(triangles x zones the element actually reaches)`.
218///
219/// Zones are expected to be pairwise non-overlapping, the same contract
220/// `subtract_many` states for its cutter group. Nothing forbids a user drawing
221/// overlapping zones, and the failure is not silent: overlapping pieces
222/// double-count, so [`ZoneSplit::sum_error_rel`] rises far above any floating
223/// point residue and the caller can refuse on it. That is the same signal the
224/// apportionment path reports as `overlapping`.
225pub fn split_mesh_by_zones(host: &[Tri], zones: &[ZoneShape]) -> ZoneSplit {
226 let host = orient_outward(host.to_vec());
227 let whole_volume = signed_volume_of(&host);
228 let negligible = whole_volume.abs() * NEGLIGIBLE_PIECE_REL;
229 let (host_lo, host_hi) = tris_aabb(&host);
230
231 let mut pieces = Vec::new();
232 let mut reached: Vec<Vec<Tri>> = Vec::new();
233 let mut remainder_failed = false;
234 for (index, zone) in zones.iter().enumerate() {
235 let (lo, hi) = zone.world_aabb();
236 if (0..3).any(|k| lo[k] > host_hi[k] || hi[k] < host_lo[k]) {
237 continue;
238 }
239 let box_tris = orient_outward(zone.to_tris());
240 let piece = boolean(&host, &box_tris, BoolOp::Intersection);
241 let volume = signed_volume_of(&piece);
242 if piece.is_empty() || volume <= negligible {
243 // NOT subtracted from the remainder. A zone the element merely
244 // abuts takes nothing, so leaving it out cannot double-count: the
245 // sliver simply stays in the remainder and the sum stays exact.
246 // Subtracting it would instead feed the arrangement an
247 // exactly-coplanar operand with no intersection to show for it,
248 // which is the kernel's hardest case for no benefit.
249 continue;
250 }
251 reached.push(box_tris);
252 pieces.push(ZonePiece { zone: Some(index), tris: piece, volume });
253 }
254
255 if !reached.is_empty() {
256 // UNIONED first, then subtracted as ONE operand.
257 //
258 // `difference_all` requires its cutters to be pairwise disjoint, and a
259 // zone set that TILES shares boundary planes: two coincident,
260 // opposite-wound cutter faces at each shared plane are classified only
261 // against the host, so both survive into the result as a zero-volume
262 // membrane inside the remainder. The volume is unaffected (the pair
263 // cancels), which is exactly why no volume-based check can see it --
264 // but the remainder is published as a closed solid, and a consumer that
265 // renders or exports it would show a phantom wall at every zone
266 // boundary. `union_all` merges the tiles first and drops each
267 // co-oriented duplicate, so the difference sees one clean operand.
268 let operands: Vec<&[Tri]> = reached.iter().map(|t| t.as_slice()).collect();
269 let (cutter, conforming) = union_all(&operands);
270 // A non-conforming union is refused BEFORE the difference sees it: the
271 // cutter would already be torn, and `difference_all`'s own conformity
272 // gate is about ITS arrangement, not about the operand it was handed.
273 // `union_many` does trust a torn union, but only because its one caller
274 // verifies the subtract that follows; nothing verifies this one, so a
275 // remainder built on it would be the piece nobody checked.
276 //
277 // Either way the failure is SAID rather than left to `sum_error_rel`,
278 // which a caller cannot tell apart from overlapping zones and which
279 // would point them at redrawing zones that are not the problem.
280 let rest = if conforming { difference_all(&host, &[&cutter]) } else { None };
281 match rest {
282 Some(rest) => {
283 let volume = signed_volume_of(&rest);
284 if !rest.is_empty() && volume > negligible {
285 pieces.push(ZonePiece { zone: None, tris: rest, volume });
286 }
287 }
288 None => remainder_failed = true,
289 }
290 } else {
291 // No zone reaches the element at all, so all of it is the remainder.
292 // Returning nothing here would state that the element vanished.
293 let volume = whole_volume;
294 pieces.push(ZonePiece { zone: None, tris: host, volume });
295 }
296
297 ZoneSplit { pieces, whole_volume, remainder_failed }
298}
299
300fn tris_aabb(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
301 let mut lo = [f64::INFINITY; 3];
302 let mut hi = [f64::NEG_INFINITY; 3];
303 for t in tris {
304 for p in t {
305 for k in 0..3 {
306 lo[k] = lo[k].min(p[k]);
307 hi[k] = hi[k].max(p[k]);
308 }
309 }
310 }
311 (lo, hi)
312}
313
314#[cfg(test)]
315#[path = "zone_split_tests.rs"]
316mod tests;