Skip to main content

symbios_shape/
query.rs

1//! Spatial query primitives over an in-progress or completed [`ShapeModel`].
2//!
3//! Currently provides oriented-bounding-box (OBB) overlap tests against the
4//! emitted terminals. Used internally by the `IfClear` / `IfOccluded` grammar
5//! ops and exposed publicly via [`ShapeModel::query`] so downstream tools can
6//! filter, count, or audit terminals after derivation.
7//!
8//! The overlap test is a true OBB-vs-OBB separating-axis-theorem (SAT) check
9//! treating each scope as a non-empty box, with thin face scopes (`size.z = 0`,
10//! `size.y = 0`, etc.) given a tiny epsilon thickness so they still produce
11//! finite separating-axis projections.
12
13use crate::model::{ShapeModel, Terminal};
14use crate::scope::{Quat, Scope, Vec3};
15
16/// Read-only spatial query view over a [`ShapeModel`].
17pub struct TerminalQuery<'a> {
18    terminals: &'a [Terminal],
19}
20
21impl<'a> TerminalQuery<'a> {
22    pub(crate) fn new(terminals: &'a [Terminal]) -> Self {
23        Self { terminals }
24    }
25
26    /// Returns `true` if any terminal's OBB overlaps the given query `scope`.
27    pub fn overlaps(&self, scope: &Scope) -> bool {
28        self.terminals
29            .iter()
30            .any(|t| scope_obb_overlaps_terminal(scope, t))
31    }
32
33    /// Returns an iterator over every terminal whose OBB overlaps `scope`.
34    pub fn overlapping(&self, scope: &'a Scope) -> impl Iterator<Item = &'a Terminal> {
35        self.terminals
36            .iter()
37            .filter(move |t| scope_obb_overlaps_terminal(scope, t))
38    }
39
40    /// Returns the underlying terminal slice (for callers that want to iterate
41    /// directly without an overlap predicate).
42    pub fn terminals(&self) -> &'a [Terminal] {
43        self.terminals
44    }
45}
46
47impl ShapeModel {
48    /// Returns a read-only query view over this model's terminals.
49    pub fn query(&self) -> TerminalQuery<'_> {
50        TerminalQuery::new(&self.terminals)
51    }
52}
53
54// ── OBB overlap (Separating Axis Theorem) ────────────────────────────────────
55
56/// Minimum half-extent assumed for any axis whose actual size is zero (face
57/// scopes, footprint scopes). Without this the SAT projections would collapse
58/// to a single point and the test would be unstable; with it, infinitely thin
59/// scopes are treated as having a 1 µm thickness for overlap purposes.
60const THIN_HALF_EXTENT: f64 = 0.5e-6;
61
62/// Pre-computed OBB representation: world-space centre, half-extents along
63/// each local axis, and the three local axes expressed in world space.
64struct Obb {
65    centre: Vec3,
66    half: Vec3,
67    axes: [Vec3; 3],
68}
69
70fn obb_from_scope(scope: &Scope) -> Obb {
71    // Half-extent guard: zero-size axes get a tiny finite thickness so the
72    // SAT projection stays well-defined.
73    let hx = (scope.size.x * 0.5).max(THIN_HALF_EXTENT);
74    let hy = (scope.size.y * 0.5).max(THIN_HALF_EXTENT);
75    let hz = (scope.size.z * 0.5).max(THIN_HALF_EXTENT);
76    let local_centre = Vec3::new(scope.size.x * 0.5, scope.size.y * 0.5, scope.size.z * 0.5);
77    let centre = scope.position + scope.rotation * local_centre;
78    let axes = [
79        scope.rotation * Vec3::X,
80        scope.rotation * Vec3::Y,
81        scope.rotation * Vec3::Z,
82    ];
83    Obb {
84        centre,
85        half: Vec3::new(hx, hy, hz),
86        axes,
87    }
88}
89
90/// Tests whether two OBBs overlap using the Separating Axis Theorem.
91///
92/// 15 axes are tested: 3 from each box (6 total) + 9 cross-products. If any
93/// axis separates the projections, the boxes do not overlap.
94///
95/// References: Christer Ericson, "Real-Time Collision Detection", §4.4.1.
96pub fn obb_overlap(a: &Scope, b: &Scope) -> bool {
97    let a = obb_from_scope(a);
98    let b = obb_from_scope(b);
99    obb_overlap_impl(&a, &b)
100}
101
102fn obb_overlap_impl(a: &Obb, b: &Obb) -> bool {
103    // 3×3 rotation matrix expressing b's axes in a's local frame, plus
104    // |abs| version for cross-axis tests. EPS guards against parallel-edge
105    // numerical jitter (per Ericson §4.4.1).
106    const EPS: f64 = 1e-9;
107    let mut r = [[0.0_f64; 3]; 3];
108    let mut abs_r = [[0.0_f64; 3]; 3];
109    for i in 0..3 {
110        for j in 0..3 {
111            r[i][j] = a.axes[i].dot(b.axes[j]);
112            abs_r[i][j] = r[i][j].abs() + EPS;
113        }
114    }
115    // Translation, expressed in a's local frame.
116    let t_world = b.centre - a.centre;
117    let t = [
118        t_world.dot(a.axes[0]),
119        t_world.dot(a.axes[1]),
120        t_world.dot(a.axes[2]),
121    ];
122    let a_h = [a.half.x, a.half.y, a.half.z];
123    let b_h = [b.half.x, b.half.y, b.half.z];
124
125    // Test the 3 axes of A.
126    for i in 0..3 {
127        let ra = a_h[i];
128        let rb = b_h[0] * abs_r[i][0] + b_h[1] * abs_r[i][1] + b_h[2] * abs_r[i][2];
129        if t[i].abs() > ra + rb {
130            return false;
131        }
132    }
133    // Test the 3 axes of B.
134    for j in 0..3 {
135        let ra = a_h[0] * abs_r[0][j] + a_h[1] * abs_r[1][j] + a_h[2] * abs_r[2][j];
136        let rb = b_h[j];
137        let proj = t[0] * r[0][j] + t[1] * r[1][j] + t[2] * r[2][j];
138        if proj.abs() > ra + rb {
139            return false;
140        }
141    }
142    // 9 cross-product axes (A_i × B_j). Each test follows the same template;
143    // unrolled for clarity.
144    macro_rules! cross_test {
145        ($i:expr, $j:expr, $ra:expr, $rb:expr, $tt:expr) => {
146            if ($tt).abs() > $ra + $rb {
147                return false;
148            }
149        };
150    }
151    // i = 0
152    cross_test!(
153        0,
154        0,
155        a_h[1] * abs_r[2][0] + a_h[2] * abs_r[1][0],
156        b_h[1] * abs_r[0][2] + b_h[2] * abs_r[0][1],
157        t[2] * r[1][0] - t[1] * r[2][0]
158    );
159    cross_test!(
160        0,
161        1,
162        a_h[1] * abs_r[2][1] + a_h[2] * abs_r[1][1],
163        b_h[0] * abs_r[0][2] + b_h[2] * abs_r[0][0],
164        t[2] * r[1][1] - t[1] * r[2][1]
165    );
166    cross_test!(
167        0,
168        2,
169        a_h[1] * abs_r[2][2] + a_h[2] * abs_r[1][2],
170        b_h[0] * abs_r[0][1] + b_h[1] * abs_r[0][0],
171        t[2] * r[1][2] - t[1] * r[2][2]
172    );
173    // i = 1
174    cross_test!(
175        1,
176        0,
177        a_h[0] * abs_r[2][0] + a_h[2] * abs_r[0][0],
178        b_h[1] * abs_r[1][2] + b_h[2] * abs_r[1][1],
179        t[0] * r[2][0] - t[2] * r[0][0]
180    );
181    cross_test!(
182        1,
183        1,
184        a_h[0] * abs_r[2][1] + a_h[2] * abs_r[0][1],
185        b_h[0] * abs_r[1][2] + b_h[2] * abs_r[1][0],
186        t[0] * r[2][1] - t[2] * r[0][1]
187    );
188    cross_test!(
189        1,
190        2,
191        a_h[0] * abs_r[2][2] + a_h[2] * abs_r[0][2],
192        b_h[0] * abs_r[1][1] + b_h[1] * abs_r[1][0],
193        t[0] * r[2][2] - t[2] * r[0][2]
194    );
195    // i = 2
196    cross_test!(
197        2,
198        0,
199        a_h[0] * abs_r[1][0] + a_h[1] * abs_r[0][0],
200        b_h[1] * abs_r[2][2] + b_h[2] * abs_r[2][1],
201        t[1] * r[0][0] - t[0] * r[1][0]
202    );
203    cross_test!(
204        2,
205        1,
206        a_h[0] * abs_r[1][1] + a_h[1] * abs_r[0][1],
207        b_h[0] * abs_r[2][2] + b_h[2] * abs_r[2][0],
208        t[1] * r[0][1] - t[0] * r[1][1]
209    );
210    cross_test!(
211        2,
212        2,
213        a_h[0] * abs_r[1][2] + a_h[1] * abs_r[0][2],
214        b_h[0] * abs_r[2][1] + b_h[1] * abs_r[2][0],
215        t[1] * r[0][2] - t[0] * r[1][2]
216    );
217    true
218}
219
220/// Convenience wrapper: tests overlap between a scope and a terminal's scope.
221/// True when every corner of `scope` lies inside `terminal`'s OBB
222/// (surface-inclusive with a tiny tolerance).
223pub(crate) fn scope_inside_terminal(scope: &Scope, terminal: &Terminal) -> bool {
224    const EPS: f64 = 1e-9;
225    let t = &terminal.scope;
226    let inv = t.rotation.inverse();
227    let half = t.size / 2.0;
228    let t_center = t.position + t.rotation * half;
229    for ix in 0..2 {
230        for iy in 0..2 {
231            for iz in 0..2 {
232                let corner_local = crate::scope::Vec3::new(
233                    ix as f64 * scope.size.x,
234                    iy as f64 * scope.size.y,
235                    iz as f64 * scope.size.z,
236                );
237                let world = scope.position + scope.rotation * corner_local;
238                let in_t = inv * (world - t_center);
239                if in_t.x.abs() > half.x + EPS
240                    || in_t.y.abs() > half.y + EPS
241                    || in_t.z.abs() > half.z + EPS
242                {
243                    return false;
244                }
245            }
246        }
247    }
248    true
249}
250
251/// True when `scope` is in surface contact with `terminal`: overlapping when
252/// grown by a hair, but not when shrunk by one.
253pub(crate) fn scope_touches_terminal(scope: &Scope, terminal: &Terminal) -> bool {
254    const HAIR: f64 = 1e-6;
255    let grown = inflate_scope(scope, HAIR);
256    let shrunk = inflate_scope(scope, -HAIR);
257    scope_obb_overlaps_terminal(&grown, terminal) && !scope_obb_overlaps_terminal(&shrunk, terminal)
258}
259
260/// Grows (or shrinks, negative `d`) a scope by `d` on every face, keeping
261/// its centre fixed. Sizes floor at zero.
262fn inflate_scope(scope: &Scope, d: f64) -> Scope {
263    let delta = crate::scope::Vec3::splat(d);
264    let new_size = (scope.size + delta * 2.0).max(crate::scope::Vec3::ZERO);
265    let shift = (scope.size - new_size) / 2.0;
266    Scope::new(
267        scope.position + scope.rotation * shift,
268        scope.rotation,
269        new_size,
270    )
271}
272
273pub(crate) fn scope_obb_overlaps_terminal(scope: &Scope, terminal: &Terminal) -> bool {
274    obb_overlap(scope, &terminal.scope)
275}
276
277// ── Snap-plane helpers ────────────────────────────────────────────────────────
278
279/// Registers the six face planes of `scope` under `label` into `out`.
280pub(crate) fn register_scope_snap_planes(
281    scope: &Scope,
282    label: &str,
283    out: &mut Vec<crate::model::SnapPlane>,
284) {
285    // For each pair of opposite faces, emit a plane at the face centre with
286    // the outward normal in world space.
287    let cx = scope.size.x * 0.5;
288    let cy = scope.size.y * 0.5;
289    let cz = scope.size.z * 0.5;
290    let local_face_centres = [
291        (Vec3::new(0.0, cy, cz), -Vec3::X),         // -X face
292        (Vec3::new(scope.size.x, cy, cz), Vec3::X), // +X face
293        (Vec3::new(cx, 0.0, cz), -Vec3::Y),         // -Y face
294        (Vec3::new(cx, scope.size.y, cz), Vec3::Y), // +Y face
295        (Vec3::new(cx, cy, 0.0), -Vec3::Z),         // -Z face
296        (Vec3::new(cx, cy, scope.size.z), Vec3::Z), // +Z face
297    ];
298    for (local_pt, local_normal) in local_face_centres {
299        let world_pt = scope.position + scope.rotation * local_pt;
300        let world_normal = (scope.rotation * local_normal).normalize();
301        out.push(crate::model::SnapPlane {
302            point: world_pt,
303            normal: world_normal,
304            label: label.to_string(),
305        });
306    }
307}
308
309/// Snaps interior `Split` boundaries to the nearest registered snap-plane
310/// along `axis` (under `label`) within `tolerance`.
311///
312/// `sizes` is the resolved per-slot length array; on return the interior
313/// positions are shifted to align with snap-planes where possible. Slot total
314/// is preserved (a snapped boundary takes width from one neighbour and gives
315/// it to the other).
316pub(crate) fn snap_split_boundaries(
317    scope: &Scope,
318    axis: crate::ops::Axis,
319    sizes: &mut [f64],
320    label: &str,
321    tolerance: f64,
322    snap_planes: &[crate::model::SnapPlane],
323) {
324    if sizes.len() < 2 || tolerance <= 0.0 || snap_planes.is_empty() {
325        return;
326    }
327    // Local axis vector & total length.
328    let (local_axis_vec, total) = match axis {
329        crate::ops::Axis::X => (Vec3::X, scope.size.x),
330        crate::ops::Axis::Y => (Vec3::Y, scope.size.y),
331        crate::ops::Axis::Z => (Vec3::Z, scope.size.z),
332    };
333    if total <= 0.0 {
334        return;
335    }
336    // World axis derived from scope.rotation.
337    let world_axis = scope.rotation * local_axis_vec;
338
339    // Collect snap-plane projections onto the split axis, in scope-local
340    // coordinates, retaining only planes whose normal is parallel-enough to
341    // the split axis (so it represents an actual perpendicular cut).
342    let mut planes_local: Vec<f64> = Vec::new();
343    let scope_local_origin = scope.position;
344    for plane in snap_planes {
345        if plane.label != label {
346            continue;
347        }
348        let parallel = plane.normal.dot(world_axis).abs();
349        if parallel < 0.9 {
350            // Plane normal not aligned with split axis — skip.
351            continue;
352        }
353        // Scope-local position along axis = (plane.point - scope.position) · world_axis.
354        let local_pos = (plane.point - scope_local_origin).dot(world_axis);
355        if local_pos < -tolerance || local_pos > total + tolerance {
356            continue;
357        }
358        planes_local.push(local_pos);
359    }
360    if planes_local.is_empty() {
361        return;
362    }
363
364    // For each interior boundary, find nearest snap-plane and adjust.
365    let n = sizes.len();
366    let mut cumulative: Vec<f64> = Vec::with_capacity(n);
367    let mut acc = 0.0;
368    for s in sizes.iter() {
369        acc += *s;
370        cumulative.push(acc);
371    }
372    // Interior boundary indices: 0..n-1 (last cumulative = total, fixed).
373    for boundary in 0..(n - 1) {
374        let pos = cumulative[boundary];
375        // Find closest snap plane within tolerance.
376        let mut best: Option<f64> = None;
377        let mut best_dist = tolerance;
378        for &p in &planes_local {
379            let d = (p - pos).abs();
380            if d <= best_dist {
381                best = Some(p);
382                best_dist = d;
383            }
384        }
385        let Some(target) = best else { continue };
386        // Don't move past adjacent boundaries (preserve ordering).
387        let lower = if boundary == 0 {
388            0.0
389        } else {
390            cumulative[boundary - 1]
391        };
392        let upper = cumulative[boundary + 1];
393        if target <= lower + 1e-9 || target >= upper - 1e-9 {
394            continue;
395        }
396        cumulative[boundary] = target;
397    }
398
399    // Re-derive sizes from cumulative.
400    let mut prev = 0.0;
401    for (i, c) in cumulative.iter().enumerate() {
402        sizes[i] = c - prev;
403        prev = *c;
404    }
405}
406
407// Suppress unused-warning when `Quat` isn't directly referenced.
408const _: fn() = || {
409    let _: Quat = Quat::IDENTITY;
410};