Skip to main content

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;