Skip to main content

rscad_core/feature/
geom.rs

1//! Analytic ray/membership evaluation of a [`DynamicObject`] tree.
2//!
3//! A tree is flattened into supported primitive leaves ([`GeomLeaf`], each
4//! carrying its accumulated world transform) plus a boolean expression over
5//! them ([`CsgExpr`]). Rays are intersected per leaf in local space and the
6//! resulting `t`-intervals are combined through the booleans (classic ray-CSG
7//! with normal flipping under `Difference`), so a hit identifies the *final
8//! solid's* surface: leaf, face tag, and outward normal — with no meshing.
9//!
10//! ## Conventions
11//!
12//! - Primitive anchors follow the backend contract pinned in
13//!   `rscad-csg-traits/src/dynamic.rs`: cube corner-anchored spanning
14//!   `[0, size]`, cylinder/cone base on `y = 0` extending along `+Y`, sphere
15//!   centered at the origin.
16//! - `Rotate` accumulates as **Rz·Ry·Rx** (OpenSCAD order), matching every
17//!   renderer (`DynamicObject::extract_top_transforms`, the SDF compiler,
18//!   Manifold). Note `SceneGraph::world_transform` currently uses XYZ order —
19//!   a pre-existing divergence; this module must match the pixels.
20//! - A zero-length `Mirror` axis yields empty geometry (Manifold behavior).
21//! - Bare 2D leaves in 3D context evaluate to empty (Manifold semantics).
22//! - Rays keep their (unnormalized) world parameterization in local space, so
23//!   `t` values are directly comparable across leaves.
24
25use crate::feature::{FaceTag, LeafPath};
26use crate::prelude_::*;
27
28/// `t`-interval tie/degeneracy tolerance, in world ray-parameter units.
29const T_EPS: f64 = 1.0e-9;
30/// Segments used to tessellate circular face outlines (exact under any
31/// affine transform, unlike transforming a circle's radius).
32const OUTLINE_SEGMENTS: usize = 48;
33
34/// Callback supplying a conservative **local-space** AABB for a subtree this
35/// module cannot evaluate analytically (sketch/extrude/revolve/offset based).
36/// Returning `None` drops the subtree from the model (it stops occluding).
37pub type AabbHint<'a> = &'a dyn Fn(&DynamicObject) -> Option<(DVec3, DVec3)>;
38
39/// A supported primitive in its local frame.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum LeafPrim {
42    /// Corner-anchored box spanning `[0, size]`.
43    Cube { size: DVec3 },
44    /// Base at `y = 0`, axis `+Y`. `segments` is the render tessellation —
45    /// edge-feature masks match it so boolean chords coincide.
46    Cylinder {
47        radius: f64,
48        height: f64,
49        segments: usize,
50    },
51    /// Base disk at `y = 0`, apex at `y = height`.
52    Cone {
53        radius: f64,
54        height: f64,
55        segments: usize,
56    },
57    /// Centered at the origin.
58    Sphere { radius: f64 },
59    /// Conservative stand-in for an unsupported subtree.
60    Aabb { min: DVec3, max: DVec3 },
61}
62
63/// A primitive leaf with its accumulated world transform.
64#[derive(Clone, Copy, Debug)]
65pub struct GeomLeaf {
66    pub prim: LeafPrim,
67    /// Local → world (renderer rotation convention, see module docs).
68    pub world: DAffine3,
69    /// World → local (cached inverse of `world`).
70    pub world_inv: DAffine3,
71}
72
73/// Boolean structure over leaf indices into [`CsgLeaves::leaves`].
74#[derive(Clone, Debug, PartialEq)]
75pub enum CsgExpr {
76    Leaf(usize),
77    Union(Vec<CsgExpr>),
78    Inter(Vec<CsgExpr>),
79    /// First operand minus the rest.
80    Diff(Vec<CsgExpr>),
81}
82
83/// Flattened analytic model of one tree: primitive leaves + boolean structure.
84#[derive(Clone, Debug)]
85pub struct CsgLeaves {
86    pub leaves: Vec<GeomLeaf>,
87    pub expr: CsgExpr,
88    /// Branch-path of each leaf (parallel to `leaves`), relative to the walk
89    /// root passed to [`CsgLeaves::from_dynamic`].
90    pub paths: Vec<LeafPath>,
91}
92
93/// One surface crossing along a ray.
94#[derive(Clone, Copy, Debug)]
95pub struct HitBoundary {
96    /// Ray parameter in world units (ray direction's world length preserved).
97    pub t: f64,
98    /// Index into [`CsgLeaves::leaves`].
99    pub leaf: usize,
100    pub face: FaceTag,
101    /// Surface belongs to a `Difference` subtrahend: its outward normal is
102    /// the leaf's inward normal.
103    pub flipped: bool,
104}
105
106/// A solid stretch of the ray: `enter.t < exit.t`.
107#[derive(Clone, Copy, Debug)]
108pub struct HitSpan {
109    pub enter: HitBoundary,
110    pub exit: HitBoundary,
111}
112
113impl CsgLeaves {
114    /// Flatten `obj` into leaves + boolean structure. `world` is the
115    /// transform above the walk root; `aabb_hint` supplies proxy bounds for
116    /// unsupported subtrees (see [`AabbHint`]).
117    pub fn from_dynamic(obj: &DynamicObject, world: DAffine3, aabb_hint: Option<AabbHint>) -> Self {
118        Self::build(obj, world, aabb_hint, false)
119    }
120
121    /// Like [`Self::from_dynamic`], but edge-feature masks are omitted: the
122    /// solid is the tree as if no chamfer/fillet were applied, with leaf
123    /// paths keeping the same shape (a feature's child region stays `[0]`).
124    /// Feature lowering resolves its edges against this view so a sibling
125    /// feature's cut cannot trim a stored edge short — masks then span the
126    /// full edge and overlap at shared corners instead of leaving a sliver
127    /// of the original sharp edge standing (mixed chamfer/fillet corners).
128    pub fn from_dynamic_unfeatured(
129        obj: &DynamicObject,
130        world: DAffine3,
131        aabb_hint: Option<AabbHint>,
132    ) -> Self {
133        Self::build(obj, world, aabb_hint, true)
134    }
135
136    fn build(
137        obj: &DynamicObject,
138        world: DAffine3,
139        aabb_hint: Option<AabbHint>,
140        strip_features: bool,
141    ) -> Self {
142        let mut csg = CsgLeaves {
143            leaves: Vec::new(),
144            expr: CsgExpr::Union(Vec::new()),
145            paths: Vec::new(),
146        };
147        let mut path = Vec::new();
148        csg.expr = csg.walk(obj, world, &mut path, aabb_hint, strip_features);
149        csg
150    }
151
152    /// The empty solid.
153    pub fn is_empty(&self) -> bool {
154        self.leaves.is_empty()
155    }
156
157    fn push_leaf(&mut self, prim: LeafPrim, world: DAffine3, path: &[u16]) -> CsgExpr {
158        let idx = self.leaves.len();
159        self.leaves.push(GeomLeaf {
160            prim,
161            world,
162            world_inv: world.inverse(),
163        });
164        self.paths.push(LeafPath(path.to_vec()));
165        CsgExpr::Leaf(idx)
166    }
167
168    fn walk_children(
169        &mut self,
170        children: &[DynamicObject],
171        world: DAffine3,
172        path: &mut Vec<u16>,
173        hint: Option<AabbHint>,
174        strip: bool,
175    ) -> Vec<CsgExpr> {
176        let branching = children.len() > 1;
177        children
178            .iter()
179            .enumerate()
180            .map(|(i, child)| {
181                if branching {
182                    path.push(i as u16);
183                }
184                let expr = self.walk(child, world, path, hint, strip);
185                if branching {
186                    path.pop();
187                }
188                expr
189            })
190            .collect()
191    }
192
193    fn walk(
194        &mut self,
195        obj: &DynamicObject,
196        world: DAffine3,
197        path: &mut Vec<u16>,
198        hint: Option<AabbHint>,
199        strip: bool,
200    ) -> CsgExpr {
201        let empty = || CsgExpr::Union(Vec::new());
202        match obj {
203            DynamicObject::Cube { size } => self.push_leaf(
204                LeafPrim::Cube {
205                    size: DVec3::from_array(*size),
206                },
207                world,
208                path,
209            ),
210            DynamicObject::Sphere { radius, .. } => {
211                self.push_leaf(LeafPrim::Sphere { radius: *radius }, world, path)
212            }
213            DynamicObject::Cylinder {
214                radius,
215                height,
216                segments,
217            } => self.push_leaf(
218                LeafPrim::Cylinder {
219                    radius: *radius,
220                    height: *height,
221                    segments: *segments,
222                },
223                world,
224                path,
225            ),
226            DynamicObject::Cone {
227                radius,
228                height,
229                segments,
230            } => self.push_leaf(
231                LeafPrim::Cone {
232                    radius: *radius,
233                    height: *height,
234                    segments: *segments,
235                },
236                world,
237                path,
238            ),
239
240            // Bare 2D geometry in 3D context is empty (Manifold semantics).
241            DynamicObject::Circle { .. }
242            | DynamicObject::Square { .. }
243            | DynamicObject::Polygon { .. }
244            | DynamicObject::Sketch { .. }
245            | DynamicObject::Empty => empty(),
246
247            // Unsupported solid subtrees become conservative AABB proxies so
248            // they keep occluding picks; without a hint they vanish.
249            DynamicObject::LinearExtrude { .. }
250            | DynamicObject::Revolve { .. }
251            | DynamicObject::Offset { .. } => match hint.and_then(|f| f(obj)) {
252                Some((min, max)) => self.push_leaf(LeafPrim::Aabb { min, max }, world, path),
253                None => empty(),
254            },
255
256            // Edge features evaluate through their lowered masks, with fixed
257            // path indices (0 = child, 1 = subtractive masks, 2 = additive
258            // masks) so graph-side path replay stays aligned whether or not
259            // any mask resolved. Mask leaves all map to the feature node
260            // itself. Masks walk without an AABB hint: Revolve-based fillet
261            // rings must vanish here (like the SDF sentinel), not become
262            // proxy boxes that would poison picking around the rim. In the
263            // unfeatured view (`strip`) the masks are omitted entirely — the
264            // child region keeps its `[0]` path so stored paths still match.
265            DynamicObject::EdgeFeature { data, child } => {
266                if strip {
267                    path.push(0);
268                    let child_expr = self.walk(child, world, path, hint, strip);
269                    path.pop();
270                    return child_expr;
271                }
272                let (masks, _) = crate::feature::lower::lower_masks(data, child);
273                path.push(0);
274                let child_expr = self.walk(child, world, path, hint, strip);
275                path.pop();
276                path.push(1);
277                let sub_exprs = self.walk_children(&masks.sub, world, path, None, strip);
278                path.pop();
279                path.push(2);
280                let add_exprs = self.walk_children(&masks.add, world, path, None, strip);
281                path.pop();
282                let cut = CsgExpr::Diff(vec![child_expr, CsgExpr::Union(sub_exprs)]);
283                CsgExpr::Union(core::iter::once(cut).chain(add_exprs).collect())
284            }
285
286            DynamicObject::Union { children } | DynamicObject::Group { children } => {
287                CsgExpr::Union(self.walk_children(children, world, path, hint, strip))
288            }
289            DynamicObject::Intersection { children } => {
290                CsgExpr::Inter(self.walk_children(children, world, path, hint, strip))
291            }
292            DynamicObject::Difference { children } => {
293                CsgExpr::Diff(self.walk_children(children, world, path, hint, strip))
294            }
295
296            DynamicObject::Translate { offset, child } => {
297                let world = world * DAffine3::from_translation(DVec3::from_array(*offset));
298                self.walk(child, world, path, hint, strip)
299            }
300            DynamicObject::Rotate { angles, child } => {
301                // Rz·Ry·Rx — OpenSCAD order, matching every renderer.
302                let q = DQuat::from_euler(
303                    EulerRot::ZYX,
304                    angles[2].to_radians(),
305                    angles[1].to_radians(),
306                    angles[0].to_radians(),
307                );
308                self.walk(child, world * DAffine3::from_quat(q), path, hint, strip)
309            }
310            DynamicObject::Scale { factors, child } => {
311                let world = world * DAffine3::from_scale(DVec3::from_array(*factors));
312                self.walk(child, world, path, hint, strip)
313            }
314            DynamicObject::Mirror { axis, child } => {
315                let axis = DVec3::from_array(*axis);
316                let len_sq = axis.length_squared();
317                if len_sq < 1.0e-24 {
318                    return empty(); // Manifold: zero mirror normal → empty
319                }
320                let n = axis / len_sq.sqrt();
321                let m = DMat3::IDENTITY - 2.0 * DMat3::from_cols(n * n.x, n * n.y, n * n.z);
322                self.walk(child, world * DAffine3::from_mat3(m), path, hint, strip)
323            }
324            DynamicObject::Color { child, .. } => self.walk(child, world, path, hint, strip),
325            DynamicObject::Material { child, .. } => self.walk(child, world, path, hint, strip),
326        }
327    }
328
329    /// All solid stretches of the ray through the final solid, sorted by `t`.
330    /// `rd` need not be normalized — `t` stays in `ro + t·rd` units.
331    pub fn spans(&self, ro: DVec3, rd: DVec3) -> Vec<HitSpan> {
332        self.expr_spans(&self.expr, ro, rd)
333    }
334
335    /// First surface crossing with `t > t_min` (typically `0.0`).
336    pub fn first_hit(&self, ro: DVec3, rd: DVec3, t_min: f64) -> Option<HitBoundary> {
337        self.spans(ro, rd)
338            .into_iter()
339            .flat_map(|s| [s.enter, s.exit])
340            .find(|b| b.t > t_min)
341    }
342
343    fn expr_spans(&self, expr: &CsgExpr, ro: DVec3, rd: DVec3) -> Vec<HitSpan> {
344        match expr {
345            CsgExpr::Leaf(idx) => leaf_spans(*idx, &self.leaves[*idx], ro, rd),
346            CsgExpr::Union(children) => children
347                .iter()
348                .map(|c| self.expr_spans(c, ro, rd))
349                .reduce(|a, b| merge_spans(a, b, BoolOp::Union))
350                .unwrap_or_default(),
351            CsgExpr::Inter(children) => {
352                let mut iter = children.iter().map(|c| self.expr_spans(c, ro, rd));
353                let Some(first) = iter.next() else {
354                    return Vec::new();
355                };
356                iter.fold(first, |a, b| merge_spans(a, b, BoolOp::Inter))
357            }
358            CsgExpr::Diff(children) => {
359                let mut iter = children.iter().map(|c| self.expr_spans(c, ro, rd));
360                let Some(first) = iter.next() else {
361                    return Vec::new();
362                };
363                iter.fold(first, |a, b| merge_spans(a, b, BoolOp::Diff))
364            }
365        }
366    }
367
368    /// Whether the final solid contains `p` (closed: boundary counts as inside).
369    pub fn contains(&self, p: DVec3) -> bool {
370        self.expr_contains(&self.expr, p)
371    }
372
373    fn expr_contains(&self, expr: &CsgExpr, p: DVec3) -> bool {
374        match expr {
375            CsgExpr::Leaf(idx) => leaf_contains(&self.leaves[*idx], p),
376            CsgExpr::Union(children) => children.iter().any(|c| self.expr_contains(c, p)),
377            CsgExpr::Inter(children) => {
378                !children.is_empty() && children.iter().all(|c| self.expr_contains(c, p))
379            }
380            CsgExpr::Diff(children) => match children.split_first() {
381                None => false,
382                Some((first, rest)) => {
383                    self.expr_contains(first, p) && !rest.iter().any(|c| self.expr_contains(c, p))
384                }
385            },
386        }
387    }
388
389    /// Outward normal of the **final solid** at a boundary returned by
390    /// [`Self::spans`]/[`Self::first_hit`] (unit length, world space).
391    pub fn boundary_normal(&self, boundary: &HitBoundary, ro: DVec3, rd: DVec3) -> DVec3 {
392        let leaf = &self.leaves[boundary.leaf];
393        let p_local = leaf.world_inv.transform_point3(ro + boundary.t * rd);
394        let n_local = local_face_normal(&leaf.prim, boundary.face, p_local);
395        // Gradient chain rule: normals map by the inverse-transpose.
396        let n_world = (leaf.world_inv.matrix3.transpose() * n_local).normalize_or_zero();
397        if boundary.flipped { -n_world } else { n_world }
398    }
399}
400
401// ── Per-leaf ray intersection ────────────────────────────────────
402
403/// Intersect the ray with one leaf in its local frame. Returns 0 or 1 span
404/// (every supported primitive is convex).
405fn leaf_spans(idx: usize, leaf: &GeomLeaf, ro: DVec3, rd: DVec3) -> Vec<HitSpan> {
406    let ro_l = leaf.world_inv.transform_point3(ro);
407    let rd_l = leaf.world_inv.matrix3 * rd;
408    let boundary = |t: f64, face: FaceTag| HitBoundary {
409        t,
410        leaf: idx,
411        face,
412        flipped: false,
413    };
414    let interval = match leaf.prim {
415        LeafPrim::Cube { size } => slab_interval(ro_l, rd_l, DVec3::ZERO, size, false),
416        LeafPrim::Aabb { min, max } => slab_interval(ro_l, rd_l, min, max, true),
417        LeafPrim::Sphere { radius } => sphere_interval(ro_l, rd_l, radius),
418        LeafPrim::Cylinder { radius, height, .. } => cylinder_interval(ro_l, rd_l, radius, height),
419        LeafPrim::Cone { radius, height, .. } => cone_interval(ro_l, rd_l, radius, height),
420    };
421    match interval {
422        Some(((t0, f0), (t1, f1))) if t1 - t0 > T_EPS => vec![HitSpan {
423            enter: boundary(t0, f0),
424            exit: boundary(t1, f1),
425        }],
426        _ => Vec::new(),
427    }
428}
429
430type FaceInterval = Option<((f64, FaceTag), (f64, FaceTag))>;
431
432/// Slab test against `[min, max]`, tagging each end with the binding axis
433/// face (or `Unknown` for AABB proxies).
434fn slab_interval(ro: DVec3, rd: DVec3, min: DVec3, max: DVec3, proxy: bool) -> FaceInterval {
435    let mut t_in = (f64::NEG_INFINITY, FaceTag::Unknown);
436    let mut t_out = (f64::INFINITY, FaceTag::Unknown);
437    for axis in 0..3 {
438        let (o, d) = (ro[axis], rd[axis]);
439        if d == 0.0 {
440            if o < min[axis] || o > max[axis] {
441                return None;
442            }
443            continue;
444        }
445        let t_min_face = (min[axis] - o) / d;
446        let t_max_face = (max[axis] - o) / d;
447        // Moving along +d crosses the min (negative) face first; along −d the
448        // max (positive) face first.
449        let (near, far) = if d > 0.0 {
450            (
451                (t_min_face, tag_for(axis, false, proxy)),
452                (t_max_face, tag_for(axis, true, proxy)),
453            )
454        } else {
455            (
456                (t_max_face, tag_for(axis, true, proxy)),
457                (t_min_face, tag_for(axis, false, proxy)),
458            )
459        };
460        if near.0 > t_in.0 {
461            t_in = near;
462        }
463        if far.0 < t_out.0 {
464            t_out = far;
465        }
466    }
467    (t_in.0 < t_out.0).then_some((t_in, t_out))
468}
469
470fn tag_for(axis: usize, positive: bool, proxy: bool) -> FaceTag {
471    if proxy {
472        FaceTag::Unknown
473    } else {
474        FaceTag::cube(axis, positive)
475    }
476}
477
478fn sphere_interval(ro: DVec3, rd: DVec3, radius: f64) -> FaceInterval {
479    let a = rd.dot(rd);
480    if a == 0.0 {
481        return None;
482    }
483    let b = 2.0 * ro.dot(rd);
484    let c = ro.dot(ro) - radius * radius;
485    let disc = b * b - 4.0 * a * c;
486    if disc <= 0.0 {
487        return None;
488    }
489    let sq = disc.sqrt();
490    let (t0, t1) = ((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a));
491    Some(((t0.min(t1), FaceTag::Sphere), (t0.max(t1), FaceTag::Sphere)))
492}
493
494fn cylinder_interval(ro: DVec3, rd: DVec3, radius: f64, height: f64) -> FaceInterval {
495    // Radial constraint in the XZ plane.
496    let a = rd.x * rd.x + rd.z * rd.z;
497    let radial: FaceInterval = if a == 0.0 {
498        (ro.x * ro.x + ro.z * ro.z <= radius * radius).then_some((
499            (f64::NEG_INFINITY, FaceTag::CylSide),
500            (f64::INFINITY, FaceTag::CylSide),
501        ))
502    } else {
503        let b = 2.0 * (ro.x * rd.x + ro.z * rd.z);
504        let c = ro.x * ro.x + ro.z * ro.z - radius * radius;
505        let disc = b * b - 4.0 * a * c;
506        (disc > 0.0).then(|| {
507            let sq = disc.sqrt();
508            let (t0, t1) = ((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a));
509            (
510                (t0.min(t1), FaceTag::CylSide),
511                (t0.max(t1), FaceTag::CylSide),
512            )
513        })
514    };
515    let radial = radial?;
516    // Axial slab [0, height]: entering upward crosses the bottom cap.
517    let axial: FaceInterval = if rd.y == 0.0 {
518        (0.0..=height).contains(&ro.y).then_some((
519            (f64::NEG_INFINITY, FaceTag::CylBottom),
520            (f64::INFINITY, FaceTag::CylTop),
521        ))
522    } else {
523        let ta = (0.0 - ro.y) / rd.y;
524        let tb = (height - ro.y) / rd.y;
525        Some(if rd.y > 0.0 {
526            ((ta, FaceTag::CylBottom), (tb, FaceTag::CylTop))
527        } else {
528            ((tb, FaceTag::CylTop), (ta, FaceTag::CylBottom))
529        })
530    };
531    let axial = axial?;
532    let t_in = if radial.0.0 > axial.0.0 {
533        radial.0
534    } else {
535        axial.0
536    };
537    let t_out = if radial.1.0 < axial.1.0 {
538        radial.1
539    } else {
540        axial.1
541    };
542    (t_in.0 < t_out.0).then_some((t_in, t_out))
543}
544
545/// Cone solid `{ √(x²+z²) ≤ k·(h−y), 0 ≤ y ≤ h }`, `k = r/h`. Convex, so the
546/// interval is found from boundary-crossing candidates + midpoint membership
547/// (robust against the double-cone sheet and apex degeneracies).
548fn cone_interval(ro: DVec3, rd: DVec3, radius: f64, height: f64) -> FaceInterval {
549    if height <= 0.0 || radius <= 0.0 {
550        return None;
551    }
552    let k = radius / height;
553    let inside = |t: f64| -> bool {
554        let p = ro + t * rd;
555        if p.y < 0.0 || p.y > height {
556            return false;
557        }
558        (p.x * p.x + p.z * p.z).sqrt() <= k * (height - p.y)
559    };
560
561    let mut candidates: Vec<(f64, FaceTag)> = Vec::with_capacity(4);
562    // Sheet crossings: x² + z² = k²(h − y)².
563    let a = rd.x * rd.x + rd.z * rd.z - k * k * rd.y * rd.y;
564    let b = 2.0 * (ro.x * rd.x + ro.z * rd.z + k * k * rd.y * (height - ro.y));
565    let c = ro.x * ro.x + ro.z * ro.z - k * k * (height - ro.y) * (height - ro.y);
566    let mut sheet = |t: f64| {
567        let y = ro.y + t * rd.y;
568        if (-T_EPS..=height + T_EPS).contains(&y) {
569            candidates.push((t, FaceTag::ConeSide));
570        }
571    };
572    if a.abs() > 1.0e-14 {
573        let disc = b * b - 4.0 * a * c;
574        if disc > 0.0 {
575            let sq = disc.sqrt();
576            sheet((-b - sq) / (2.0 * a));
577            sheet((-b + sq) / (2.0 * a));
578        }
579    } else if b.abs() > 1.0e-14 {
580        sheet(-c / b);
581    }
582    // Base-plane crossing y = 0, inside the base disk.
583    if rd.y != 0.0 {
584        let t = -ro.y / rd.y;
585        let p = ro + t * rd;
586        if p.x * p.x + p.z * p.z <= radius * radius + T_EPS {
587            candidates.push((t, FaceTag::ConeBottom));
588        }
589    }
590    candidates.sort_by(|x, y| x.0.total_cmp(&y.0));
591    candidates.dedup_by(|x, y| (x.0 - y.0).abs() < T_EPS);
592    // Convex solid: the inside stretch is bounded by consecutive candidates.
593    candidates
594        .windows(2)
595        .find(|w| inside(0.5 * (w[0].0 + w[1].0)))
596        .map(|w| (w[0], w[1]))
597}
598
599// ── Per-leaf membership ──────────────────────────────────────────
600
601fn leaf_contains(leaf: &GeomLeaf, p: DVec3) -> bool {
602    let p = leaf.world_inv.transform_point3(p);
603    match leaf.prim {
604        LeafPrim::Cube { size } => {
605            (0.0..=size.x).contains(&p.x)
606                && (0.0..=size.y).contains(&p.y)
607                && (0.0..=size.z).contains(&p.z)
608        }
609        LeafPrim::Aabb { min, max } => {
610            (min.x..=max.x).contains(&p.x)
611                && (min.y..=max.y).contains(&p.y)
612                && (min.z..=max.z).contains(&p.z)
613        }
614        LeafPrim::Sphere { radius } => p.length_squared() <= radius * radius,
615        LeafPrim::Cylinder { radius, height, .. } => {
616            (0.0..=height).contains(&p.y) && p.x * p.x + p.z * p.z <= radius * radius
617        }
618        LeafPrim::Cone { radius, height, .. } => {
619            if height <= 0.0 {
620                return false;
621            }
622            let k = radius / height;
623            (0.0..=height).contains(&p.y) && (p.x * p.x + p.z * p.z).sqrt() <= k * (height - p.y)
624        }
625    }
626}
627
628// ── Normals ──────────────────────────────────────────────────────
629
630/// Leaf-local outward normal of `face` at local point `p` (on that face).
631fn local_face_normal(prim: &LeafPrim, face: FaceTag, p: DVec3) -> DVec3 {
632    match face {
633        FaceTag::CubePosX => DVec3::X,
634        FaceTag::CubeNegX => -DVec3::X,
635        FaceTag::CubePosY | FaceTag::CylTop => DVec3::Y,
636        FaceTag::CubeNegY | FaceTag::CylBottom | FaceTag::ConeBottom => -DVec3::Y,
637        FaceTag::CubePosZ => DVec3::Z,
638        FaceTag::CubeNegZ => -DVec3::Z,
639        FaceTag::CylSide => DVec3::new(p.x, 0.0, p.z).normalize_or_zero(),
640        FaceTag::ConeSide => {
641            let LeafPrim::Cone { radius, height, .. } = prim else {
642                return DVec3::Y;
643            };
644            let rho = (p.x * p.x + p.z * p.z).sqrt();
645            if rho < 1.0e-12 {
646                return DVec3::Y; // apex: any outward direction; pick the axis
647            }
648            // Gradient of √(x²+z²) − k(h−y):  (x/ρ, k, z/ρ).
649            DVec3::new(p.x / rho, radius / height, p.z / rho).normalize()
650        }
651        FaceTag::Sphere => p.normalize_or_zero(),
652        FaceTag::Unknown => aabb_proxy_normal(prim, p),
653    }
654}
655
656/// Nearest-plane normal for AABB proxy faces (best effort — proxies are
657/// never selectable, this only keeps hover math finite).
658fn aabb_proxy_normal(prim: &LeafPrim, p: DVec3) -> DVec3 {
659    let LeafPrim::Aabb { min, max } = prim else {
660        return DVec3::Y;
661    };
662    let mut best = (f64::INFINITY, DVec3::Y);
663    for axis in 0..3 {
664        let mut n = DVec3::ZERO;
665        n[axis] = -1.0;
666        if (p[axis] - min[axis]).abs() < best.0 {
667            best = ((p[axis] - min[axis]).abs(), n);
668        }
669        if (max[axis] - p[axis]).abs() < best.0 {
670            best = ((max[axis] - p[axis]).abs(), -n);
671        }
672    }
673    best.1
674}
675
676// ── Interval booleans ────────────────────────────────────────────
677
678#[derive(Clone, Copy, PartialEq)]
679enum BoolOp {
680    Union,
681    Inter,
682    Diff,
683}
684
685/// Combine two disjoint sorted span lists. Difference events from `b` flip
686/// their boundary orientation (subtrahend exit becomes result entry).
687fn merge_spans(a: Vec<HitSpan>, b: Vec<HitSpan>, op: BoolOp) -> Vec<HitSpan> {
688    // Fast paths.
689    match op {
690        BoolOp::Union if b.is_empty() => return a,
691        BoolOp::Union if a.is_empty() => return b,
692        BoolOp::Inter if a.is_empty() || b.is_empty() => return Vec::new(),
693        BoolOp::Diff if a.is_empty() || b.is_empty() => return a,
694        _ => {}
695    }
696
697    struct Event {
698        t: f64,
699        from_b: bool,
700        is_enter: bool,
701        boundary: HitBoundary,
702    }
703    let mut events: Vec<Event> = Vec::with_capacity(2 * (a.len() + b.len()));
704    for (spans, from_b) in [(&a, false), (&b, true)] {
705        for s in spans.iter() {
706            events.push(Event {
707                t: s.enter.t,
708                from_b,
709                is_enter: true,
710                boundary: s.enter,
711            });
712            events.push(Event {
713                t: s.exit.t,
714                from_b,
715                is_enter: false,
716                boundary: s.exit,
717            });
718        }
719    }
720    events.sort_by(|x, y| x.t.total_cmp(&y.t));
721
722    let combined = |ia: bool, ib: bool| match op {
723        BoolOp::Union => ia || ib,
724        BoolOp::Inter => ia && ib,
725        BoolOp::Diff => ia && !ib,
726    };
727    let mut inside_a = false;
728    let mut inside_b = false;
729    let mut inside = false;
730    let mut open: Option<HitBoundary> = None;
731    let mut out: Vec<HitSpan> = Vec::new();
732    for ev in events {
733        if ev.from_b {
734            inside_b = ev.is_enter;
735        } else {
736            inside_a = ev.is_enter;
737        }
738        let now = combined(inside_a, inside_b);
739        if now == inside {
740            continue;
741        }
742        inside = now;
743        let mut boundary = ev.boundary;
744        if op == BoolOp::Diff && ev.from_b {
745            boundary.flipped = !boundary.flipped;
746        }
747        if now {
748            open = Some(boundary);
749        } else if let Some(enter) = open.take() {
750            out.push(HitSpan {
751                enter,
752                exit: boundary,
753            });
754        }
755    }
756    // Normalize: drop degenerate slivers, join touching spans (their shared
757    // boundary is interior to the combined solid, not a real surface).
758    let mut norm: Vec<HitSpan> = Vec::with_capacity(out.len());
759    for span in out {
760        if span.exit.t - span.enter.t <= T_EPS {
761            continue;
762        }
763        match norm.last_mut() {
764            Some(prev) if span.enter.t - prev.exit.t <= T_EPS => prev.exit = span.exit,
765            _ => norm.push(span),
766        }
767    }
768    norm
769}
770
771// ── Face outlines (for viewport highlights) ──────────────────────
772
773impl GeomLeaf {
774    /// Closed world-space polylines outlining `face`. Circles are tessellated
775    /// locally and transformed, so non-uniform scale renders the true ellipse.
776    /// Spheres and proxies return no outline.
777    pub fn face_outline(&self, face: FaceTag) -> Vec<Vec<DVec3>> {
778        let circle = |y: f64, r: f64| -> Vec<DVec3> {
779            (0..OUTLINE_SEGMENTS)
780                .map(|i| {
781                    let a = core::f64::consts::TAU * i as f64 / OUTLINE_SEGMENTS as f64;
782                    self.world
783                        .transform_point3(DVec3::new(r * a.cos(), y, r * a.sin()))
784                })
785                .collect()
786        };
787        match (&self.prim, face) {
788            (LeafPrim::Cube { size }, tag) => {
789                let (axis, positive) = match tag {
790                    FaceTag::CubePosX => (0, true),
791                    FaceTag::CubeNegX => (0, false),
792                    FaceTag::CubePosY => (1, true),
793                    FaceTag::CubeNegY => (1, false),
794                    FaceTag::CubePosZ => (2, true),
795                    FaceTag::CubeNegZ => (2, false),
796                    _ => return Vec::new(),
797                };
798                let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
799                let corners = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
800                let quad = corners
801                    .iter()
802                    .map(|&(cu, cv)| {
803                        let mut p = DVec3::ZERO;
804                        p[axis] = if positive { size[axis] } else { 0.0 };
805                        p[u] = cu * size[u];
806                        p[v] = cv * size[v];
807                        self.world.transform_point3(p)
808                    })
809                    .collect();
810                vec![quad]
811            }
812            (LeafPrim::Cylinder { radius, height, .. }, FaceTag::CylTop) => {
813                vec![circle(*height, *radius)]
814            }
815            (LeafPrim::Cylinder { radius, .. }, FaceTag::CylBottom) => vec![circle(0.0, *radius)],
816            (LeafPrim::Cylinder { radius, height, .. }, FaceTag::CylSide) => {
817                vec![circle(0.0, *radius), circle(*height, *radius)]
818            }
819            (LeafPrim::Cone { radius, .. }, FaceTag::ConeBottom | FaceTag::ConeSide) => {
820                vec![circle(0.0, *radius)]
821            }
822            _ => Vec::new(),
823        }
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    fn cube(size: [f64; 3]) -> DynamicObject {
832        DynamicObject::Cube { size }
833    }
834
835    fn unit_ray_x() -> (DVec3, DVec3) {
836        (DVec3::new(-10.0, 0.5, 0.5), DVec3::X)
837    }
838
839    fn leaves(obj: &DynamicObject) -> CsgLeaves {
840        CsgLeaves::from_dynamic(obj, DAffine3::IDENTITY, None)
841    }
842
843    #[test]
844    fn cube_axis_ray_hits_neg_then_pos_x() {
845        let csg = leaves(&cube([1.0, 1.0, 1.0]));
846        let spans = csg.spans(unit_ray_x().0, unit_ray_x().1);
847        assert_eq!(spans.len(), 1);
848        let s = spans[0];
849        assert!((s.enter.t - 10.0).abs() < 1e-12);
850        assert!((s.exit.t - 11.0).abs() < 1e-12);
851        assert_eq!(s.enter.face, FaceTag::CubeNegX);
852        assert_eq!(s.exit.face, FaceTag::CubePosX);
853        let n = csg.boundary_normal(&s.enter, unit_ray_x().0, unit_ray_x().1);
854        assert!((n - (-DVec3::X)).length() < 1e-12);
855    }
856
857    #[test]
858    fn cube_miss_and_axis_parallel_guard() {
859        let csg = leaves(&cube([1.0, 1.0, 1.0]));
860        // Parallel to X at y outside the slab.
861        assert!(csg.spans(DVec3::new(-10.0, 2.0, 0.5), DVec3::X).is_empty());
862        // Ray with a zero component inside the slab still hits.
863        assert_eq!(csg.spans(DVec3::new(0.5, 0.5, -5.0), DVec3::Z).len(), 1);
864    }
865
866    #[test]
867    fn translated_rotated_cube_matches_renderer_convention() {
868        // rotate([0,0,90]) then translate([2,0,0]): +X face maps to +Y side.
869        let obj = DynamicObject::Translate {
870            offset: [2.0, 0.0, 0.0],
871            child: Box::new(DynamicObject::Rotate {
872                angles: [0.0, 0.0, 90.0],
873                child: Box::new(cube([1.0, 2.0, 3.0])),
874            }),
875        };
876        let csg = leaves(&obj);
877        // Rz(90°): local +X → world +Y. Cube occupies x∈[0,-2]? No:
878        // corner-anchored [0,1]×[0,2]×[0,3] rotated by Rz90 → x∈[-2,0], y∈[0,1],
879        // then translated → x∈[0,2] shifted: x∈[2-2,2]=[0,2], y∈[0,1], z∈[0,3].
880        let ro = DVec3::new(1.0, 10.0, 1.5);
881        let spans = csg.spans(ro, -DVec3::Y);
882        assert_eq!(spans.len(), 1);
883        // Entering downward crosses the face whose world normal is +Y: the
884        // rotated local +X face.
885        assert_eq!(spans[0].enter.face, FaceTag::CubePosX);
886        let n = csg.boundary_normal(&spans[0].enter, ro, -DVec3::Y);
887        assert!((n - DVec3::Y).length() < 1e-9);
888    }
889
890    #[test]
891    fn non_uniform_scale_keeps_world_t() {
892        let obj = DynamicObject::Scale {
893            factors: [2.0, 3.0, 4.0],
894            child: Box::new(cube([1.0, 1.0, 1.0])),
895        };
896        let csg = leaves(&obj);
897        let (ro, rd) = (DVec3::new(-10.0, 1.0, 1.0), DVec3::X);
898        let spans = csg.spans(ro, rd);
899        assert_eq!(spans.len(), 1);
900        assert!((spans[0].enter.t - 10.0).abs() < 1e-12);
901        assert!(
902            (spans[0].exit.t - 12.0).abs() < 1e-12,
903            "scaled span is 2 long"
904        );
905    }
906
907    #[test]
908    fn mirror_flips_geometry_not_normals_outwardness() {
909        let obj = DynamicObject::Mirror {
910            axis: [1.0, 0.0, 0.0],
911            child: Box::new(cube([1.0, 1.0, 1.0])),
912        };
913        let csg = leaves(&obj);
914        // Mirrored cube occupies x ∈ [-1, 0].
915        let (ro, rd) = (DVec3::new(-10.0, 0.5, 0.5), DVec3::X);
916        let spans = csg.spans(ro, rd);
917        assert_eq!(spans.len(), 1);
918        assert!((spans[0].enter.t - 9.0).abs() < 1e-12);
919        let n = csg.boundary_normal(&spans[0].enter, ro, rd);
920        assert!(
921            (n - (-DVec3::X)).length() < 1e-12,
922            "outward normal faces the ray"
923        );
924    }
925
926    #[test]
927    fn zero_mirror_axis_is_empty() {
928        let obj = DynamicObject::Mirror {
929            axis: [0.0, 0.0, 0.0],
930            child: Box::new(cube([1.0, 1.0, 1.0])),
931        };
932        assert!(leaves(&obj).is_empty());
933    }
934
935    #[test]
936    fn sphere_and_cylinder_intervals() {
937        let csg = leaves(&DynamicObject::Sphere {
938            radius: 2.0,
939            segments: 8,
940            stacks: 8,
941        });
942        let spans = csg.spans(DVec3::new(-10.0, 0.0, 0.0), DVec3::X);
943        assert_eq!(spans.len(), 1);
944        assert!((spans[0].enter.t - 8.0).abs() < 1e-12);
945        assert!((spans[0].exit.t - 12.0).abs() < 1e-12);
946
947        let cyl = leaves(&DynamicObject::Cylinder {
948            radius: 1.0,
949            height: 4.0,
950            segments: 8,
951        });
952        // Down the axis: enters the top cap, exits the bottom.
953        let spans = cyl.spans(DVec3::new(0.0, 10.0, 0.0), -DVec3::Y);
954        assert_eq!(spans.len(), 1);
955        assert_eq!(spans[0].enter.face, FaceTag::CylTop);
956        assert_eq!(spans[0].exit.face, FaceTag::CylBottom);
957        assert!((spans[0].enter.t - 6.0).abs() < 1e-12);
958        // Sideways through the wall at mid-height.
959        let spans = cyl.spans(DVec3::new(-5.0, 2.0, 0.0), DVec3::X);
960        assert_eq!(spans[0].enter.face, FaceTag::CylSide);
961        assert!((spans[0].enter.t - 4.0).abs() < 1e-12);
962        let n = cyl.boundary_normal(&spans[0].enter, DVec3::new(-5.0, 2.0, 0.0), DVec3::X);
963        assert!((n - (-DVec3::X)).length() < 1e-12);
964    }
965
966    #[test]
967    fn cone_side_and_base() {
968        let cone = leaves(&DynamicObject::Cone {
969            radius: 2.0,
970            height: 4.0,
971            segments: 8,
972        });
973        // Horizontal ray at half height: local radius there is 1.
974        let (ro, rd) = (DVec3::new(-5.0, 2.0, 0.0), DVec3::X);
975        let spans = cone.spans(ro, rd);
976        assert_eq!(spans.len(), 1);
977        assert_eq!(spans[0].enter.face, FaceTag::ConeSide);
978        assert!((spans[0].enter.t - 4.0).abs() < 1e-9);
979        assert!((spans[0].exit.t - 6.0).abs() < 1e-9);
980        let n = cone.boundary_normal(&spans[0].enter, ro, rd);
981        // Outward, tilted upward by the slope: n ∝ (−1, k, 0), k = r/h = 0.5.
982        let expect = DVec3::new(-1.0, 0.5, 0.0).normalize();
983        assert!((n - expect).length() < 1e-9);
984        // Vertical ray through the base.
985        let spans = cone.spans(DVec3::new(0.5, -5.0, 0.0), DVec3::Y);
986        assert_eq!(spans[0].enter.face, FaceTag::ConeBottom);
987        assert_eq!(spans[0].exit.face, FaceTag::ConeSide);
988    }
989
990    #[test]
991    fn union_merges_touching_and_overlapping() {
992        let obj = DynamicObject::Union {
993            children: vec![
994                cube([1.0, 1.0, 1.0]),
995                DynamicObject::Translate {
996                    offset: [1.0, 0.0, 0.0],
997                    child: Box::new(cube([1.0, 1.0, 1.0])),
998                },
999            ],
1000        };
1001        let csg = leaves(&obj);
1002        let spans = csg.spans(unit_ray_x().0, unit_ray_x().1);
1003        assert_eq!(spans.len(), 1, "touching cubes merge into one span");
1004        assert!((spans[0].exit.t - 12.0).abs() < 1e-12);
1005    }
1006
1007    #[test]
1008    fn difference_flips_subtrahend_normal() {
1009        // 2×1×1 plate minus a unit cube in its middle-left: ray along X sees
1010        // plate wall, then the hole wall (subtracted cube's face, flipped).
1011        let obj = DynamicObject::Difference {
1012            children: vec![
1013                cube([3.0, 1.0, 1.0]),
1014                DynamicObject::Translate {
1015                    offset: [1.0, -0.5, -0.5],
1016                    child: Box::new(cube([1.0, 2.0, 2.0])),
1017                },
1018            ],
1019        };
1020        let csg = leaves(&obj);
1021        let (ro, rd) = unit_ray_x();
1022        let spans = csg.spans(ro, rd);
1023        assert_eq!(spans.len(), 2, "plate is split by the through-slot");
1024        // First span: [x=0, x=1], exit face is the slot wall = subtracted
1025        // cube's NegX face, flipped.
1026        let exit = spans[0].exit;
1027        assert_eq!(exit.face, FaceTag::CubeNegX);
1028        assert!(exit.flipped);
1029        let n = csg.boundary_normal(&exit, ro, rd);
1030        assert!(
1031            (n - DVec3::X).length() < 1e-12,
1032            "flipped normal points into the slot (out of the material)"
1033        );
1034        // contains: inside plate material, not inside the slot.
1035        assert!(csg.contains(DVec3::new(0.5, 0.5, 0.5)));
1036        assert!(!csg.contains(DVec3::new(1.5, 0.5, 0.5)));
1037    }
1038
1039    #[test]
1040    fn intersection_binds_to_tighter_solid() {
1041        let obj = DynamicObject::Intersection {
1042            children: vec![
1043                cube([2.0, 2.0, 2.0]),
1044                DynamicObject::Translate {
1045                    offset: [1.0, 0.0, 0.0],
1046                    child: Box::new(cube([2.0, 2.0, 2.0])),
1047                },
1048            ],
1049        };
1050        let csg = leaves(&obj);
1051        let (ro, rd) = (DVec3::new(-10.0, 1.0, 1.0), DVec3::X);
1052        let spans = csg.spans(ro, rd);
1053        assert_eq!(spans.len(), 1);
1054        assert!(
1055            (spans[0].enter.t - 11.0).abs() < 1e-12,
1056            "second cube binds entry"
1057        );
1058        assert!(
1059            (spans[0].exit.t - 12.0).abs() < 1e-12,
1060            "first cube binds exit"
1061        );
1062    }
1063
1064    #[test]
1065    fn empty_leaves_and_2d_are_invisible() {
1066        assert!(leaves(&DynamicObject::Empty).is_empty());
1067        assert!(leaves(&DynamicObject::Square { size: [1.0, 1.0] }).is_empty());
1068        let diff = DynamicObject::Difference { children: vec![] };
1069        assert!(
1070            leaves(&diff)
1071                .spans(unit_ray_x().0, unit_ray_x().1)
1072                .is_empty()
1073        );
1074    }
1075
1076    #[test]
1077    fn unsupported_subtree_uses_aabb_hint() {
1078        let extrude = DynamicObject::LinearExtrude {
1079            height: 2.0,
1080            twist: 0.0,
1081            scale: 1.0,
1082            slices: 1,
1083            child: Box::new(DynamicObject::Square { size: [1.0, 1.0] }),
1084        };
1085        // Without a hint the subtree vanishes.
1086        assert!(leaves(&extrude).is_empty());
1087        // With a hint it occludes as a box, tagged Unknown.
1088        let hint = |obj: &DynamicObject| match obj {
1089            DynamicObject::LinearExtrude { height, .. } => {
1090                Some((DVec3::ZERO, DVec3::new(1.0, *height, 1.0)))
1091            }
1092            _ => None,
1093        };
1094        let csg = CsgLeaves::from_dynamic(&extrude, DAffine3::IDENTITY, Some(&hint));
1095        let spans = csg.spans(DVec3::new(-5.0, 0.5, 0.5), DVec3::X);
1096        assert_eq!(spans.len(), 1);
1097        assert_eq!(spans[0].enter.face, FaceTag::Unknown);
1098        assert!(!spans[0].enter.face.is_selectable());
1099    }
1100
1101    #[test]
1102    fn branch_paths_skip_single_child_wrappers() {
1103        // Union[cube, Translate(Rotate(cube))] — second leaf's path is just [1].
1104        let obj = DynamicObject::Union {
1105            children: vec![
1106                cube([1.0; 3]),
1107                DynamicObject::Translate {
1108                    offset: [5.0, 0.0, 0.0],
1109                    child: Box::new(DynamicObject::Rotate {
1110                        angles: [0.0, 45.0, 0.0],
1111                        child: Box::new(cube([1.0; 3])),
1112                    }),
1113                },
1114            ],
1115        };
1116        let csg = leaves(&obj);
1117        assert_eq!(csg.paths[0], LeafPath(vec![0]));
1118        assert_eq!(csg.paths[1], LeafPath(vec![1]));
1119        // A single-child Difference consumes no index.
1120        let single = DynamicObject::Difference {
1121            children: vec![cube([1.0; 3])],
1122        };
1123        let csg = leaves(&single);
1124        assert_eq!(csg.paths[0], LeafPath(Vec::new()));
1125    }
1126
1127    #[test]
1128    fn first_hit_skips_behind_camera() {
1129        let csg = leaves(&cube([1.0, 1.0, 1.0]));
1130        // Origin inside the cube: first hit in front is the exit face.
1131        let hit = csg.first_hit(DVec3::splat(0.5), DVec3::X, 0.0).unwrap();
1132        assert_eq!(hit.face, FaceTag::CubePosX);
1133    }
1134
1135    #[test]
1136    fn face_outline_quad_and_circles() {
1137        let csg = leaves(&cube([1.0, 2.0, 3.0]));
1138        let outline = csg.leaves[0].face_outline(FaceTag::CubePosX);
1139        assert_eq!(outline.len(), 1);
1140        assert_eq!(outline[0].len(), 4);
1141        assert!(outline[0].iter().all(|p| (p.x - 1.0).abs() < 1e-12));
1142
1143        let cyl = leaves(&DynamicObject::Cylinder {
1144            radius: 1.0,
1145            height: 2.0,
1146            segments: 8,
1147        });
1148        assert_eq!(cyl.leaves[0].face_outline(FaceTag::CylSide).len(), 2);
1149        assert_eq!(cyl.leaves[0].face_outline(FaceTag::CylTop).len(), 1);
1150
1151        let sph = leaves(&DynamicObject::Sphere {
1152            radius: 1.0,
1153            segments: 8,
1154            stacks: 8,
1155        });
1156        assert!(sph.leaves[0].face_outline(FaceTag::Sphere).is_empty());
1157    }
1158}