Skip to main content

rscad_core/feature/
lower.rs

1//! Lower an [`EdgeFeatureData`] into pure-CSG masks against its child.
2//!
3//! Chamfers and fillets are not primitives anywhere in the pipeline — an
4//! `EdgeFeature` node *lowers* to booleans over ordinary
5//! `Cube`/`Cylinder`/`Cone`/`Revolve` solids, so every backend (Manifold,
6//! BSP, SDF ray-march, projection) renders them through variants it already
7//! supports. Subtractive masks cut as `Difference(child, Union(sub))`;
8//! additive masks (concave ring fillets) union on top of that.
9//!
10//! Edges are **re-resolved against the current child on every lowering**
11//! ([`resolve_edge`]), so features follow parameter edits; selections that no
12//! longer resolve are skipped (callers surface the count from
13//! [`LowerReport`]). Masks are built in the child's local frame — callers
14//! apply outer transforms above the feature node as usual.
15//!
16//! ## Chamfer masks
17//!
18//! - **Straight convex edge**: the cut plane passes through the two setback
19//!   lines `P_F = edge + s·d̂_F`; the mask is an oriented box with one face on
20//!   that plane, deep enough to swallow the edge, emitted as
21//!   `Translate ∘ Rotate ∘ Cube`. Extending past the setback lines is safe —
22//!   beyond them the box sits on the air side of both faces (convexity) — and
23//!   the tiny ε overhang along the edge keeps corners watertight (adjacent
24//!   chamfers stay un-mitered in v1).
25//! - **Circular convex rim**: the meridian setback points revolve into a cut
26//!   cone (a cylinder when the setbacks share a radius). The mask is
27//!   `Difference(slab, cone)` when the rim's material lies radially inside
28//!   (outer rims) or `Intersection(slab, cone)` when it lies outside
29//!   (hole-rim countersinks). The slab is clamped axially to the setback
30//!   band, so blind holes are never deepened and walls below the chamfer are
31//!   untouched; radial/axial ε only ever extends into air.
32//!
33//! ## Fillet masks
34//!
35//! For faces meeting at interior angle `γ` (`cos γ = d̂_A·d̂_B`), a radius-`r`
36//! arc tangent to both faces has its center a distance `h = r/sin(γ/2)` down
37//! the bisector and touches each face at setback `s_T = r/tan(γ/2)` from the
38//! edge. The clamp below applies to `s_T` (that is what must fit on the
39//! faces), and `r` rescales with it.
40//!
41//! - **Straight convex edge**: the chamfer box through the `s_T` setbacks,
42//!   minus the tangent cylinder along the edge — the cylinder wall becomes
43//!   the fillet surface. Fully SDF-visible.
44//! - **Circular rim** (convex *or* concave): the meridian wedge between the
45//!   setbacks minus the tangent circle, sampled into a polygon
46//!   (`data.segments` arc chords) and revolved around the rim axis. Convex
47//!   rims subtract the ring; concave rims (boss-base junctions) *add* the
48//!   corner-fill ring. `Revolve` masks are invisible in the SDF preview
49//!   (existing sentinel) and ignored by the analytic picking walk — mesh
50//!   backends render them exactly.
51//!
52//! Feature size is clamped per edge to `0.45 ×` the smaller adjacent face
53//! extent, so a slider can't push a mask through the far side of a face.
54//!
55//! Rim masks tessellate at the **curved leaf's own segment count and
56//! angular phase** ([`rim_segments`]/[`place_on_rim`]): where a mask surface
57//! runs along the leaf's wall (countersink tangency, fillet tangent bands),
58//! coincident chords make the boolean junction a clean polygon ring instead
59//! of an interleaved sliver band (wobbly seams, streaky shading).
60//!
61//! ## Frame-mapped (elliptical) rims
62//!
63//! Rims of anisotropically scaled leaves resolve as circles in the leaf's
64//! own frame ([`CircularEdge::frame`](crate::feature::edge::CircularEdge)).
65//! Their masks are built in that frame by the same meridian constructions
66//! and wrapped with the frame transform (`Translate∘Rotate∘Scale∘Rotate`
67//! from an SVD split), so the cut is the exact affine image of the circular
68//! one: the feature stretches with the shape, and `size`/extents are
69//! frame-local for those edges. A constant-width cut along an ellipse is
70//! not expressible with the primitive vocabulary (its offset curves are
71//! not conics), so stretching is the exact-CSG semantic choice.
72
73use std::collections::BTreeMap;
74
75use crate::feature::edge::{Convexity, EdgeGeom, resolve_edge};
76use crate::feature::geom::CsgLeaves;
77use crate::feature::{EdgeFeatureData, EdgeFeatureKind, LeafPath};
78use crate::prelude_::*;
79
80/// Watertightness margin as a fraction of the (clamped) feature size.
81const EPS_FRAC: f64 = 1.0e-3;
82/// Fraction of the smaller adjacent face extent a feature may consume.
83const MAX_EXTENT_FRAC: f64 = 0.45;
84/// Tessellation of revolved/round mask surfaces that are *not* user-tunable
85/// (cut cones, slabs, ring revolution) — matches typical body tessellation.
86const MASK_SEGMENTS: usize = 64;
87
88/// What a lowering pass did — callers log/report the skip count.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub struct LowerReport {
91    pub applied: usize,
92    pub skipped: usize,
93}
94
95/// Masks for one lowering pass, in the child's frame: `sub` cuts material
96/// (chamfers, convex fillets), `add` fills it in (concave ring fillets).
97#[derive(Clone, Debug, Default)]
98pub struct LoweredMasks {
99    pub sub: Vec<DynamicObject>,
100    pub add: Vec<DynamicObject>,
101}
102
103/// A single edge's mask with its boolean role.
104enum Mask {
105    Sub(DynamicObject),
106    Add(DynamicObject),
107}
108
109/// Lower `data` against `child`: subtract the cut masks, union the fill
110/// masks, or pass the child through unchanged when nothing resolves.
111pub fn lower(data: &EdgeFeatureData, child: &DynamicObject) -> (DynamicObject, LowerReport) {
112    let (masks, report) = lower_masks(data, child);
113    let mut out = child.clone();
114    if !masks.sub.is_empty() {
115        out = DynamicObject::Difference {
116            children: vec![
117                out,
118                DynamicObject::Union {
119                    children: masks.sub,
120                },
121            ],
122        };
123    }
124    if !masks.add.is_empty() {
125        let children = core::iter::once(out).chain(masks.add).collect();
126        out = DynamicObject::Union { children };
127    }
128    (out, report)
129}
130
131/// The masks for `data` against `child`, in the child's frame.
132/// Unresolvable/unsupported edges are skipped and counted.
133///
134/// Edges resolve against the **unfeatured** child (inner chamfers/fillets
135/// stripped): a sibling feature's cut must not trim a stored edge's
136/// surviving run, or the mask stops short of shared corners and a sliver of
137/// the original sharp edge pokes through where mixed-kind features meet.
138/// Full-length masks simply overlap at corners, matching the same-kind
139/// merge behavior.
140pub fn lower_masks(data: &EdgeFeatureData, child: &DynamicObject) -> (LoweredMasks, LowerReport) {
141    let csg = CsgLeaves::from_dynamic_unfeatured(child, DAffine3::IDENTITY, None);
142    let index: BTreeMap<&LeafPath, usize> =
143        csg.paths.iter().enumerate().map(|(i, p)| (p, i)).collect();
144    let mut masks = LoweredMasks::default();
145    let mut report = LowerReport::default();
146    for sel in &data.edges {
147        let resolved = index
148            .get(&sel.a.path)
149            .zip(index.get(&sel.b.path))
150            .ok_or(crate::feature::edge::EdgeReject::Unsupported)
151            .and_then(|(&la, &lb)| resolve_edge(&csg, (la, sel.a.face), (lb, sel.b.face)));
152        let mask = match resolved {
153            Ok((geom, convexity)) => edge_mask(data, &geom, convexity),
154            Err(_) => None,
155        };
156        match mask {
157            Some(Mask::Sub(m)) => {
158                masks.sub.push(m);
159                report.applied += 1;
160            }
161            Some(Mask::Add(m)) => {
162                masks.add.push(m);
163                report.applied += 1;
164            }
165            None => report.skipped += 1,
166        }
167    }
168    (masks, report)
169}
170
171/// Mask for one resolved edge, or `None` when this kind/shape combination is
172/// not lowerable (concave chamfers are undefined; straight concave creases
173/// are rejected at resolve time and never reach here).
174fn edge_mask(data: &EdgeFeatureData, geom: &EdgeGeom, convexity: Convexity) -> Option<Mask> {
175    match (data.kind, convexity, geom) {
176        (EdgeFeatureKind::Chamfer, Convexity::Convex, EdgeGeom::Straight(e)) => {
177            let s = clamp_size(data.size, e.extent_a, e.extent_b)?;
178            Some(Mask::Sub(straight_chamfer_mask(e, s)))
179        }
180        (EdgeFeatureKind::Chamfer, Convexity::Convex, EdgeGeom::Circular(c)) => {
181            let s = clamp_size(data.size, c.extent_a, c.extent_b)?;
182            circular_chamfer_mask(c, s).map(Mask::Sub)
183        }
184        (EdgeFeatureKind::Fillet, Convexity::Convex, EdgeGeom::Straight(e)) => {
185            straight_fillet_mask(e, data.size, data.segments).map(Mask::Sub)
186        }
187        (EdgeFeatureKind::Fillet, convexity, EdgeGeom::Circular(c)) => {
188            circular_fillet_mask(c, data.size, data.segments, convexity).map(match convexity {
189                Convexity::Convex => Mask::Sub,
190                Convexity::Concave => Mask::Add,
191            })
192        }
193        _ => None,
194    }
195}
196
197/// Per-edge size clamp: at most [`MAX_EXTENT_FRAC`] of the smaller adjacent
198/// face extent. `None` when there is no room at all.
199fn clamp_size(size: f64, extent_a: f64, extent_b: f64) -> Option<f64> {
200    let cap = MAX_EXTENT_FRAC * extent_a.min(extent_b);
201    let s = size.min(cap);
202    (s > 1.0e-9).then_some(s)
203}
204
205// ── Straight chamfer ─────────────────────────────────────────────
206
207/// Oriented box mask cutting the corner between the setback lines.
208fn straight_chamfer_mask(e: &crate::feature::edge::StraightEdge, s: f64) -> DynamicObject {
209    let eps = EPS_FRAC * s;
210    let u_hat = e.dir();
211    let len = (e.end - e.start).length();
212    let pa = e.start + s * e.da;
213    let pb = e.start + s * e.db;
214
215    // Frame: x = edge, y = across the chamfer strip, z = out of the cut
216    // plane toward the removed corner (air side, `z·(na+nb) > 0`).
217    let mut y_hat = (pb - pa).normalize();
218    let mut z_hat = u_hat.cross(y_hat);
219    if z_hat.dot(e.na + e.nb) < 0.0 {
220        y_hat = -y_hat;
221        z_hat = -z_hat;
222    }
223    let (from, width) = if y_hat.dot(pb - pa) >= 0.0 {
224        (pa, (pb - pa).length())
225    } else {
226        (pb, (pb - pa).length())
227    };
228    let depth = (e.start - pa).dot(z_hat);
229
230    let corner = from - eps * u_hat - eps * y_hat;
231    oriented_cube(
232        corner,
233        DMat3::from_cols(u_hat, y_hat, z_hat),
234        DVec3::new(len + 2.0 * eps, width + 2.0 * eps, depth + eps),
235    )
236}
237
238/// `Translate ∘ Rotate ∘ Cube` for a corner-anchored box in the frame given
239/// by rotation matrix `r` (orthonormal, right-handed).
240fn oriented_cube(corner: DVec3, r: DMat3, size: DVec3) -> DynamicObject {
241    DynamicObject::Translate {
242        offset: corner.to_array(),
243        child: Box::new(DynamicObject::Rotate {
244            angles: euler_zyx_degrees(r),
245            child: Box::new(DynamicObject::Cube {
246                size: size.to_array(),
247            }),
248        }),
249    }
250}
251
252/// Euler angles (degrees, `[x, y, z]` storage) reproducing `r` under the
253/// renderer's `Rz·Ry·Rx` convention.
254fn euler_zyx_degrees(r: DMat3) -> [f64; 3] {
255    let (z, y, x) = DQuat::from_mat3(&r).to_euler(EulerRot::ZYX);
256    [x.to_degrees(), y.to_degrees(), z.to_degrees()]
257}
258
259// ── Circular chamfer ─────────────────────────────────────────────
260
261/// Revolved cut mask for a convex rim. Meridian frame: x = outward radial,
262/// y = rim axis (the planar face's outward normal). Built in the rim's
263/// [`frame`](crate::feature::edge::CircularEdge::frame) and wrapped with it,
264/// so elliptical rims (anisotropic leaves) get the frame image of the
265/// circular cut — the feature stretches with the shape, and `s` is
266/// frame-local there.
267fn circular_chamfer_mask(c: &crate::feature::edge::CircularEdge, s: f64) -> Option<DynamicObject> {
268    let eps = EPS_FRAC * s;
269    // Setback points in the meridian plane; the rim sits at (radius, 0).
270    let pa = DVec2::new(c.radius, 0.0) + s * c.da2;
271    let pb = DVec2::new(c.radius, 0.0) + s * c.db2;
272    if pa.x <= eps || pb.x <= eps {
273        return None; // setback crosses the axis — no ring left to cut
274    }
275
276    // The removed wedge sits radially inside the cut for hole rims
277    // (plane-face material outside the rim) and outside it for outer rims.
278    let removed_inside = c.da2.x > 0.0;
279    let (y_lo, y_hi) = (pa.y.min(pb.y), pa.y.max(pb.y));
280    let slab_r = pa.x.max(pb.x).max(c.radius) + eps;
281    // ε extends only into air: above the planar face (+y) and radially; the
282    // low end stays exactly at the setback so walls/holes below are safe.
283    let slab = axial_cylinder(c, slab_r, y_lo, y_hi + eps);
284    let cut = cut_cone(c, pa, pb, y_lo, y_hi + eps)?;
285
286    let children = vec![slab, cut];
287    let mask = if removed_inside {
288        DynamicObject::Intersection { children }
289    } else {
290        DynamicObject::Difference { children }
291    };
292    Some(wrap_frame(&c.frame, mask))
293}
294
295/// Angular resolution for a rim's masks: the curved leaf's own tessellation
296/// when known (chords then coincide in the mesh booleans — no sliver fins),
297/// else [`MASK_SEGMENTS`].
298fn rim_segments(c: &crate::feature::edge::CircularEdge) -> usize {
299    c.leaf_segments.unwrap_or(MASK_SEGMENTS).max(3)
300}
301
302/// `Translate ∘ Rotate` for rim masks: local `+Y` along `up_sign · axis`,
303/// local `+X` along the leaf's tessellation origin (`ref_dir`), placed at
304/// `center + y_offset · axis` — mask polygon vertices land on the leaf's.
305fn place_on_rim(
306    c: &crate::feature::edge::CircularEdge,
307    y_offset: f64,
308    up_sign: f64,
309    obj: DynamicObject,
310) -> DynamicObject {
311    let y = up_sign * c.axis;
312    let x = c.ref_dir;
313    let r = DMat3::from_cols(x, y, x.cross(y));
314    DynamicObject::Translate {
315        offset: (c.center + y_offset * c.axis).to_array(),
316        child: Box::new(DynamicObject::Rotate {
317            angles: euler_zyx_degrees(r),
318            child: Box::new(obj),
319        }),
320    }
321}
322
323/// Solid of revolution whose meridian boundary passes through `pa` and `pb`
324/// (a cone, or a cylinder when the radii match), covering at least the
325/// axial slab band `[y_lo, y_hi]` so the boolean against the slab is exact.
326fn cut_cone(
327    c: &crate::feature::edge::CircularEdge,
328    pa: DVec2,
329    pb: DVec2,
330    y_lo: f64,
331    y_hi: f64,
332) -> Option<DynamicObject> {
333    let d = pb - pa;
334    if d.x.abs() < 1.0e-12 {
335        // Vertical cut: a plain cylinder through both points.
336        let pad = y_hi - y_lo;
337        return Some(axial_cylinder(c, pa.x, y_lo - pad, y_hi + pad));
338    }
339    if d.y.abs() < 1.0e-12 {
340        return None; // horizontal cut can't bound a corner wedge
341    }
342    let slope = d.y / d.x; // dy per dρ, nonzero both ways
343    let y_apex = pa.y - pa.x * slope;
344    // The setback segment never crosses the axis, so the apex lies outside
345    // its own y-band; but it must also clear the (ε-padded) slab band for
346    // the cone to bound the cut across the whole slab.
347    let sigma = if y_apex >= y_hi {
348        1.0 // apex above: cone narrows upward, build apex-up directly
349    } else if y_apex <= y_lo {
350        -1.0 // apex below: build apex-up in the flipped frame ỹ = −y
351    } else {
352        return None; // ε-sliver degeneracy
353    };
354    // Build coords ỹ = σ·y: apex at apex_t, base beyond the far slab end.
355    let apex_t = sigma * y_apex;
356    let far_t = sigma * if sigma > 0.0 { y_lo } else { y_hi };
357    let base_t = far_t - (y_hi - y_lo).max(1.0e-9);
358    let height = apex_t - base_t;
359    if height <= 0.0 {
360        return None;
361    }
362    // Radius at the build-frame base, from the meridian line through pa.
363    let radius = pa.x * height / (apex_t - sigma * pa.y);
364    if !radius.is_finite() || radius <= 0.0 {
365        return None;
366    }
367    // Base sits at rim-frame y = σ·base_t, apex points along σ·axis.
368    Some(place_on_rim(
369        c,
370        sigma * base_t,
371        sigma,
372        DynamicObject::Cone {
373            radius,
374            height,
375            segments: rim_segments(c),
376        },
377    ))
378}
379
380/// Cylinder around the rim axis spanning rim-frame `y ∈ [y0, y1]`.
381fn axial_cylinder(
382    c: &crate::feature::edge::CircularEdge,
383    radius: f64,
384    y0: f64,
385    y1: f64,
386) -> DynamicObject {
387    place_on_rim(
388        c,
389        y0,
390        1.0,
391        DynamicObject::Cylinder {
392            radius,
393            height: (y1 - y0).max(1.0e-9),
394            segments: rim_segments(c),
395        },
396    )
397}
398
399// ── Fillets ──────────────────────────────────────────────────────
400
401/// Fillet frame for faces meeting at interior angle `γ` (`cos γ = d̂_A·d̂_B`):
402/// the tangency setback `s_T = r/tan(γ/2)` (clamped to the face extents, with
403/// the radius rescaled to match) and the arc-center distance `h = r/sin(γ/2)`
404/// along the bisector. `None` for flush faces, knife edges, or no room.
405fn fillet_frame(
406    size: f64,
407    cos_gamma: f64,
408    extent_a: f64,
409    extent_b: f64,
410) -> Option<(f64, f64, f64)> {
411    let sin_half = ((1.0 - cos_gamma) / 2.0).max(0.0).sqrt();
412    let cos_half = ((1.0 + cos_gamma) / 2.0).max(0.0).sqrt();
413    if sin_half < 1.0e-6 || cos_half < 1.0e-6 {
414        return None;
415    }
416    let setback = clamp_size(size * cos_half / sin_half, extent_a, extent_b)?;
417    let radius = setback * sin_half / cos_half;
418    Some((radius, setback, radius / sin_half))
419}
420
421/// Box-minus-cylinder mask rounding a straight convex edge: the chamfer box
422/// through the tangency setbacks, minus the tangent cylinder whose wall
423/// becomes the fillet surface.
424fn straight_fillet_mask(
425    e: &crate::feature::edge::StraightEdge,
426    size: f64,
427    segments: usize,
428) -> Option<DynamicObject> {
429    let (r, setback, h) = fillet_frame(size, e.da.dot(e.db), e.extent_a, e.extent_b)?;
430    let cut_box = straight_chamfer_mask(e, setback);
431    let eps = EPS_FRAC * setback;
432    let u_hat = e.dir();
433    let len = (e.end - e.start).length();
434    // Tangent cylinder along the edge, overshooting the box on both ends so
435    // the subtraction is watertight.
436    let axis = e.start + h * (e.da + e.db).normalize();
437    let round = place_on_axis(
438        axis - 2.0 * eps * u_hat,
439        u_hat,
440        DynamicObject::Cylinder {
441            radius: r,
442            height: len + 4.0 * eps,
443            segments: segments.max(3),
444        },
445    );
446    Some(DynamicObject::Difference {
447        children: vec![cut_box, round],
448    })
449}
450
451/// Revolved fillet mask for a rim: the meridian wedge between the tangency
452/// setbacks minus the tangent circle, swept around the rim axis. Convex rims
453/// subtract the ring (rounding the corner off); concave rims (boss-base
454/// junctions) return the corner-fill ring for the caller to union in.
455fn circular_fillet_mask(
456    c: &crate::feature::edge::CircularEdge,
457    size: f64,
458    segments: usize,
459    convexity: Convexity,
460) -> Option<DynamicObject> {
461    let (r, setback, h) = fillet_frame(size, c.da2.dot(c.db2), c.extent_a, c.extent_b)?;
462    let eps = EPS_FRAC * setback;
463    let e2 = DVec2::new(c.radius, 0.0);
464    let pa = e2 + setback * c.da2;
465    let pb = e2 + setback * c.db2;
466    if pa.x <= eps || pb.x <= eps {
467        return None; // setback crosses the axis — no ring left
468    }
469    let center = e2 + h * (c.da2 + c.db2).normalize();
470
471    // ε pushes the wall segments across the faces: into air for a convex
472    // corner cut, into material for a concave corner fill. The planar face's
473    // meridian outward normal is +y by construction.
474    let out = match convexity {
475        Convexity::Convex => 1.0,
476        Convexity::Concave => -1.0,
477    };
478    let na = out * DVec2::Y;
479    let nb = out * c.nb2;
480
481    let mut points = vec![pa + eps * na, e2 + eps * (na + nb), pb + eps * nb, pb];
482    // The short arc from pb back to pa — the one bulging toward the edge.
483    let a0 = (pb - center).to_angle();
484    let mut sweep = (pa - center).to_angle() - a0;
485    if sweep > core::f64::consts::PI {
486        sweep -= core::f64::consts::TAU;
487    } else if sweep < -core::f64::consts::PI {
488        sweep += core::f64::consts::TAU;
489    }
490    let chords = segments.max(3);
491    points.extend(
492        (1..chords).map(|i| center + r * DVec2::from_angle(a0 + sweep * i as f64 / chords as f64)),
493    );
494    points.push(pa);
495    ensure_ccw(&mut points);
496
497    Some(wrap_frame(
498        &c.frame,
499        place_on_rim(
500            c,
501            0.0,
502            1.0,
503            DynamicObject::Revolve {
504                angle_degrees: 360.0,
505                segments: rim_segments(c),
506                child: Box::new(DynamicObject::Polygon {
507                    points: points.iter().map(|p| p.to_array()).collect(),
508                }),
509            },
510        ),
511    ))
512}
513
514/// Reverse `points` in place if the loop winds clockwise (backends fill CCW).
515fn ensure_ccw(points: &mut [DVec2]) {
516    let area2: f64 = (0..points.len())
517        .map(|i| {
518            let p = points[i];
519            let q = points[(i + 1) % points.len()];
520            p.x * q.y - q.x * p.y
521        })
522        .sum();
523    if area2 < 0.0 {
524        points.reverse();
525    }
526}
527
528/// `Translate ∘ Rotate` aligning local +Y with `up` (unit) at `origin`.
529fn place_on_axis(origin: DVec3, up: DVec3, obj: DynamicObject) -> DynamicObject {
530    let rot = DQuat::from_rotation_arc(DVec3::Y, up);
531    let (z, y, x) = rot.to_euler(EulerRot::ZYX);
532    DynamicObject::Translate {
533        offset: origin.to_array(),
534        child: Box::new(DynamicObject::Rotate {
535            angles: [x.to_degrees(), y.to_degrees(), z.to_degrees()],
536            child: Box::new(obj),
537        }),
538    }
539}
540
541/// Wrap `obj` in `Translate ∘ Rotate ∘ Scale ∘ Rotate` wrappers reproducing
542/// the affine `frame` (identity passes through). The linear part splits as
543/// `M = R₁·Σ·R₂` via the eigendecomposition of `MᵀM` (an SVD); a reflection
544/// folds into a negated scale factor so both rotations stay proper.
545fn wrap_frame(frame: &DAffine3, obj: DynamicObject) -> DynamicObject {
546    if *frame == DAffine3::IDENTITY {
547        return obj;
548    }
549    let m = frame.matrix3;
550    let (q, lam) = jacobi_eigen(m.transpose() * m);
551    let sigma = DVec3::new(
552        lam.x.max(0.0).sqrt(),
553        lam.y.max(0.0).sqrt(),
554        lam.z.max(0.0).sqrt(),
555    );
556    if sigma.min_element() < 1.0e-12 * sigma.max_element().max(1.0e-300) {
557        return obj; // degenerate (flattened) frame — nothing sane to emit
558    }
559    // N = M·Q·Σ⁻¹ is orthogonal; improper N moves its reflection into Σ.
560    let n = m * q * DMat3::from_diagonal(sigma.recip());
561    let (r1, sigma) = if n.determinant() < 0.0 {
562        let flip = DVec3::new(1.0, 1.0, -1.0);
563        (n * DMat3::from_diagonal(flip), sigma * flip)
564    } else {
565        (n, sigma)
566    };
567    let inner = DynamicObject::Rotate {
568        angles: euler_zyx_degrees(q.transpose()),
569        child: Box::new(obj),
570    };
571    let scaled = DynamicObject::Scale {
572        factors: sigma.to_array(),
573        child: Box::new(inner),
574    };
575    let rotated = DynamicObject::Rotate {
576        angles: euler_zyx_degrees(r1),
577        child: Box::new(scaled),
578    };
579    DynamicObject::Translate {
580        offset: frame.translation.to_array(),
581        child: Box::new(rotated),
582    }
583}
584
585/// Eigendecomposition `m = Q·diag(λ)·Qᵀ` of a symmetric 3×3 matrix by cyclic
586/// Jacobi rotations; `Q` is returned proper (det +1).
587fn jacobi_eigen(m: DMat3) -> (DMat3, DVec3) {
588    // a[r][c], symmetric.
589    let mut a = [
590        [m.x_axis.x, m.y_axis.x, m.z_axis.x],
591        [m.x_axis.y, m.y_axis.y, m.z_axis.y],
592        [m.x_axis.z, m.y_axis.z, m.z_axis.z],
593    ];
594    let mut q = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
595    for _sweep in 0..12 {
596        let off = a[0][1].abs() + a[0][2].abs() + a[1][2].abs();
597        let diag = a[0][0].abs() + a[1][1].abs() + a[2][2].abs();
598        if off <= 1.0e-15 * diag.max(1.0e-300) {
599            break;
600        }
601        for (p, r) in [(0usize, 1usize), (0, 2), (1, 2)] {
602            let apr = a[p][r];
603            if apr.abs() <= 1.0e-30 {
604                continue;
605            }
606            // Classic Jacobi rotation zeroing a[p][r].
607            let theta = 0.5 * (a[r][r] - a[p][p]) / apr;
608            let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
609            let c = 1.0 / (t * t + 1.0).sqrt();
610            let s = t * c;
611            let rot_cols = |row: &mut [f64; 3]| {
612                let (kp, kr) = (row[p], row[r]);
613                row[p] = c * kp - s * kr;
614                row[r] = s * kp + c * kr;
615            };
616            a.iter_mut().for_each(rot_cols);
617            // Rows p and r (p < r in every pair above).
618            let (head, tail) = a.split_at_mut(r);
619            for (apk, ark) in head[p].iter_mut().zip(tail[0].iter_mut()) {
620                let (x, y) = (*apk, *ark);
621                *apk = c * x - s * y;
622                *ark = s * x + c * y;
623            }
624            q.iter_mut().for_each(rot_cols);
625        }
626    }
627    let mut qm = DMat3::from_cols(
628        DVec3::new(q[0][0], q[1][0], q[2][0]),
629        DVec3::new(q[0][1], q[1][1], q[2][1]),
630        DVec3::new(q[0][2], q[1][2], q[2][2]),
631    );
632    if qm.determinant() < 0.0 {
633        qm.z_axis = -qm.z_axis;
634    }
635    (qm, DVec3::new(a[0][0], a[1][1], a[2][2]))
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use crate::feature::{EdgeSel, FaceRef, FaceTag};
642
643    fn cube(size: [f64; 3]) -> DynamicObject {
644        DynamicObject::Cube { size }
645    }
646
647    fn cylinder(radius: f64, height: f64) -> DynamicObject {
648        DynamicObject::Cylinder {
649            radius,
650            height,
651            segments: 64,
652        }
653    }
654
655    fn translate(offset: [f64; 3], child: DynamicObject) -> DynamicObject {
656        DynamicObject::Translate {
657            offset,
658            child: Box::new(child),
659        }
660    }
661
662    fn face(path: &[u16], tag: FaceTag) -> FaceRef {
663        FaceRef {
664            path: LeafPath(path.to_vec()),
665            face: tag,
666        }
667    }
668
669    fn chamfer(size: f64, edges: Vec<EdgeSel>) -> EdgeFeatureData {
670        EdgeFeatureData {
671            kind: EdgeFeatureKind::Chamfer,
672            size,
673            segments: 16,
674            edges,
675        }
676    }
677
678    fn contains(obj: &DynamicObject, p: DVec3) -> bool {
679        CsgLeaves::from_dynamic(obj, DAffine3::IDENTITY, None).contains(p)
680    }
681
682    #[test]
683    fn straight_chamfer_cuts_the_corner_and_nothing_else() {
684        let child = cube([2.0, 2.0, 2.0]);
685        // Edge between +X and +Y faces (along Z at x=2, y=2), chamfer 0.4.
686        let data = chamfer(
687            0.4,
688            vec![EdgeSel::new(
689                face(&[], FaceTag::CubePosX),
690                face(&[], FaceTag::CubePosY),
691            )],
692        );
693        let (lowered, report) = lower(&data, &child);
694        assert_eq!(report.applied, 1);
695        assert_eq!(report.skipped, 0);
696
697        // The old edge line is gone along its whole length…
698        for z in [0.05, 1.0, 1.95] {
699            assert!(
700                !contains(&lowered, DVec3::new(1.95, 1.95, z)),
701                "corner material at z={z} must be cut"
702            );
703        }
704        // …the cut plane sits at the setback (x + y = 4 − 0.4): just under
705        // it is material, just over it is air.
706        assert!(contains(&lowered, DVec3::new(1.75, 1.80, 1.0)));
707        assert!(!contains(&lowered, DVec3::new(1.85, 1.85, 1.0)));
708        // Untouched regions stay: the opposite edge and the faces away from
709        // the setback band.
710        assert!(contains(&lowered, DVec3::new(0.05, 0.05, 1.0)));
711        assert!(contains(&lowered, DVec3::new(1.95, 0.5, 1.0)));
712        assert!(contains(&lowered, DVec3::new(0.5, 1.95, 1.0)));
713    }
714
715    #[test]
716    fn rim_chamfer_cuts_ring_without_touching_wall_below() {
717        let child = cylinder(1.0, 2.0);
718        let data = chamfer(
719            0.2,
720            vec![EdgeSel::new(
721                face(&[], FaceTag::CylSide),
722                face(&[], FaceTag::CylTop),
723            )],
724        );
725        let (lowered, report) = lower(&data, &child);
726        assert_eq!((report.applied, report.skipped), (1, 0));
727
728        // Rim corner removed all around.
729        for theta in [0.0f64, 1.0, 2.5, 4.0, 5.5] {
730            let (s, c) = theta.sin_cos();
731            assert!(
732                !contains(&lowered, DVec3::new(0.97 * c, 1.97, 0.97 * s)),
733                "rim corner at θ={theta} must be cut"
734            );
735        }
736        // The 45° cut surface: material just inside, air just outside.
737        assert!(contains(&lowered, DVec3::new(0.85, 1.90, 0.0)));
738        assert!(!contains(&lowered, DVec3::new(0.93, 1.95, 0.0)));
739        // Wall below the setback and the cap center are untouched.
740        assert!(contains(&lowered, DVec3::new(0.99, 1.75, 0.0)));
741        assert!(contains(&lowered, DVec3::new(0.0, 1.99, 0.0)));
742        assert!(contains(&lowered, DVec3::new(0.99, 0.5, 0.0)));
743    }
744
745    #[test]
746    fn hole_rim_countersink_clips_to_the_funnel() {
747        // Plate with a through-hole at (2, ·, 2), r = 0.5; countersink 0.15.
748        let child = DynamicObject::Difference {
749            children: vec![
750                cube([4.0, 1.0, 4.0]),
751                translate([2.0, -0.5, 2.0], cylinder(0.5, 2.0)),
752            ],
753        };
754        let data = chamfer(
755            0.15,
756            vec![EdgeSel::new(
757                face(&[0], FaceTag::CubePosY),
758                face(&[1], FaceTag::CylSide),
759            )],
760        );
761        let (lowered, report) = lower(&data, &child);
762        assert_eq!((report.applied, report.skipped), (1, 0));
763
764        // Funnel ring at the top: what was plate material at the rim is gone.
765        assert!(!contains(&lowered, DVec3::new(2.55, 0.97, 2.0)));
766        // Below the countersink depth the wall is intact…
767        assert!(contains(&lowered, DVec3::new(2.55, 0.5, 2.0)));
768        // …and the hole is not deepened (it is a through hole: the bore
769        // itself stays air, the material right next to it stays material).
770        assert!(!contains(&lowered, DVec3::new(2.0, 0.5, 2.0)));
771        assert!(contains(&lowered, DVec3::new(2.7, 0.97, 2.0)));
772    }
773
774    fn fillet(size: f64, edges: Vec<EdgeSel>) -> EdgeFeatureData {
775        EdgeFeatureData {
776            kind: EdgeFeatureKind::Fillet,
777            ..chamfer(size, edges)
778        }
779    }
780
781    fn boss_on_plate() -> (DynamicObject, EdgeSel) {
782        let child = DynamicObject::Union {
783            children: vec![
784                cube([4.0, 1.0, 4.0]),
785                translate([2.0, 1.0, 2.0], cylinder(0.5, 1.0)),
786            ],
787        };
788        let sel = EdgeSel::new(face(&[0], FaceTag::CubePosY), face(&[1], FaceTag::CylSide));
789        (child, sel)
790    }
791
792    #[test]
793    fn concave_chamfer_skips() {
794        // Boss on a plate: the base ring is concave — chamfer must skip it.
795        let (child, sel) = boss_on_plate();
796        let data = chamfer(0.2, vec![sel]);
797        let (lowered, report) = lower(&data, &child);
798        assert_eq!((report.applied, report.skipped), (0, 1));
799        assert_eq!(lowered, child, "nothing to lower — child passes through");
800    }
801
802    #[test]
803    fn straight_fillet_keeps_the_arc_interior() {
804        // Fillet 0.4 on the +X/+Y edge of a 2³ cube: arc center (1.6, 1.6),
805        // r = 0.4 (90° dihedral: setback = radius).
806        let child = cube([2.0, 2.0, 2.0]);
807        let data = fillet(
808            0.4,
809            vec![EdgeSel::new(
810                face(&[], FaceTag::CubePosX),
811                face(&[], FaceTag::CubePosY),
812            )],
813        );
814        let (lowered, report) = lower(&data, &child);
815        assert_eq!((report.applied, report.skipped), (1, 0));
816
817        // The sharp corner is rounded off along the whole edge…
818        for z in [0.05, 1.0, 1.95] {
819            assert!(
820                !contains(&lowered, DVec3::new(1.95, 1.95, z)),
821                "corner material at z={z} must be cut"
822            );
823        }
824        // …but unlike a chamfer, points beyond the chord and inside the
825        // tangent circle survive: (1.85, 1.85) has x+y > 3.6 yet lies within
826        // r of the arc center.
827        assert!(contains(&lowered, DVec3::new(1.85, 1.85, 1.0)));
828        // Faces outside the setback band are untouched.
829        assert!(contains(&lowered, DVec3::new(1.95, 0.5, 1.0)));
830        assert!(contains(&lowered, DVec3::new(0.5, 1.95, 1.0)));
831    }
832
833    #[test]
834    fn convex_rim_fillet_is_a_subtractive_ring() {
835        let data = fillet(
836            0.2,
837            vec![EdgeSel::new(
838                face(&[], FaceTag::CylSide),
839                face(&[], FaceTag::CylTop),
840            )],
841        );
842        let (masks, report) = lower_masks(&data, &cylinder(1.0, 2.0));
843        assert_eq!((report.applied, report.skipped), (1, 0));
844        assert_eq!((masks.sub.len(), masks.add.len()), (1, 0));
845        // The ring is a revolved meridian polygon around the rim axis.
846        assert_polygon_ring(&masks.sub[0], 0.2, DVec2::new(0.8, -0.2));
847    }
848
849    #[test]
850    fn boss_base_ring_fillet_is_an_additive_ring() {
851        let (child, sel) = boss_on_plate();
852        let data = fillet(0.2, vec![sel]);
853        let (masks, report) = lower_masks(&data, &child);
854        assert_eq!((report.applied, report.skipped), (1, 0));
855        assert_eq!((masks.sub.len(), masks.add.len()), (0, 1));
856        // Corner-fill arc center in boss meridian coords: radially outside
857        // the boss wall, above the plate top, by (r, r) for the 90° corner.
858        assert_polygon_ring(&masks.add[0], 0.2, DVec2::new(0.7, 0.2));
859
860        // The lowered tree unions the ring on top of the child.
861        let (lowered, _) = lower(&data, &child);
862        match &lowered {
863            DynamicObject::Union { children } => {
864                assert_eq!(children.len(), 2);
865                assert_eq!(children[0], child);
866            }
867            other => panic!("expected Union(child, ring), got {:?}", other.name()),
868        }
869    }
870
871    /// Ring mask sanity: a revolved polygon whose arc chords sit at radius
872    /// `r` from `center` (meridian coords) and whose points stay off-axis.
873    fn assert_polygon_ring(mask: &DynamicObject, r: f64, center: DVec2) {
874        let mut node = mask;
875        loop {
876            match node {
877                DynamicObject::Translate { child, .. }
878                | DynamicObject::Rotate { child, .. }
879                | DynamicObject::Revolve { child, .. } => node = child,
880                DynamicObject::Polygon { points } => {
881                    assert!(points.iter().all(|p| p[0] > 0.0), "profile crosses axis");
882                    let on_arc = points
883                        .iter()
884                        .filter(|p| ((DVec2::from_array(**p) - center).length() - r).abs() < 1.0e-9)
885                        .count();
886                    assert!(
887                        on_arc >= 3,
888                        "expected arc samples at radius {r} of {center}"
889                    );
890                    return;
891                }
892                other => panic!("unexpected ring mask node {:?}", other.name()),
893            }
894        }
895    }
896
897    #[test]
898    fn mixed_chamfer_fillet_corner_leaves_no_horn() {
899        // Chamfer on the PosX/PosY edge, fillet stacked on the PosX/PosZ
900        // edge — they share the (2,2,2) corner. The fillet must resolve its
901        // edge against the unfeatured cube: if the chamfer's cut trims it,
902        // the fillet stops short and a sliver of the original sharp edge
903        // survives at the corner (the "horn").
904        let inner = DynamicObject::EdgeFeature {
905            data: chamfer(
906                0.3,
907                vec![EdgeSel::new(
908                    face(&[], FaceTag::CubePosX),
909                    face(&[], FaceTag::CubePosY),
910                )],
911            ),
912            child: Box::new(cube([2.0, 2.0, 2.0])),
913        };
914        let stacked = DynamicObject::EdgeFeature {
915            data: EdgeFeatureData {
916                kind: EdgeFeatureKind::Fillet,
917                ..chamfer(
918                    0.3,
919                    vec![EdgeSel::new(
920                        face(&[0], FaceTag::CubePosX),
921                        face(&[0], FaceTag::CubePosZ),
922                    )],
923                )
924            },
925            child: Box::new(inner),
926        };
927        // Horn region: on the old sharp PosX/PosZ edge, inside the chamfer's
928        // trim zone (y > 1.7) but below its cut plane (x + y < 3.7), outside
929        // the fillet's tangent cylinder — must be removed by the full-length
930        // fillet mask.
931        assert!(!contains(&stacked, DVec3::new(1.98, 1.71, 1.98)));
932        // The fillet's kept round survives away from the corner…
933        assert!(contains(&stacked, DVec3::new(1.8, 1.0, 1.8)));
934        // …the chamfer still cuts its own edge…
935        assert!(!contains(&stacked, DVec3::new(1.9, 1.9, 1.0)));
936        // …and plain faces stay.
937        assert!(contains(&stacked, DVec3::new(1.98, 0.5, 0.5)));
938    }
939
940    #[test]
941    fn rim_masks_adopt_leaf_tessellation_and_phase() {
942        // 20-segment hole: mask cones/cylinders must tessellate at 20 with
943        // the hole's angular origin (local +X), so their chords coincide
944        // with the bore's in mesh booleans; the fillet ring revolve too.
945        let hole = translate(
946            [2.0, -0.5, 2.0],
947            DynamicObject::Cylinder {
948                radius: 0.5,
949                height: 2.0,
950                segments: 20,
951            },
952        );
953        let child = DynamicObject::Difference {
954            children: vec![cube([4.0, 1.0, 4.0]), hole],
955        };
956        let sel = || {
957            vec![EdgeSel::new(
958                face(&[0], FaceTag::CubePosY),
959                face(&[1], FaceTag::CylSide),
960            )]
961        };
962
963        let (masks, report) = lower_masks(&chamfer(0.15, sel()), &child);
964        assert_eq!((report.applied, report.skipped), (1, 0));
965        let csg = CsgLeaves::from_dynamic(&masks.sub[0], DAffine3::IDENTITY, None);
966        assert!(!csg.leaves.is_empty());
967        for leaf in &csg.leaves {
968            let segments = match leaf.prim {
969                LeafPrim::Cylinder { segments, .. } | LeafPrim::Cone { segments, .. } => segments,
970                _ => continue,
971            };
972            assert_eq!(segments, 20, "mask must match the hole tessellation");
973            // Phase: the mask's local +X maps onto the hole's angular
974            // origin (world +X here — the hole leaf carries no rotation).
975            let x_img = (leaf.world.matrix3 * DVec3::X).normalize();
976            assert!(
977                (x_img - DVec3::X).length() < 1e-9,
978                "mask phase must anchor at the leaf origin, got {x_img:?}"
979            );
980        }
981
982        // Fillet ring: the revolve sweep adopts the hole's 20 segments.
983        let fillet_data = EdgeFeatureData {
984            kind: EdgeFeatureKind::Fillet,
985            ..chamfer(0.15, sel())
986        };
987        let (masks, report) = lower_masks(&fillet_data, &child);
988        assert_eq!((report.applied, report.skipped), (1, 0));
989        fn find_revolve_segments(obj: &DynamicObject) -> Option<usize> {
990            match obj {
991                DynamicObject::Revolve { segments, .. } => Some(*segments),
992                DynamicObject::Translate { child, .. }
993                | DynamicObject::Rotate { child, .. }
994                | DynamicObject::Scale { child, .. } => find_revolve_segments(child),
995                _ => None,
996            }
997        }
998        assert_eq!(find_revolve_segments(&masks.sub[0]), Some(20));
999    }
1000
1001    #[test]
1002    fn wrap_frame_reproduces_affine() {
1003        let cases = [
1004            // Rotation + anisotropic scale + rotation + translation.
1005            DAffine3::from_translation(DVec3::new(1.0, 2.0, 3.0))
1006                * DAffine3::from_quat(DQuat::from_rotation_z(0.5))
1007                * DAffine3::from_scale(DVec3::new(2.0, 1.0, 0.5))
1008                * DAffine3::from_quat(DQuat::from_rotation_x(0.3)),
1009            // Reflection (negative determinant) folds into a scale factor.
1010            DAffine3::from_scale(DVec3::new(-2.0, 1.0, 0.5))
1011                * DAffine3::from_quat(DQuat::from_rotation_y(0.7)),
1012        ];
1013        for f in cases {
1014            let wrapped = wrap_frame(&f, cube([1.0, 1.0, 1.0]));
1015            let csg = CsgLeaves::from_dynamic(&wrapped, DAffine3::IDENTITY, None);
1016            let got = csg.leaves[0].world;
1017            assert!(
1018                (got.matrix3.x_axis - f.matrix3.x_axis).length() < 1e-9
1019                    && (got.matrix3.y_axis - f.matrix3.y_axis).length() < 1e-9
1020                    && (got.matrix3.z_axis - f.matrix3.z_axis).length() < 1e-9
1021                    && (got.translation - f.translation).length() < 1e-9,
1022                "wrapper chain must reproduce the frame: {got:?} vs {f:?}"
1023            );
1024        }
1025    }
1026
1027    #[test]
1028    fn elliptical_rim_chamfer_cuts_in_world() {
1029        // Anisotropic [2,1,1] cylinder: the rim resolves in the leaf frame
1030        // and the mask is the frame image of the circular cut — the corner
1031        // ring is removed at both ellipse azimuths, walls and cap survive.
1032        let child = DynamicObject::Scale {
1033            factors: [2.0, 1.0, 1.0],
1034            child: Box::new(cylinder(1.0, 2.0)),
1035        };
1036        let data = chamfer(
1037            0.2,
1038            vec![EdgeSel::new(
1039                face(&[], FaceTag::CylSide),
1040                face(&[], FaceTag::CylTop),
1041            )],
1042        );
1043        let (lowered, report) = lower(&data, &child);
1044        assert_eq!((report.applied, report.skipped), (1, 0));
1045        // Rim corner cut on the major (x) and minor (z) vertices.
1046        assert!(!contains(&lowered, DVec3::new(1.94, 1.97, 0.0)));
1047        assert!(!contains(&lowered, DVec3::new(0.0, 1.97, 0.97)));
1048        // Wall below the setback band and the cap center survive.
1049        assert!(contains(&lowered, DVec3::new(1.98, 1.5, 0.0)));
1050        assert!(contains(&lowered, DVec3::new(0.0, 1.5, 0.99)));
1051        assert!(contains(&lowered, DVec3::new(0.0, 1.99, 0.0)));
1052    }
1053
1054    #[test]
1055    fn stale_paths_skip_and_valid_ones_apply() {
1056        let child = cube([2.0, 2.0, 2.0]);
1057        let data = chamfer(
1058            0.2,
1059            vec![
1060                EdgeSel::new(face(&[], FaceTag::CubePosX), face(&[], FaceTag::CubePosY)),
1061                // A path that doesn't exist in this child.
1062                EdgeSel::new(face(&[7], FaceTag::CubePosX), face(&[7], FaceTag::CubePosY)),
1063            ],
1064        );
1065        let (_, report) = lower(&data, &child);
1066        assert_eq!((report.applied, report.skipped), (1, 1));
1067    }
1068
1069    #[test]
1070    fn nested_feature_child_paths_resolve() {
1071        // An outer feature whose child is itself an EdgeFeature addresses
1072        // the inner child region with a leading 0 — the path the engine
1073        // stores when stacking mixed kinds. Must resolve, not skip.
1074        let inner = DynamicObject::EdgeFeature {
1075            data: chamfer(
1076                0.2,
1077                vec![EdgeSel::new(
1078                    face(&[], FaceTag::CubePosX),
1079                    face(&[], FaceTag::CubePosY),
1080                )],
1081            ),
1082            child: Box::new(cube([2.0, 2.0, 2.0])),
1083        };
1084        let data = chamfer(
1085            0.2,
1086            vec![EdgeSel::new(
1087                face(&[0], FaceTag::CubeNegX),
1088                face(&[0], FaceTag::CubeNegY),
1089            )],
1090        );
1091        let (lowered, report) = lower(&data, &inner);
1092        assert_eq!((report.applied, report.skipped), (1, 0));
1093        // Both the inner and the outer corner are cut.
1094        assert!(!contains(&lowered, DVec3::new(1.95, 1.95, 1.0)));
1095        assert!(!contains(&lowered, DVec3::new(0.05, 0.05, 1.0)));
1096    }
1097
1098    #[test]
1099    fn chamfer_follows_parameter_edits() {
1100        let edges = vec![EdgeSel::new(
1101            face(&[], FaceTag::CubePosX),
1102            face(&[], FaceTag::CubePosY),
1103        )];
1104        let data = chamfer(0.3, edges);
1105        // Same feature, two child sizes: the cut follows the moving edge.
1106        let (small, _) = lower(&data, &cube([2.0, 2.0, 2.0]));
1107        let (large, _) = lower(&data, &cube([3.0, 3.0, 3.0]));
1108        assert!(!contains(&small, DVec3::new(1.95, 1.95, 1.0)));
1109        assert!(contains(&large, DVec3::new(1.95, 1.95, 1.0)));
1110        assert!(!contains(&large, DVec3::new(2.95, 2.95, 1.0)));
1111    }
1112
1113    #[test]
1114    fn size_clamps_to_adjacent_extent() {
1115        // Chamfer "1000" on a 2×2×2 cube: clamped to 0.45·2 = 0.9 per face,
1116        // so the body must survive.
1117        let child = cube([2.0, 2.0, 2.0]);
1118        let data = chamfer(
1119            1000.0,
1120            vec![EdgeSel::new(
1121                face(&[], FaceTag::CubePosX),
1122                face(&[], FaceTag::CubePosY),
1123            )],
1124        );
1125        let (lowered, report) = lower(&data, &child);
1126        assert_eq!(report.applied, 1);
1127        assert!(contains(&lowered, DVec3::new(1.0, 0.2, 1.0)));
1128        assert!(
1129            contains(&lowered, DVec3::new(1.9, 1.05, 1.0)),
1130            "clamped cut leaves the face below the setback"
1131        );
1132        assert!(!contains(&lowered, DVec3::new(1.9, 1.9, 1.0)));
1133    }
1134
1135    #[test]
1136    fn transformed_leaf_masks_follow_the_leaf_frame() {
1137        // The chamfered cube sits rotated + translated inside the child.
1138        let child = translate(
1139            [5.0, 0.0, 0.0],
1140            DynamicObject::Rotate {
1141                angles: [0.0, 0.0, 45.0],
1142                child: Box::new(cube([2.0, 2.0, 2.0])),
1143            },
1144        );
1145        let data = chamfer(
1146            0.4,
1147            vec![EdgeSel::new(
1148                face(&[], FaceTag::CubePosX),
1149                face(&[], FaceTag::CubePosY),
1150            )],
1151        );
1152        let (lowered, report) = lower(&data, &child);
1153        assert_eq!((report.applied, report.skipped), (1, 0));
1154        // Probe in the leaf's frame mapped to child coords: the +X/+Y edge
1155        // midpoint of the rotated cube.
1156        let world = DAffine3::from_translation(DVec3::new(5.0, 0.0, 0.0))
1157            * DAffine3::from_quat(DQuat::from_rotation_z(45.0_f64.to_radians()));
1158        let old_edge = world.transform_point3(DVec3::new(1.95, 1.95, 1.0));
1159        let deep = world.transform_point3(DVec3::new(1.0, 1.0, 1.0));
1160        assert!(!contains(&lowered, old_edge));
1161        assert!(contains(&lowered, deep));
1162    }
1163}