Skip to main content

rscad_core/feature/
edge.rs

1//! Analytic edge resolution: turn a pair of leaf faces into a verified edge
2//! curve on the **final solid**, or a typed rejection.
3//!
4//! Supported curves (v1): straight lines (plane ∩ plane, incl. cube edges)
5//! and full circles (cylinder/cone rims, plane ∩ cylinder-side with the plane
6//! ⊥ axis, plane ∩ sphere, plane ∩ cone-side ⊥ axis). Everything else is
7//! rejected with a reason a frontend can show ([`EdgeReject::message`]).
8//!
9//! Leaf transforms may scale round surfaces arbitrarily: axisymmetric scale
10//! about the leaf's own axis keeps the curves circular in the evaluation
11//! frame ([`axisymmetric_scale`]); anisotropic scale makes them elliptical,
12//! so resolution retries in the leaf's local frame where they are exact
13//! circles again, and the result carries that frame
14//! ([`CircularEdge::frame`] — the world curve is its affine image). Only
15//! pairs mixing two *differently* anisotropic round leaves reject
16//! ([`EdgeReject::NonUniformScale`]).
17//!
18//! Resolution is *verified* against the final solid, not just the two
19//! surfaces: sample points along the candidate curve must lie on the actual
20//! boundary (membership probes straddling the edge bisector), and the
21//! surviving side of each face is found by probing — so an edge fully
22//! consumed by a boolean resolves to nothing, and a partially consumed
23//! straight edge is trimmed to its longest surviving run. Circles must
24//! survive at every sample (no partial-arc features in v1).
25//!
26//! Convexity is classified geometrically from the probed face directions:
27//! with `d̂A` pointing along face A's surviving surface away from the edge,
28//! the edge is convex iff `d̂A·n̂B < 0` (the faces bend away from each other).
29//! Both faces must agree. Straight concave edges are rejected in v1; concave
30//! *circles* (boss-base rings) resolve fine — fillet lowering uses them.
31
32use crate::feature::FaceTag;
33use crate::feature::geom::{CsgExpr, CsgLeaves, GeomLeaf, LeafPrim};
34use crate::prelude_::*;
35
36/// Membership-probe offset as a fraction of the model's AABB diagonal.
37const PROBE_FRAC: f64 = 1.0e-4;
38/// Samples along a straight edge for existence verification.
39const LINE_SAMPLES: usize = 9;
40/// Samples around a circle for existence verification (all must pass).
41const CIRCLE_SAMPLES: usize = 8;
42/// Bisection iterations when trimming a partially surviving line.
43const TRIM_ITERS: usize = 12;
44/// Relative tolerance for axis-alignment / similarity checks.
45const ALIGN_EPS: f64 = 1.0e-7;
46
47// ── Public result types ──────────────────────────────────────────
48
49/// Whether material bends away from (convex) or wraps around (concave) an
50/// edge, on the final solid.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Convexity {
53    Convex,
54    Concave,
55}
56
57/// Why an edge candidate did not resolve. Frontends surface
58/// [`message`](Self::message) verbatim.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum EdgeReject {
61    /// Proxy/unknown faces, or a face tag that no longer matches its leaf.
62    Unsupported,
63    /// The surfaces intersect in something other than a line or full circle
64    /// (oblique plane∩cylinder ellipse, cylinder∩cylinder, …).
65    UnsupportedCurve,
66    /// Round faces of two leaves under mismatched anisotropic scales: no
67    /// single frame makes both surfaces canonical, so their intersection is
68    /// not a line/circle in any frame. (A single anisotropic leaf — or one
69    /// paired with planes — resolves in its own frame instead.)
70    NonUniformScale,
71    /// The faces don't meet in a crease on the final solid (miss, parallel,
72    /// flush, tangent, or fully consumed by booleans).
73    NotAnEdge,
74    /// The circle exists geometrically but part of it is cut away.
75    PartialCircle,
76    /// Straight concave creases are not supported in v1.
77    StraightConcave,
78}
79
80impl EdgeReject {
81    pub fn message(self) -> &'static str {
82        match self {
83            Self::Unsupported => "Edge not supported on this surface",
84            Self::UnsupportedCurve => "Edge curve not supported (line/circle only)",
85            Self::NonUniformScale => "Mismatched anisotropic scales not supported",
86            Self::NotAnEdge => "No edge between these faces",
87            Self::PartialCircle => "Partial circular edges not supported",
88            Self::StraightConcave => "Concave straight edges not supported",
89        }
90    }
91}
92
93/// Resolved edge geometry, in the evaluation frame.
94#[derive(Clone, Copy, Debug)]
95pub enum EdgeGeom {
96    Straight(StraightEdge),
97    Circular(CircularEdge),
98}
99
100/// A verified straight edge on the final solid, in the evaluation frame.
101#[derive(Clone, Copy, Debug)]
102pub struct StraightEdge {
103    pub start: DVec3,
104    pub end: DVec3,
105    /// Final-solid outward normals of the two faces along the edge.
106    pub na: DVec3,
107    pub nb: DVec3,
108    /// Unit in-face directions away from the edge, along each surviving face.
109    pub da: DVec3,
110    pub db: DVec3,
111    /// How far each face extends from the edge along `da`/`db` (setback room).
112    pub extent_a: f64,
113    pub extent_b: f64,
114}
115
116impl StraightEdge {
117    pub fn dir(&self) -> DVec3 {
118        (self.end - self.start).normalize_or_zero()
119    }
120}
121
122/// A verified full-circle edge (rim), in `frame` coordinates.
123///
124/// The meridian frame at any rim point has x = outward radial (away from the
125/// axis) and y = `axis`. `axis` is oriented as the **planar face's final
126/// outward normal**, so the planar face is always `y = 0` with normal `+y`
127/// in meridian coordinates.
128///
129/// `frame` is identity for rims that are circles in the evaluation frame.
130/// Rims that are *elliptical* in the evaluation frame (anisotropically
131/// scaled leaves) resolve in the owning leaf's local frame, where they are
132/// exact circles; `frame` maps that frame to the evaluation frame, so the
133/// world curve is the frame image of this circle. All fields (center, axis,
134/// radius, meridian data, extents) are frame-local.
135#[derive(Clone, Copy, Debug)]
136pub struct CircularEdge {
137    /// Frame the circle lives in (frame → evaluation coordinates).
138    pub frame: DAffine3,
139    pub center: DVec3,
140    /// Unit circle-plane normal — the planar face's final outward normal.
141    pub axis: DVec3,
142    pub radius: f64,
143    /// Angular origin of the curved leaf's tessellation: its local `+X`
144    /// image (unit, ⊥ `axis`). Masks anchor their polygon phase here so
145    /// their chords coincide with the leaf's facets in the mesh booleans.
146    pub ref_dir: DVec3,
147    /// Segment count of the curved leaf's render tessellation (`None` for
148    /// spheres, which have no prism phase to match).
149    pub leaf_segments: Option<usize>,
150    /// Final outward normal of the curved face, meridian coords.
151    pub nb2: DVec2,
152    /// In-face directions away from the rim, meridian coords. Face A is the
153    /// planar face (`da2.y == 0`).
154    pub da2: DVec2,
155    pub db2: DVec2,
156    /// Setback room along `da2`/`db2`.
157    pub extent_a: f64,
158    pub extent_b: f64,
159}
160
161impl CircularEdge {
162    /// Meridian frame at angle `theta` from `ref_dir` (leaf tessellation
163    /// phase): (outward radial, rim point).
164    pub fn radial_at(&self, theta: f64) -> (DVec3, DVec3) {
165        let (u, v) = (self.ref_dir, self.ref_dir.cross(self.axis));
166        let r_hat = theta.cos() * u + theta.sin() * v;
167        (r_hat, self.center + self.radius * r_hat)
168    }
169}
170
171/// Distance from `p` to the segment `[a, b]`.
172fn point_segment_distance(p: DVec3, a: DVec3, b: DVec3) -> f64 {
173    let ab = b - a;
174    let len_sq = ab.length_squared();
175    if len_sq < 1.0e-24 {
176        return (p - a).length();
177    }
178    let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
179    (p - (a + t * ab)).length()
180}
181
182/// Samples used to approximate distances to frame-mapped (elliptical) rims.
183const ELLIPSE_DISTANCE_SEGMENTS: usize = 64;
184
185impl EdgeGeom {
186    /// Shortest distance from evaluation-frame `p` to the edge curve.
187    /// Analytic for lines and true circles; frame-mapped rims (world
188    /// ellipses) fall back to a fine polyline approximation.
189    pub fn distance_to(&self, p: DVec3) -> f64 {
190        match self {
191            EdgeGeom::Straight(e) => point_segment_distance(p, e.start, e.end),
192            EdgeGeom::Circular(c) if c.frame == DAffine3::IDENTITY => {
193                let rel = p - c.center;
194                let axial = rel.dot(c.axis);
195                let radial = rel - axial * c.axis;
196                let rho = radial.length();
197                if rho < 1.0e-12 {
198                    // On the axis: every rim point is equidistant.
199                    return (axial * axial + c.radius * c.radius).sqrt();
200                }
201                let nearest = c.center + radial / rho * c.radius;
202                (p - nearest).length()
203            }
204            EdgeGeom::Circular(_) => {
205                let pts = self.polyline(ELLIPSE_DISTANCE_SEGMENTS);
206                (0..pts.len())
207                    .map(|i| point_segment_distance(p, pts[i], pts[(i + 1) % pts.len()]))
208                    .fold(f64::INFINITY, f64::min)
209            }
210        }
211    }
212
213    /// Evaluation-frame polyline for viewport highlighting (closed for
214    /// circles; frame-mapped rims trace the world ellipse).
215    pub fn polyline(&self, segments: usize) -> Vec<DVec3> {
216        match self {
217            EdgeGeom::Straight(e) => vec![e.start, e.end],
218            EdgeGeom::Circular(c) => (0..segments.max(3))
219                .map(|i| {
220                    let theta = core::f64::consts::TAU * i as f64 / segments.max(3) as f64;
221                    c.frame.transform_point3(c.radial_at(theta).1)
222                })
223                .collect(),
224        }
225    }
226
227    /// Re-express a resolution done in `frame` coordinates in the evaluation
228    /// frame. Straight edges map exactly (lines stay lines; the in-face
229    /// directions are re-derived orthogonal to the mapped edge). Circles keep
230    /// their frame-local data and compose `frame` (the world curve is the
231    /// frame image — an ellipse in general).
232    fn into_world_from(self, frame: &DAffine3) -> EdgeGeom {
233        match self {
234            EdgeGeom::Straight(e) => {
235                let m = frame.matrix3;
236                let n_it = m.inverse().transpose();
237                let na = (n_it * e.na).normalize_or_zero();
238                let nb = (n_it * e.nb).normalize_or_zero();
239                let start = frame.transform_point3(e.start);
240                let end = frame.transform_point3(e.end);
241                let u = (end - start).normalize_or_zero();
242                // The edge line lies in both face planes, so u ⊥ na, nb: the
243                // world in-face direction is u × n, signed by the mapped one.
244                let redir = |n: DVec3, d_frame: DVec3| {
245                    let c = u.cross(n).normalize_or_zero();
246                    if c.dot(m * d_frame) >= 0.0 { c } else { -c }
247                };
248                EdgeGeom::Straight(StraightEdge {
249                    start,
250                    end,
251                    na,
252                    nb,
253                    da: redir(na, e.da),
254                    db: redir(nb, e.db),
255                    extent_a: (m * (e.da * e.extent_a)).length(),
256                    extent_b: (m * (e.db * e.extent_b)).length(),
257                })
258            }
259            EdgeGeom::Circular(c) => EdgeGeom::Circular(CircularEdge {
260                frame: *frame * c.frame,
261                ..c
262            }),
263        }
264    }
265}
266
267// ── Leaf helpers ─────────────────────────────────────────────────
268
269/// Face tags a leaf primitive exposes (candidate enumeration).
270pub fn leaf_face_tags(prim: &LeafPrim) -> &'static [FaceTag] {
271    match prim {
272        LeafPrim::Cube { .. } => &[
273            FaceTag::CubePosX,
274            FaceTag::CubeNegX,
275            FaceTag::CubePosY,
276            FaceTag::CubeNegY,
277            FaceTag::CubePosZ,
278            FaceTag::CubeNegZ,
279        ],
280        LeafPrim::Cylinder { .. } => &[FaceTag::CylSide, FaceTag::CylTop, FaceTag::CylBottom],
281        LeafPrim::Cone { .. } => &[FaceTag::ConeSide, FaceTag::ConeBottom],
282        LeafPrim::Sphere { .. } => &[FaceTag::Sphere],
283        LeafPrim::Aabb { .. } => &[],
284    }
285}
286
287/// Per-leaf orientation: `true` when the leaf sits under an odd number of
288/// `Difference` subtrahend positions, so its surfaces bound the final solid
289/// with *inverted* normals (hole walls).
290pub fn leaf_flipped(csg: &CsgLeaves) -> Vec<bool> {
291    fn walk(expr: &CsgExpr, flipped: bool, out: &mut Vec<(usize, bool)>) {
292        match expr {
293            CsgExpr::Leaf(i) => out.push((*i, flipped)),
294            CsgExpr::Union(cs) | CsgExpr::Inter(cs) => {
295                cs.iter().for_each(|c| walk(c, flipped, out));
296            }
297            CsgExpr::Diff(cs) => {
298                if let Some((first, rest)) = cs.split_first() {
299                    walk(first, flipped, out);
300                    rest.iter().for_each(|c| walk(c, !flipped, out));
301                }
302            }
303        }
304    }
305    let mut pairs = Vec::with_capacity(csg.leaves.len());
306    walk(&csg.expr, false, &mut pairs);
307    let mut flags = vec![false; csg.leaves.len()];
308    for (i, f) in pairs {
309        flags[i] = f;
310    }
311    flags
312}
313
314/// Evaluation-frame AABB over every leaf (probe scaling).
315pub fn csg_aabb(csg: &CsgLeaves) -> Option<(DVec3, DVec3)> {
316    let mut bounds: Option<(DVec3, DVec3)> = None;
317    for leaf in &csg.leaves {
318        let (lo, hi) = match leaf.prim {
319            LeafPrim::Cube { size } => (DVec3::ZERO, size),
320            LeafPrim::Cylinder { radius, height, .. } | LeafPrim::Cone { radius, height, .. } => (
321                DVec3::new(-radius, 0.0, -radius),
322                DVec3::new(radius, height, radius),
323            ),
324            LeafPrim::Sphere { radius } => (DVec3::splat(-radius), DVec3::splat(radius)),
325            LeafPrim::Aabb { min, max } => (min, max),
326        };
327        for i in 0..8 {
328            let corner = DVec3::new(
329                if i & 1 == 0 { lo.x } else { hi.x },
330                if i & 2 == 0 { lo.y } else { hi.y },
331                if i & 4 == 0 { lo.z } else { hi.z },
332            );
333            let p = leaf.world.transform_point3(corner);
334            bounds = Some(match bounds {
335                None => (p, p),
336                Some((mn, mx)) => (mn.min(p), mx.max(p)),
337            });
338        }
339    }
340    bounds
341}
342
343/// Radial/axial scale factors `(s_r, s_h)` of an affine that keeps a local
344/// Y-axisymmetric surface a surface of revolution: the Gram matrix `MᵀM`
345/// must be `diag(s_r², s_h², s_r²)` — the image frame stays orthogonal with
346/// equal X/Z stretch, so cross-section circles map to circles. `None` when
347/// the linear part shears or squashes the cross-section anisotropically
348/// (circles become ellipses). Tolerances are relative per entry, so extreme
349/// radial/axial aspect ratios don't loosen the radial check.
350fn axisymmetric_scale(world: &DAffine3) -> Option<(f64, f64)> {
351    let m = world.matrix3;
352    let g = m.transpose() * m;
353    let r2 = (g.x_axis.x + g.z_axis.z) / 2.0;
354    let h2 = g.y_axis.y;
355    if r2 <= 0.0 || h2 <= 0.0 {
356        return None;
357    }
358    // |g_xy|, |g_yz| ≤ √(g_xx·g_yy) by Cauchy–Schwarz: bound them mixed.
359    let mix = ALIGN_EPS * (r2 * h2).sqrt();
360    let ok = (g.x_axis.x - r2).abs() < ALIGN_EPS * r2
361        && g.x_axis.z.abs() < ALIGN_EPS * r2
362        && g.x_axis.y.abs() < mix
363        && g.y_axis.z.abs() < mix;
364    ok.then(|| (r2.sqrt(), h2.sqrt()))
365}
366
367/// Uniform scale factor of an affine similarity, or `None` when the linear
368/// part shears/scales non-uniformly (spheres stop being spheres).
369fn similarity_scale(world: &DAffine3) -> Option<f64> {
370    let (s_r, s_h) = axisymmetric_scale(world)?;
371    let (r2, h2) = (s_r * s_r, s_h * s_h);
372    ((r2 - h2).abs() < ALIGN_EPS * r2.max(h2)).then(|| ((2.0 * r2 + h2) / 3.0).sqrt())
373}
374
375// ── Face surfaces ────────────────────────────────────────────────
376
377/// In-plane extent of a planar face, for clipping edge candidates.
378#[derive(Clone, Copy, Debug)]
379enum PlaneExtent {
380    /// Affine image of the unit square: `origin + u·eu + v·ev`, u,v ∈ [0,1].
381    Quad { origin: DVec3, eu: DVec3, ev: DVec3 },
382    /// Disc about `center` (uniform-scale radius).
383    Disc { center: DVec3, radius: f64 },
384}
385
386/// Analytic surface of one leaf face in the evaluation frame. Round surfaces
387/// only exist under similarity transforms (checked at construction).
388#[derive(Clone, Copy, Debug)]
389enum Surface {
390    Plane {
391        point: DVec3,
392        /// Leaf-orientation outward unit normal (flip applied by callers).
393        normal: DVec3,
394        extent: PlaneExtent,
395    },
396    Cylinder {
397        base: DVec3,
398        axis: DVec3,
399        radius: f64,
400        height: f64,
401    },
402    Cone {
403        base: DVec3,
404        axis: DVec3,
405        radius: f64,
406        height: f64,
407    },
408    Sphere {
409        center: DVec3,
410        radius: f64,
411    },
412}
413
414impl Surface {
415    /// Leaf-orientation outward normal at a point on the surface.
416    fn normal_at(&self, p: DVec3) -> DVec3 {
417        match self {
418            Surface::Plane { normal, .. } => *normal,
419            Surface::Cylinder { base, axis, .. } => {
420                let rel = p - *base;
421                (rel - rel.dot(*axis) * *axis).normalize_or_zero()
422            }
423            Surface::Cone {
424                base,
425                axis,
426                radius,
427                height,
428            } => {
429                let rel = p - *base;
430                let r_hat = (rel - rel.dot(*axis) * *axis).normalize_or_zero();
431                if r_hat == DVec3::ZERO {
432                    return *axis;
433                }
434                (r_hat + radius / height * *axis).normalize()
435            }
436            Surface::Sphere { center, .. } => (p - *center).normalize_or_zero(),
437        }
438    }
439
440    /// Project a nearby point back onto the surface (survival probes walk
441    /// along curved faces).
442    fn project(&self, p: DVec3) -> DVec3 {
443        match self {
444            Surface::Plane { point, normal, .. } => p - (p - *point).dot(*normal) * *normal,
445            Surface::Cylinder {
446                base, axis, radius, ..
447            } => {
448                let rel = p - *base;
449                let y = rel.dot(*axis);
450                let radial = rel - y * *axis;
451                let r_hat = radial.normalize_or_zero();
452                *base + y * *axis + *radius * r_hat
453            }
454            Surface::Cone {
455                base,
456                axis,
457                radius,
458                height,
459            } => {
460                let rel = p - *base;
461                let y = rel.dot(*axis).clamp(0.0, *height);
462                let radial = rel - rel.dot(*axis) * *axis;
463                let r_hat = radial.normalize_or_zero();
464                let rho = radius * (1.0 - y / height);
465                *base + y * *axis + rho * r_hat
466            }
467            Surface::Sphere { center, radius } => {
468                *center + (p - *center).normalize_or_zero() * *radius
469            }
470        }
471    }
472}
473
474/// Surface of `(leaf, face)` in the evaluation frame, or a rejection.
475fn face_surface(leaf: &GeomLeaf, face: FaceTag) -> Result<Surface, EdgeReject> {
476    let w = &leaf.world;
477    let normal = |n_local: DVec3| (leaf.world_inv.matrix3.transpose() * n_local).normalize();
478    let quad = |axis: usize, positive: bool, size: DVec3| {
479        let mut corner = DVec3::ZERO;
480        corner[axis] = if positive { size[axis] } else { 0.0 };
481        let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
482        let mut eu = DVec3::ZERO;
483        eu[u] = size[u];
484        let mut ev = DVec3::ZERO;
485        ev[v] = size[v];
486        PlaneExtent::Quad {
487            origin: w.transform_point3(corner),
488            eu: w.matrix3 * eu,
489            ev: w.matrix3 * ev,
490        }
491    };
492    let similarity = || similarity_scale(w).ok_or(EdgeReject::NonUniformScale);
493    let axisym = || axisymmetric_scale(w).ok_or(EdgeReject::NonUniformScale);
494    match (&leaf.prim, face) {
495        (LeafPrim::Cube { size }, tag) => {
496            let (axis, positive) = match tag {
497                FaceTag::CubePosX => (0, true),
498                FaceTag::CubeNegX => (0, false),
499                FaceTag::CubePosY => (1, true),
500                FaceTag::CubeNegY => (1, false),
501                FaceTag::CubePosZ => (2, true),
502                FaceTag::CubeNegZ => (2, false),
503                _ => return Err(EdgeReject::Unsupported),
504            };
505            let mut n_local = DVec3::ZERO;
506            n_local[axis] = if positive { 1.0 } else { -1.0 };
507            let mut on_face = DVec3::ZERO;
508            on_face[axis] = if positive { size[axis] } else { 0.0 };
509            Ok(Surface::Plane {
510                point: w.transform_point3(on_face),
511                normal: normal(n_local),
512                extent: quad(axis, positive, *size),
513            })
514        }
515        (LeafPrim::Cylinder { radius, height, .. }, FaceTag::CylTop | FaceTag::CylBottom) => {
516            let (s_r, _) = axisym()?;
517            let y = if face == FaceTag::CylTop {
518                *height
519            } else {
520                0.0
521            };
522            let n_local = if face == FaceTag::CylTop {
523                DVec3::Y
524            } else {
525                -DVec3::Y
526            };
527            let center = w.transform_point3(DVec3::new(0.0, y, 0.0));
528            Ok(Surface::Plane {
529                point: center,
530                normal: normal(n_local),
531                extent: PlaneExtent::Disc {
532                    center,
533                    radius: radius * s_r,
534                },
535            })
536        }
537        (LeafPrim::Cylinder { radius, height, .. }, FaceTag::CylSide) => {
538            let (s_r, s_h) = axisym()?;
539            Ok(Surface::Cylinder {
540                base: w.transform_point3(DVec3::ZERO),
541                axis: (w.matrix3 * DVec3::Y).normalize(),
542                radius: radius * s_r,
543                height: height * s_h,
544            })
545        }
546        (LeafPrim::Cone { radius, .. }, FaceTag::ConeBottom) => {
547            let (s_r, _) = axisym()?;
548            let center = w.transform_point3(DVec3::ZERO);
549            Ok(Surface::Plane {
550                point: center,
551                normal: normal(-DVec3::Y),
552                extent: PlaneExtent::Disc {
553                    center,
554                    radius: radius * s_r,
555                },
556            })
557        }
558        (LeafPrim::Cone { radius, height, .. }, FaceTag::ConeSide) => {
559            let (s_r, s_h) = axisym()?;
560            Ok(Surface::Cone {
561                base: w.transform_point3(DVec3::ZERO),
562                axis: (w.matrix3 * DVec3::Y).normalize(),
563                radius: radius * s_r,
564                height: height * s_h,
565            })
566        }
567        (LeafPrim::Sphere { radius }, FaceTag::Sphere) => {
568            let s = similarity()?;
569            Ok(Surface::Sphere {
570                center: w.transform_point3(DVec3::ZERO),
571                radius: radius * s,
572            })
573        }
574        _ => Err(EdgeReject::Unsupported),
575    }
576}
577
578// ── Probing ──────────────────────────────────────────────────────
579
580struct Prober<'a> {
581    csg: &'a CsgLeaves,
582    /// Membership-probe offset (model-scaled).
583    delta: f64,
584}
585
586impl<'a> Prober<'a> {
587    fn new(csg: &'a CsgLeaves) -> Self {
588        let diag = csg_aabb(csg)
589            .map(|(mn, mx)| (mx - mn).length())
590            .unwrap_or(1.0)
591            .max(1.0e-6);
592        Self {
593            csg,
594            delta: PROBE_FRAC * diag,
595        }
596    }
597
598    /// `q` lies on the final solid's boundary crease between two faces whose
599    /// summed outward normal is `bisector` (unnormalized OK).
600    fn edge_exists(&self, q: DVec3, bisector: DVec3) -> bool {
601        let b = bisector.normalize_or_zero();
602        if b == DVec3::ZERO {
603            return false;
604        }
605        self.csg.contains(q - self.delta * b) && !self.csg.contains(q + self.delta * b)
606    }
607
608    /// Whether `surface` survives on the final solid just off `q` along `d`
609    /// (walk δ along the face, then straddle it by δ/2).
610    fn face_survives(&self, surface: &Surface, n_final_sign: f64, q: DVec3, d: DVec3) -> bool {
611        let p = surface.project(q + self.delta * d);
612        let n = surface.normal_at(p) * n_final_sign;
613        if n == DVec3::ZERO {
614            return false;
615        }
616        let h = 0.5 * self.delta;
617        self.csg.contains(p - h * n) && !self.csg.contains(p + h * n)
618    }
619
620    /// Pick the in-face direction (out of `±candidate`) along which the face
621    /// survives. Exactly one side must survive.
622    fn orient(
623        &self,
624        surface: &Surface,
625        n_sign: f64,
626        q: DVec3,
627        candidate: DVec3,
628    ) -> Result<DVec3, EdgeReject> {
629        let pos = self.face_survives(surface, n_sign, q, candidate);
630        let neg = self.face_survives(surface, n_sign, q, -candidate);
631        match (pos, neg) {
632            (true, false) => Ok(candidate),
633            (false, true) => Ok(-candidate),
634            _ => Err(EdgeReject::NotAnEdge),
635        }
636    }
637}
638
639/// Convexity from the probed face directions: convex iff each face's
640/// in-face direction points *behind* the other face (`d·n < 0`). The two
641/// faces must agree.
642fn classify(da_dot_nb: f64, db_dot_na: f64) -> Result<Convexity, EdgeReject> {
643    let eps = 1.0e-9;
644    if da_dot_nb < -eps && db_dot_na < -eps {
645        Ok(Convexity::Convex)
646    } else if da_dot_nb > eps && db_dot_na > eps {
647        Ok(Convexity::Concave)
648    } else {
649        Err(EdgeReject::NotAnEdge) // tangent or inconsistent corner
650    }
651}
652
653// ── Extent clipping ──────────────────────────────────────────────
654
655/// Interval of `t` where `q0 + t·dir` stays inside the extent. `dir` need
656/// not be unit. Returns `None` when the line misses entirely.
657fn clip_to_extent(extent: &PlaneExtent, q0: DVec3, dir: DVec3) -> Option<(f64, f64)> {
658    match extent {
659        PlaneExtent::Quad { origin, eu, ev } => {
660            // Dual (gram) solve for the affine params of a point in the plane.
661            let g00 = eu.dot(*eu);
662            let g01 = eu.dot(*ev);
663            let g11 = ev.dot(*ev);
664            let det = g00 * g11 - g01 * g01;
665            if det.abs() < 1.0e-18 {
666                return None;
667            }
668            let params = |p: DVec3| {
669                let r = p - *origin;
670                let (bu, bv) = (r.dot(*eu), r.dot(*ev));
671                ((bu * g11 - bv * g01) / det, (bv * g00 - bu * g01) / det)
672            };
673            let (u0, v0) = params(q0);
674            let (u1, v1) = params(q0 + dir);
675            let (du, dv) = (u1 - u0, v1 - v0);
676            // Edges lie exactly on the face boundary (u or v = 0/1), so the
677            // constant-parameter test needs slack for accumulated transform
678            // error, or every rotated cube edge would clip to nothing.
679            const PARAM_EPS: f64 = 1.0e-9;
680            let mut lo = f64::NEG_INFINITY;
681            let mut hi = f64::INFINITY;
682            for (start, delta) in [(u0, du), (v0, dv)] {
683                if delta.abs() < 1.0e-12 {
684                    if !(-PARAM_EPS..=1.0 + PARAM_EPS).contains(&start) {
685                        return None;
686                    }
687                    continue;
688                }
689                let (t0, t1) = ((0.0 - start) / delta, (1.0 - start) / delta);
690                lo = lo.max(t0.min(t1));
691                hi = hi.min(t0.max(t1));
692            }
693            (lo < hi).then_some((lo, hi))
694        }
695        PlaneExtent::Disc { center, radius } => {
696            let rel = q0 - *center;
697            let a = dir.dot(dir);
698            if a < 1.0e-18 {
699                return (rel.length() <= *radius).then_some((f64::NEG_INFINITY, f64::INFINITY));
700            }
701            let b = 2.0 * rel.dot(dir);
702            let c = rel.dot(rel) - radius * radius;
703            let disc = b * b - 4.0 * a * c;
704            (disc > 0.0).then(|| {
705                let sq = disc.sqrt();
706                ((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a))
707            })
708        }
709    }
710}
711
712/// How far the face extends from `q` along unit `d` (setback room).
713fn extent_along(extent: &PlaneExtent, q: DVec3, d: DVec3) -> f64 {
714    clip_to_extent(extent, q, d).map_or(0.0, |(_, hi)| hi.max(0.0))
715}
716
717// ── Resolution ───────────────────────────────────────────────────
718
719/// Resolve the edge where faces `(leaf_a, face_a)` and `(leaf_b, face_b)`
720/// meet on the final solid. See the module docs for what resolves and why
721/// things reject.
722///
723/// Anisotropically scaled round leaves make the evaluation-frame attempt
724/// reject with [`EdgeReject::NonUniformScale`]; the resolution is then
725/// retried in each round face's leaf frame (where that leaf's surfaces are
726/// canonical), and the result is mapped back — circles keep the frame
727/// ([`CircularEdge::frame`]), straight cap creases map exactly.
728pub fn resolve_edge(
729    csg: &CsgLeaves,
730    a: (usize, FaceTag),
731    b: (usize, FaceTag),
732) -> Result<(EdgeGeom, Convexity), EdgeReject> {
733    let world = resolve_edge_framed(csg, a, b);
734    let Err(EdgeReject::NonUniformScale) = world else {
735        return world;
736    };
737    let mut err = EdgeReject::NonUniformScale;
738    let mut tried: Option<usize> = None;
739    for (li, tag) in [a, b] {
740        // Only round-face leaves define a useful frame (cube faces are
741        // planes under any affine and never reject on scale).
742        let round = matches!(
743            tag,
744            FaceTag::CylSide
745                | FaceTag::CylTop
746                | FaceTag::CylBottom
747                | FaceTag::ConeSide
748                | FaceTag::ConeBottom
749                | FaceTag::Sphere
750        );
751        if !round || tried == Some(li) {
752            continue;
753        }
754        tried = Some(li);
755        let Some(leaf) = csg.leaves.get(li) else {
756            continue;
757        };
758        let frame = leaf.world;
759        let reframed = reframe(csg, &leaf.world_inv, &frame);
760        match resolve_edge_framed(&reframed, a, b) {
761            Ok((geom, convexity)) => return Ok((geom.into_world_from(&frame), convexity)),
762            Err(e) => err = e,
763        }
764    }
765    Err(err)
766}
767
768/// The same solid expressed in the frame `F` (world = `F` · frame coords):
769/// every leaf transform is left-multiplied by `F⁻¹`.
770fn reframe(csg: &CsgLeaves, f_inv: &DAffine3, f: &DAffine3) -> CsgLeaves {
771    CsgLeaves {
772        leaves: csg
773            .leaves
774            .iter()
775            .map(|l| GeomLeaf {
776                prim: l.prim,
777                world: *f_inv * l.world,
778                // (F⁻¹ · W)⁻¹ = W⁻¹ · F
779                world_inv: l.world_inv * *f,
780            })
781            .collect(),
782        expr: csg.expr.clone(),
783        paths: csg.paths.clone(),
784    }
785}
786
787/// One resolution attempt with all geometry read in the given CSG's own
788/// coordinates (the evaluation frame, or a leaf frame after [`reframe`]).
789fn resolve_edge_framed(
790    csg: &CsgLeaves,
791    a: (usize, FaceTag),
792    b: (usize, FaceTag),
793) -> Result<(EdgeGeom, Convexity), EdgeReject> {
794    if a == b {
795        return Err(EdgeReject::NotAnEdge);
796    }
797    let flipped = leaf_flipped(csg);
798    let leaf_a = csg.leaves.get(a.0).ok_or(EdgeReject::Unsupported)?;
799    let leaf_b = csg.leaves.get(b.0).ok_or(EdgeReject::Unsupported)?;
800    let surf_a = face_surface(leaf_a, a.1)?;
801    let surf_b = face_surface(leaf_b, b.1)?;
802    let sign_a = if flipped[a.0] { -1.0 } else { 1.0 };
803    let sign_b = if flipped[b.0] { -1.0 } else { 1.0 };
804    let prober = Prober::new(csg);
805
806    // Same-leaf cylinder/cone rims come from parameters directly (the generic
807    // plane∩surface path would see the cap plane flush with the wall's end).
808    if a.0 == b.0
809        && let Some(circle) = same_leaf_rim(leaf_a, a.1, b.1)?
810    {
811        let plane_first = matches!(
812            (a.1, b.1),
813            (
814                FaceTag::CylTop | FaceTag::CylBottom | FaceTag::ConeBottom,
815                _
816            )
817        );
818        let (plane, curved, sp, sc) = if plane_first {
819            (&surf_a, &surf_b, sign_a, sign_b)
820        } else {
821            (&surf_b, &surf_a, sign_b, sign_a)
822        };
823        return resolve_circle(&prober, circle, plane, curved, leaf_a, sp, sc, true);
824    }
825
826    match (&surf_a, &surf_b) {
827        (Surface::Plane { .. }, Surface::Plane { .. }) => {
828            resolve_line(&prober, &surf_a, &surf_b, sign_a, sign_b)
829        }
830        (Surface::Plane { .. }, _) => {
831            let circle = plane_curved_circle(&surf_a, &surf_b)?;
832            resolve_circle(
833                &prober, circle, &surf_a, &surf_b, leaf_b, sign_a, sign_b, false,
834            )
835        }
836        (_, Surface::Plane { .. }) => {
837            let circle = plane_curved_circle(&surf_b, &surf_a)?;
838            resolve_circle(
839                &prober, circle, &surf_b, &surf_a, leaf_a, sign_b, sign_a, false,
840            )
841        }
842        _ => Err(EdgeReject::UnsupportedCurve),
843    }
844}
845
846/// Same-leaf rim circle from primitive parameters (`None`: not a rim pair).
847fn same_leaf_rim(
848    leaf: &GeomLeaf,
849    ta: FaceTag,
850    tb: FaceTag,
851) -> Result<Option<(DVec3, DVec3, f64)>, EdgeReject> {
852    use FaceTag as F;
853    let pair = |x: FaceTag, y: FaceTag| (ta == x && tb == y) || (ta == y && tb == x);
854    let (y_local, radius_local) = match &leaf.prim {
855        LeafPrim::Cylinder { radius, height, .. } => {
856            if pair(F::CylSide, F::CylTop) {
857                (*height, *radius)
858            } else if pair(F::CylSide, F::CylBottom) {
859                (0.0, *radius)
860            } else {
861                return if pair(F::CylTop, F::CylBottom) {
862                    Err(EdgeReject::NotAnEdge)
863                } else {
864                    Ok(None)
865                };
866            }
867        }
868        LeafPrim::Cone { radius, .. } if pair(F::ConeSide, F::ConeBottom) => (0.0, *radius),
869        _ => return Ok(None),
870    };
871    let (s_r, _) = axisymmetric_scale(&leaf.world).ok_or(EdgeReject::NonUniformScale)?;
872    let center = leaf.world.transform_point3(DVec3::new(0.0, y_local, 0.0));
873    let axis = (leaf.world.matrix3 * DVec3::Y).normalize();
874    Ok(Some((center, axis, radius_local * s_r)))
875}
876
877/// Circle where a plane meets a curved surface, or a typed rejection.
878/// Returns `(center, plane_normal_of_circle, radius)` (axis unsigned).
879fn plane_curved_circle(
880    plane: &Surface,
881    curved: &Surface,
882) -> Result<(DVec3, DVec3, f64), EdgeReject> {
883    let Surface::Plane { point, normal, .. } = plane else {
884        return Err(EdgeReject::UnsupportedCurve);
885    };
886    match curved {
887        Surface::Cylinder {
888            base,
889            axis,
890            radius,
891            height,
892        } => {
893            if normal.dot(*axis).abs() < 1.0 - ALIGN_EPS {
894                return Err(EdgeReject::UnsupportedCurve); // oblique → ellipse
895            }
896            // Closed range: a plane flush with a cap still meets the wall in
897            // that cap's rim circle (boss bases, stacked parts) — the
898            // existence probes decide whether the crease is real.
899            let y = (*point - *base).dot(*axis);
900            let tol = 1.0e-9 * height.max(1.0);
901            if !(-tol..=height + tol).contains(&y) {
902                return Err(EdgeReject::NotAnEdge);
903            }
904            Ok((*base + y.clamp(0.0, *height) * *axis, *axis, *radius))
905        }
906        Surface::Cone {
907            base,
908            axis,
909            radius,
910            height,
911        } => {
912            if normal.dot(*axis).abs() < 1.0 - ALIGN_EPS {
913                return Err(EdgeReject::UnsupportedCurve);
914            }
915            let y = (*point - *base).dot(*axis);
916            let tol = 1.0e-9 * height.max(1.0);
917            // Open at the apex (a point, not a circle); closed at the base.
918            if y < -tol || y >= *height - tol {
919                return Err(EdgeReject::NotAnEdge);
920            }
921            let y = y.max(0.0);
922            Ok((*base + y * *axis, *axis, radius * (1.0 - y / height)))
923        }
924        Surface::Sphere { center, radius } => {
925            let d = (*center - *point).dot(*normal);
926            if d.abs() >= *radius {
927                return Err(EdgeReject::NotAnEdge);
928            }
929            let r = (radius * radius - d * d).sqrt();
930            if r < 1.0e-9 * radius {
931                return Err(EdgeReject::NotAnEdge); // tangent point
932            }
933            Ok((*center - d * *normal, *normal, r))
934        }
935        _ => Err(EdgeReject::UnsupportedCurve),
936    }
937}
938
939/// Resolve a straight plane∩plane edge: intersect, clip to both faces,
940/// verify against the final solid, trim to the surviving run.
941fn resolve_line(
942    prober: &Prober,
943    surf_a: &Surface,
944    surf_b: &Surface,
945    sign_a: f64,
946    sign_b: f64,
947) -> Result<(EdgeGeom, Convexity), EdgeReject> {
948    let (
949        Surface::Plane {
950            point: pa,
951            normal: na_leaf,
952            extent: ext_a,
953        },
954        Surface::Plane {
955            point: pb,
956            normal: nb_leaf,
957            extent: ext_b,
958        },
959    ) = (surf_a, surf_b)
960    else {
961        return Err(EdgeReject::UnsupportedCurve);
962    };
963    let na = sign_a * *na_leaf;
964    let nb = sign_b * *nb_leaf;
965    let u = na_leaf.cross(*nb_leaf);
966    let u_len = u.length();
967    if u_len < 1.0e-9 {
968        return Err(EdgeReject::NotAnEdge); // parallel/coplanar faces
969    }
970    let u_hat = u / u_len;
971    // Point on both planes: q = (d1·(n2×u) − d2·(n1×u)) / |u|².
972    let (d1, d2) = (pa.dot(*na_leaf), pb.dot(*nb_leaf));
973    let q0 = (d1 * nb_leaf.cross(u) - d2 * na_leaf.cross(u)) / (u_len * u_len);
974    debug_assert!((q0.dot(*na_leaf) - d1).abs() < 1.0e-6);
975    debug_assert!((q0.dot(*nb_leaf) - d2).abs() < 1.0e-6);
976
977    // Clip to both faces' extents.
978    let (a_lo, a_hi) = clip_to_extent(ext_a, q0, u_hat).ok_or(EdgeReject::NotAnEdge)?;
979    let (b_lo, b_hi) = clip_to_extent(ext_b, q0, u_hat).ok_or(EdgeReject::NotAnEdge)?;
980    let (lo, hi) = (a_lo.max(b_lo), a_hi.min(b_hi));
981    if !(hi - lo).is_finite() || hi - lo < prober.delta {
982        return Err(EdgeReject::NotAnEdge);
983    }
984
985    // Verify samples on the final solid; keep the longest surviving run.
986    let bisector = na + nb;
987    if bisector.length_squared() < 1.0e-12 {
988        return Err(EdgeReject::NotAnEdge); // flush faces, no crease
989    }
990    let at = |t: f64| q0 + t * u_hat;
991    let sample_t: Vec<f64> = (0..LINE_SAMPLES)
992        .map(|i| {
993            let f = (i as f64 + 0.5) / LINE_SAMPLES as f64;
994            lo + f * (hi - lo)
995        })
996        .collect();
997    let alive: Vec<bool> = sample_t
998        .iter()
999        .map(|&t| prober.edge_exists(at(t), bisector))
1000        .collect();
1001    let (run_start, run_len) = longest_run(&alive).ok_or(EdgeReject::NotAnEdge)?;
1002
1003    // Trim the run's outer bounds: clipped ends when fully alive, else
1004    // bisect between the last alive and first dead sample.
1005    let exists = |t: f64| prober.edge_exists(at(t), bisector);
1006    let t_start = if run_start == 0 {
1007        lo
1008    } else {
1009        bisect(sample_t[run_start], sample_t[run_start - 1], exists)
1010    };
1011    let run_end = run_start + run_len - 1;
1012    let t_end = if run_end + 1 == LINE_SAMPLES {
1013        hi
1014    } else {
1015        bisect(sample_t[run_end], sample_t[run_end + 1], exists)
1016    };
1017    if t_end - t_start < prober.delta {
1018        return Err(EdgeReject::NotAnEdge);
1019    }
1020
1021    // Orient the in-face directions at the run midpoint and classify.
1022    let q_mid = at(0.5 * (t_start + t_end));
1023    let da = prober.orient(surf_a, sign_a, q_mid, u_hat.cross(na).normalize())?;
1024    let db = prober.orient(surf_b, sign_b, q_mid, u_hat.cross(nb).normalize())?;
1025    let convexity = classify(da.dot(nb), db.dot(na))?;
1026    if convexity == Convexity::Concave {
1027        return Err(EdgeReject::StraightConcave);
1028    }
1029
1030    let edge = StraightEdge {
1031        start: at(t_start),
1032        end: at(t_end),
1033        na,
1034        nb,
1035        da,
1036        db,
1037        extent_a: extent_along(ext_a, q_mid, da),
1038        extent_b: extent_along(ext_b, q_mid, db),
1039    };
1040    Ok((EdgeGeom::Straight(edge), convexity))
1041}
1042
1043/// Resolve a circular rim: verify every sample, orient the meridian
1044/// directions, classify convexity.
1045#[allow(clippy::too_many_arguments)]
1046fn resolve_circle(
1047    prober: &Prober,
1048    (center, axis_raw, radius): (DVec3, DVec3, f64),
1049    plane: &Surface,
1050    curved: &Surface,
1051    curved_leaf: &GeomLeaf,
1052    sign_plane: f64,
1053    sign_curved: f64,
1054    same_leaf: bool,
1055) -> Result<(EdgeGeom, Convexity), EdgeReject> {
1056    let Surface::Plane {
1057        normal: np_leaf,
1058        extent: ext_p,
1059        ..
1060    } = plane
1061    else {
1062        return Err(EdgeReject::UnsupportedCurve);
1063    };
1064    // The circle axis, oriented as the planar face's FINAL outward normal.
1065    let np_final = sign_plane * *np_leaf;
1066    let axis = if axis_raw.dot(np_final) >= 0.0 {
1067        axis_raw
1068    } else {
1069        -axis_raw
1070    };
1071    // Tessellation phase of the curved leaf: its local +X, ⊥ the axis. Masks
1072    // anchored here share the leaf's chord positions in mesh booleans.
1073    let ref_dir = {
1074        let mx = curved_leaf.world.matrix3 * DVec3::X;
1075        let perp = mx - mx.dot(axis) * axis;
1076        let len = perp.length();
1077        if len > 1.0e-12 {
1078            perp / len
1079        } else {
1080            plane_basis(axis).0
1081        }
1082    };
1083    let leaf_segments = match curved_leaf.prim {
1084        LeafPrim::Cylinder { segments, .. } | LeafPrim::Cone { segments, .. } => Some(segments),
1085        _ => None,
1086    };
1087    let (bu, bv) = plane_basis(axis);
1088
1089    // Containment in the planar face (skipped for same-leaf rims, where the
1090    // rim IS the disc boundary) + existence at every sample.
1091    let mut nb2_acc = DVec2::ZERO;
1092    for i in 0..CIRCLE_SAMPLES {
1093        let theta = core::f64::consts::TAU * i as f64 / CIRCLE_SAMPLES as f64;
1094        let r_hat = theta.cos() * bu + theta.sin() * bv;
1095        let q = center + radius * r_hat;
1096        if !same_leaf {
1097            let inside = match ext_p {
1098                PlaneExtent::Quad { .. } => {
1099                    clip_to_extent(ext_p, q, r_hat).is_some_and(|(lo, hi)| lo <= 0.0 && hi >= 0.0)
1100                }
1101                PlaneExtent::Disc {
1102                    center: c,
1103                    radius: r,
1104                } => (q - *c).length() <= *r + 1.0e-9,
1105            };
1106            if !inside {
1107                return Err(EdgeReject::PartialCircle);
1108            }
1109        }
1110        let nb3 = curved.normal_at(q) * sign_curved;
1111        if !prober.edge_exists(q, np_final + nb3) {
1112            return Err(EdgeReject::PartialCircle);
1113        }
1114        nb2_acc += DVec2::new(nb3.dot(r_hat), nb3.dot(axis));
1115    }
1116    let nb2 = (nb2_acc / CIRCLE_SAMPLES as f64).normalize_or_zero();
1117    if nb2 == DVec2::ZERO {
1118        return Err(EdgeReject::NotAnEdge);
1119    }
1120
1121    // Orientation probes at θ = 0 (meridian frame: x = radial, y = axis).
1122    let r_hat = bu;
1123    let q = center + radius * r_hat;
1124    let da3 = prober.orient(plane, sign_plane, q, r_hat)?;
1125    let da2 = DVec2::new(da3.dot(r_hat), 0.0);
1126    // Curved-face meridian tangent candidates (along the surface's own axis,
1127    // which may oppose the circle axis — e.g. a bottom-cap rim).
1128    let tangent = match curved {
1129        Surface::Cylinder { axis: ca, .. } => *ca,
1130        Surface::Cone {
1131            axis: ca,
1132            radius,
1133            height,
1134            ..
1135        } => {
1136            let k = radius / height;
1137            (*ca - k * r_hat).normalize()
1138        }
1139        Surface::Sphere { center: c, .. } => {
1140            let n = (q - *c).normalize_or_zero();
1141            // Meridian tangent ⊥ sphere normal, in the (r̂, axis) plane.
1142            (axis - axis.dot(n) * n).normalize_or_zero()
1143        }
1144        Surface::Plane { .. } => return Err(EdgeReject::UnsupportedCurve),
1145    };
1146    if tangent == DVec3::ZERO {
1147        return Err(EdgeReject::NotAnEdge);
1148    }
1149    let db3 = prober.orient(curved, sign_curved, q, tangent)?;
1150    let db2 = DVec2::new(db3.dot(r_hat), db3.dot(axis)).normalize_or_zero();
1151
1152    // Meridian normals: planar face is +y by construction.
1153    let na2 = DVec2::Y;
1154    let convexity = classify(da2.dot(nb2), db2.dot(na2))?;
1155
1156    // Setback room: planar face along da3; curved face along its meridian.
1157    let extent_a = extent_along(ext_p, q, da3);
1158    let extent_b = match curved {
1159        Surface::Cylinder {
1160            base,
1161            axis: ca,
1162            height,
1163            ..
1164        } => {
1165            let y = (q - *base).dot(*ca);
1166            if db3.dot(*ca) >= 0.0 { height - y } else { y }
1167        }
1168        Surface::Cone { radius, height, .. } => (radius * radius + height * height).sqrt(),
1169        Surface::Sphere { radius, .. } => *radius,
1170        Surface::Plane { .. } => 0.0,
1171    };
1172
1173    let edge = CircularEdge {
1174        frame: DAffine3::IDENTITY,
1175        center,
1176        axis,
1177        radius,
1178        ref_dir,
1179        leaf_segments,
1180        nb2,
1181        da2,
1182        db2,
1183        extent_a,
1184        extent_b: extent_b.max(0.0),
1185    };
1186    Ok((EdgeGeom::Circular(edge), convexity))
1187}
1188
1189// ── Small utilities ──────────────────────────────────────────────
1190
1191/// Longest run of `true` in `flags`: `(start, len)`.
1192fn longest_run(flags: &[bool]) -> Option<(usize, usize)> {
1193    let mut best: Option<(usize, usize)> = None;
1194    let mut cur: Option<(usize, usize)> = None;
1195    for (i, &f) in flags.iter().enumerate() {
1196        cur = match (cur, f) {
1197            (None, true) => Some((i, 1)),
1198            (Some((s, n)), true) => Some((s, n + 1)),
1199            (_, false) => None,
1200        };
1201        if let Some((_, n)) = cur
1202            && best.is_none_or(|(_, bn)| n > bn)
1203        {
1204            best = cur;
1205        }
1206    }
1207    best
1208}
1209
1210/// Bisect between a passing `t_ok` and failing `t_bad` parameter.
1211fn bisect(mut t_ok: f64, mut t_bad: f64, exists: impl Fn(f64) -> bool) -> f64 {
1212    for _ in 0..TRIM_ITERS {
1213        let mid = 0.5 * (t_ok + t_bad);
1214        if exists(mid) {
1215            t_ok = mid;
1216        } else {
1217            t_bad = mid;
1218        }
1219    }
1220    t_ok
1221}
1222
1223/// Orthonormal basis of the plane with unit normal `n`.
1224pub(crate) fn plane_basis(n: DVec3) -> (DVec3, DVec3) {
1225    let helper = if n.y.abs() < 0.9 { DVec3::Y } else { DVec3::X };
1226    let u = n.cross(helper).normalize();
1227    (u, n.cross(u))
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233
1234    fn cube(size: [f64; 3]) -> DynamicObject {
1235        DynamicObject::Cube { size }
1236    }
1237
1238    fn cylinder(radius: f64, height: f64) -> DynamicObject {
1239        DynamicObject::Cylinder {
1240            radius,
1241            height,
1242            segments: 32,
1243        }
1244    }
1245
1246    fn translate(offset: [f64; 3], child: DynamicObject) -> DynamicObject {
1247        DynamicObject::Translate {
1248            offset,
1249            child: Box::new(child),
1250        }
1251    }
1252
1253    fn leaves(obj: &DynamicObject) -> CsgLeaves {
1254        CsgLeaves::from_dynamic(obj, DAffine3::IDENTITY, None)
1255    }
1256
1257    /// Plate with a through-hole: leaf 0 = plate cube, leaf 1 = hole cylinder.
1258    fn plate_with_hole() -> DynamicObject {
1259        DynamicObject::Difference {
1260            children: vec![
1261                cube([4.0, 1.0, 4.0]),
1262                translate([2.0, -0.5, 2.0], cylinder(0.5, 2.0)),
1263            ],
1264        }
1265    }
1266
1267    /// Boss on a plate: leaf 0 = plate, leaf 1 = boss cylinder on top.
1268    fn boss_on_plate() -> DynamicObject {
1269        DynamicObject::Union {
1270            children: vec![
1271                cube([4.0, 1.0, 4.0]),
1272                translate([2.0, 1.0, 2.0], cylinder(0.5, 1.0)),
1273            ],
1274        }
1275    }
1276
1277    #[test]
1278    fn cube_adjacent_faces_resolve_convex_lines() {
1279        let csg = leaves(&cube([1.0, 2.0, 3.0]));
1280        let adjacent = [
1281            (FaceTag::CubePosX, FaceTag::CubePosY),
1282            (FaceTag::CubePosX, FaceTag::CubeNegZ),
1283            (FaceTag::CubeNegY, FaceTag::CubeNegZ),
1284        ];
1285        for (fa, fb) in adjacent {
1286            let (geom, convexity) =
1287                resolve_edge(&csg, (0, fa), (0, fb)).expect("adjacent faces share an edge");
1288            assert_eq!(convexity, Convexity::Convex, "{fa:?}/{fb:?}");
1289            let EdgeGeom::Straight(e) = geom else {
1290                panic!("cube edge must be straight");
1291            };
1292            // Endpoints on both face planes and a full-length run.
1293            let expect_len = match (fa, fb) {
1294                (FaceTag::CubePosX, FaceTag::CubePosY) => 3.0,
1295                (FaceTag::CubePosX, FaceTag::CubeNegZ) => 2.0,
1296                _ => 1.0,
1297            };
1298            assert!(((e.end - e.start).length() - expect_len).abs() < 1e-9);
1299        }
1300        // Opposite faces are parallel: no edge.
1301        assert_eq!(
1302            resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubeNegX)).unwrap_err(),
1303            EdgeReject::NotAnEdge
1304        );
1305    }
1306
1307    #[test]
1308    fn cube_edge_directions_point_into_faces() {
1309        let csg = leaves(&cube([1.0, 1.0, 1.0]));
1310        let (geom, _) = resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubePosY)).unwrap();
1311        let EdgeGeom::Straight(e) = geom else {
1312            panic!("straight");
1313        };
1314        assert!((e.na - DVec3::X).length() < 1e-9);
1315        assert!((e.nb - DVec3::Y).length() < 1e-9);
1316        // Into face A (x = 1 plane): downward; into face B (y = 1): -x.
1317        assert!(e.da.dot(DVec3::Y) < -0.99);
1318        assert!(e.db.dot(DVec3::X) < -0.99);
1319        assert!((e.extent_a - 1.0).abs() < 1e-9);
1320        assert!((e.extent_b - 1.0).abs() < 1e-9);
1321    }
1322
1323    #[test]
1324    fn same_leaf_cylinder_rims() {
1325        let csg = leaves(&cylinder(1.0, 2.0));
1326        let (geom, convexity) =
1327            resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1328        assert_eq!(convexity, Convexity::Convex);
1329        let EdgeGeom::Circular(c) = geom else {
1330            panic!("rim must be circular");
1331        };
1332        assert!((c.center - DVec3::new(0.0, 2.0, 0.0)).length() < 1e-9);
1333        assert!((c.axis - DVec3::Y).length() < 1e-9, "axis = cap outward");
1334        assert!((c.radius - 1.0).abs() < 1e-9);
1335        // Plane face setback runs inward; wall setback runs down.
1336        assert!(c.da2.x < -0.99);
1337        assert!(c.db2.y < -0.99);
1338
1339        let (_, convexity) =
1340            resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylBottom)).unwrap();
1341        assert_eq!(convexity, Convexity::Convex);
1342
1343        assert_eq!(
1344            resolve_edge(&csg, (0, FaceTag::CylTop), (0, FaceTag::CylBottom)).unwrap_err(),
1345            EdgeReject::NotAnEdge
1346        );
1347    }
1348
1349    #[test]
1350    fn cone_base_rim_resolves() {
1351        let csg = leaves(&DynamicObject::Cone {
1352            radius: 1.0,
1353            height: 2.0,
1354            segments: 32,
1355        });
1356        let (geom, convexity) =
1357            resolve_edge(&csg, (0, FaceTag::ConeSide), (0, FaceTag::ConeBottom)).unwrap();
1358        assert_eq!(convexity, Convexity::Convex);
1359        let EdgeGeom::Circular(c) = geom else {
1360            panic!("circular");
1361        };
1362        assert!((c.radius - 1.0).abs() < 1e-9);
1363        assert!(
1364            (c.axis + DVec3::Y).length() < 1e-9,
1365            "axis = base outward (-Y)"
1366        );
1367        // Wall setback climbs the slant: radially inward and axially away
1368        // from the base plane in meridian coords.
1369        assert!(c.db2.x < 0.0 && c.db2.y < 0.0);
1370    }
1371
1372    #[test]
1373    fn hole_rim_is_convex_circle_with_outward_plane_dir() {
1374        let obj = plate_with_hole();
1375        let csg = leaves(&obj);
1376        let (geom, convexity) =
1377            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap();
1378        assert_eq!(convexity, Convexity::Convex, "hole rims are chamferable");
1379        let EdgeGeom::Circular(c) = geom else {
1380            panic!("circular");
1381        };
1382        assert!((c.center - DVec3::new(2.0, 1.0, 2.0)).length() < 1e-9);
1383        assert!((c.radius - 0.5).abs() < 1e-9);
1384        assert!((c.axis - DVec3::Y).length() < 1e-9);
1385        // The plate surface survives *outside* the hole; the wall runs down.
1386        assert!(c.da2.x > 0.99);
1387        assert!(c.db2.y < -0.99);
1388    }
1389
1390    #[test]
1391    fn boss_base_ring_is_concave() {
1392        let csg = leaves(&boss_on_plate());
1393        let (geom, convexity) =
1394            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap();
1395        assert_eq!(
1396            convexity,
1397            Convexity::Concave,
1398            "boss-base ring is a fillet case"
1399        );
1400        let EdgeGeom::Circular(c) = geom else {
1401            panic!("circular");
1402        };
1403        assert!(c.da2.x > 0.99, "plate survives outside the boss");
1404        assert!(c.db2.y > 0.99, "boss wall survives upward");
1405    }
1406
1407    #[test]
1408    fn straight_concave_rejected() {
1409        // L-bracket: two cubes sharing a concave inner edge along Z at x=1,y=1.
1410        let obj = DynamicObject::Union {
1411            children: vec![
1412                cube([2.0, 1.0, 2.0]),
1413                translate([0.0, 1.0, 0.0], cube([1.0, 1.0, 2.0])),
1414            ],
1415        };
1416        let csg = leaves(&obj);
1417        assert_eq!(
1418            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CubePosX)).unwrap_err(),
1419            EdgeReject::StraightConcave
1420        );
1421    }
1422
1423    #[test]
1424    fn oblique_and_curved_pairs_rejected() {
1425        // Tilted cylinder through a cube top: oblique plane∩cylinder.
1426        let obj = DynamicObject::Union {
1427            children: vec![
1428                cube([4.0, 1.0, 4.0]),
1429                translate(
1430                    [2.0, 0.0, 2.0],
1431                    DynamicObject::Rotate {
1432                        angles: [30.0, 0.0, 0.0],
1433                        child: Box::new(cylinder(0.5, 3.0)),
1434                    },
1435                ),
1436            ],
1437        };
1438        let csg = leaves(&obj);
1439        assert_eq!(
1440            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap_err(),
1441            EdgeReject::UnsupportedCurve
1442        );
1443        // Cylinder∩cylinder is never a supported curve.
1444        let obj = DynamicObject::Union {
1445            children: vec![
1446                cylinder(1.0, 2.0),
1447                translate([1.0, 0.0, 0.0], cylinder(1.0, 2.0)),
1448            ],
1449        };
1450        let csg = leaves(&obj);
1451        assert_eq!(
1452            resolve_edge(&csg, (0, FaceTag::CylSide), (1, FaceTag::CylSide)).unwrap_err(),
1453            EdgeReject::UnsupportedCurve
1454        );
1455    }
1456
1457    #[test]
1458    fn mismatched_anisotropic_frames_reject() {
1459        // A boss cylinder standing on a squashed cylinder's cap: the cap's
1460        // elliptical extent needs leaf A's frame, the boss wall stays round
1461        // only in leaf B's — no single frame fits both.
1462        let obj = DynamicObject::Union {
1463            children: vec![
1464                DynamicObject::Scale {
1465                    factors: [2.0, 1.0, 1.0],
1466                    child: Box::new(cylinder(1.0, 2.0)),
1467                },
1468                translate([0.0, 2.0, 0.0], cylinder(0.25, 1.0)),
1469            ],
1470        };
1471        let csg = leaves(&obj);
1472        assert_eq!(
1473            resolve_edge(&csg, (0, FaceTag::CylTop), (1, FaceTag::CylSide)).unwrap_err(),
1474            EdgeReject::NonUniformScale
1475        );
1476        // Cube faces stay planes under any affine: edges still resolve.
1477        let obj = DynamicObject::Scale {
1478            factors: [2.0, 1.0, 1.0],
1479            child: Box::new(cube([1.0, 1.0, 1.0])),
1480        };
1481        let csg = leaves(&obj);
1482        assert!(resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubePosY)).is_ok());
1483    }
1484
1485    #[test]
1486    fn elliptical_rim_resolves_in_leaf_frame() {
1487        // [2,1,1] squashes the cross-section: the rim is a world ellipse,
1488        // resolved as a unit circle in the leaf's frame.
1489        let obj = DynamicObject::Scale {
1490            factors: [2.0, 1.0, 1.0],
1491            child: Box::new(cylinder(1.0, 2.0)),
1492        };
1493        let csg = leaves(&obj);
1494        let (geom, convexity) =
1495            resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1496        assert_eq!(convexity, Convexity::Convex);
1497        let EdgeGeom::Circular(c) = geom else {
1498            panic!("circular");
1499        };
1500        assert_ne!(c.frame, DAffine3::IDENTITY);
1501        assert!((c.radius - 1.0).abs() < 1e-9, "frame-local unit radius");
1502        // The world polyline traces the ellipse x²/4 + z² = 1 at y = 2.
1503        for p in geom.polyline(24) {
1504            assert!((p.y - 2.0).abs() < 1e-9);
1505            assert!((p.x * p.x / 4.0 + p.z * p.z - 1.0).abs() < 1e-9);
1506        }
1507        // World distances follow the ellipse (near the semi-major vertex).
1508        let d = geom.distance_to(DVec3::new(2.1, 2.0, 0.0));
1509        assert!((d - 0.1).abs() < 0.01, "distance to ellipse vertex: {d}");
1510    }
1511
1512    #[test]
1513    fn spheroid_dome_rim_resolves() {
1514        // A [1,2,1]-scaled sphere sunk into a plate: an ellipsoid in world,
1515        // a unit sphere in its own frame — the dome ring resolves there.
1516        let obj = DynamicObject::Union {
1517            children: vec![
1518                cube([4.0, 1.0, 4.0]),
1519                translate(
1520                    [2.0, 0.5, 2.0],
1521                    DynamicObject::Scale {
1522                        factors: [1.0, 2.0, 1.0],
1523                        child: Box::new(DynamicObject::Sphere {
1524                            radius: 1.0,
1525                            segments: 32,
1526                            stacks: 16,
1527                        }),
1528                    },
1529                ),
1530            ],
1531        };
1532        let csg = leaves(&obj);
1533        let (geom, convexity) =
1534            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::Sphere)).unwrap();
1535        assert_eq!(convexity, Convexity::Concave, "dome meets plate concavely");
1536        let EdgeGeom::Circular(c) = geom else {
1537            panic!("circular");
1538        };
1539        // Plate top y = 1 is local y = 0.25 in the sphere frame: the ring
1540        // radius is √(1 − 0.25²), unscaled in world x/z.
1541        let expect = (1.0f64 - 0.0625).sqrt();
1542        assert!((c.radius - expect).abs() < 1e-9);
1543        for p in geom.polyline(16) {
1544            assert!((p.y - 1.0).abs() < 1e-9);
1545            let rho = DVec2::new(p.x - 2.0, p.z - 2.0).length();
1546            assert!((rho - expect).abs() < 1e-9);
1547        }
1548    }
1549
1550    #[test]
1551    fn cap_chord_straight_edge_maps_from_frame() {
1552        // Squashed cylinder with its x > 0 half cut away: the cap meets the
1553        // cut wall in a straight chord — resolved in the leaf frame (the
1554        // cap's extent is elliptical in world) and mapped back exactly.
1555        let obj = DynamicObject::Difference {
1556            children: vec![
1557                DynamicObject::Scale {
1558                    factors: [2.0, 1.0, 1.0],
1559                    child: Box::new(cylinder(1.0, 2.0)),
1560                },
1561                translate([0.0, -1.0, -3.0], cube([3.0, 4.0, 6.0])),
1562            ],
1563        };
1564        let csg = leaves(&obj);
1565        let (geom, convexity) =
1566            resolve_edge(&csg, (0, FaceTag::CylTop), (1, FaceTag::CubeNegX)).unwrap();
1567        assert_eq!(convexity, Convexity::Convex);
1568        let EdgeGeom::Straight(e) = geom else {
1569            panic!("straight chord");
1570        };
1571        // Chord x = 0, y = 2, z ∈ [−1, 1] (the ellipse's minor vertices).
1572        assert!(e.start.x.abs() < 1e-6 && e.end.x.abs() < 1e-6);
1573        assert!((e.start.y - 2.0).abs() < 1e-6 && (e.end.y - 2.0).abs() < 1e-6);
1574        assert!(((e.end - e.start).length() - 2.0).abs() < 1e-3);
1575        assert!((e.na - DVec3::Y).length() < 1e-6, "cap normal up");
1576        assert!((e.nb - DVec3::X).length() < 1e-6, "cut wall normal +x");
1577        // In-face directions: away from the cut into the cap (−x), and up
1578        // isn't possible on the wall — material survives below? No: the wall
1579        // is the hole side; its surviving in-face direction runs down (−y).
1580        assert!(e.da.dot(DVec3::X) < -0.99);
1581        assert!(e.db.dot(DVec3::Y) < -0.99);
1582        assert!(e.extent_a > 0.5 && e.extent_b > 0.5);
1583    }
1584
1585    #[test]
1586    fn axisymmetric_scale_keeps_rims_circular() {
1587        // Stretched taller ([1,3,1]): cross-section untouched, rim stays a
1588        // unit circle at the scaled height.
1589        let obj = DynamicObject::Scale {
1590            factors: [1.0, 3.0, 1.0],
1591            child: Box::new(cylinder(1.0, 2.0)),
1592        };
1593        let csg = leaves(&obj);
1594        let (geom, convexity) =
1595            resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1596        assert_eq!(convexity, Convexity::Convex);
1597        let EdgeGeom::Circular(c) = geom else {
1598            panic!("circular");
1599        };
1600        assert!((c.center - DVec3::new(0.0, 6.0, 0.0)).length() < 1e-9);
1601        assert!((c.radius - 1.0).abs() < 1e-9);
1602
1603        // Fattened uniformly across the cross-section ([2,1,2]).
1604        let obj = DynamicObject::Scale {
1605            factors: [2.0, 1.0, 2.0],
1606            child: Box::new(cylinder(1.0, 2.0)),
1607        };
1608        let csg = leaves(&obj);
1609        let (geom, _) = resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylBottom)).unwrap();
1610        let EdgeGeom::Circular(c) = geom else {
1611            panic!("circular");
1612        };
1613        assert!(c.center.length() < 1e-9);
1614        assert!((c.radius - 2.0).abs() < 1e-9);
1615
1616        // Cone base rim under [2,1,2]: radius doubles, slant changes, still
1617        // a circle.
1618        let obj = DynamicObject::Scale {
1619            factors: [2.0, 1.0, 2.0],
1620            child: Box::new(DynamicObject::Cone {
1621                radius: 1.0,
1622                height: 2.0,
1623                segments: 32,
1624            }),
1625        };
1626        let csg = leaves(&obj);
1627        let (geom, convexity) =
1628            resolve_edge(&csg, (0, FaceTag::ConeSide), (0, FaceTag::ConeBottom)).unwrap();
1629        assert_eq!(convexity, Convexity::Convex);
1630        let EdgeGeom::Circular(c) = geom else {
1631            panic!("circular");
1632        };
1633        assert!((c.radius - 2.0).abs() < 1e-9);
1634    }
1635
1636    #[test]
1637    fn axisymmetric_scale_composes_with_rotation() {
1638        // World Scale[2,2,1] over Rotate(x, 90°): the cylinder axis lands on
1639        // Z (unit world stretch) and the cross-section spans world X/Y
1640        // (scaled 2×) — axisymmetric about the *leaf* axis even though the
1641        // scale wrapper alone is not.
1642        let obj = DynamicObject::Scale {
1643            factors: [2.0, 2.0, 1.0],
1644            child: Box::new(DynamicObject::Rotate {
1645                angles: [90.0, 0.0, 0.0],
1646                child: Box::new(cylinder(1.0, 2.0)),
1647            }),
1648        };
1649        let csg = leaves(&obj);
1650        let (geom, convexity) =
1651            resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1652        assert_eq!(convexity, Convexity::Convex);
1653        let EdgeGeom::Circular(c) = geom else {
1654            panic!("circular");
1655        };
1656        assert!((c.radius - 2.0).abs() < 1e-9);
1657        assert!(c.axis.cross(DVec3::Z).length() < 1e-9);
1658        assert!((c.center - DVec3::new(0.0, 0.0, 2.0)).length() < 1e-9);
1659
1660        // The same wrapper pair with the scale squashing the cross-section
1661        // ([1,2,1] over the rotated cylinder → world X vs Y anisotropy):
1662        // resolves through the leaf frame as a world ellipse at z = 2.
1663        let obj = DynamicObject::Scale {
1664            factors: [1.0, 2.0, 1.0],
1665            child: Box::new(DynamicObject::Rotate {
1666                angles: [90.0, 0.0, 0.0],
1667                child: Box::new(cylinder(1.0, 2.0)),
1668            }),
1669        };
1670        let csg = leaves(&obj);
1671        let (geom, _) = resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1672        let EdgeGeom::Circular(c) = geom else {
1673            panic!("circular");
1674        };
1675        assert_ne!(c.frame, DAffine3::IDENTITY);
1676        for p in geom.polyline(16) {
1677            assert!((p.z - 2.0).abs() < 1e-9);
1678            assert!((p.x * p.x + p.y * p.y / 4.0 - 1.0).abs() < 1e-9);
1679        }
1680    }
1681
1682    #[test]
1683    fn scaled_hole_rim_resolves() {
1684        // Plate with a hole whose cylinder is stretched deeper: the rim on
1685        // the plate top is still an exact circle (cross-leaf pair).
1686        let obj = DynamicObject::Difference {
1687            children: vec![
1688                cube([4.0, 1.0, 4.0]),
1689                translate(
1690                    [2.0, -0.5, 2.0],
1691                    DynamicObject::Scale {
1692                        factors: [1.0, 2.0, 1.0],
1693                        child: Box::new(cylinder(0.5, 1.0)),
1694                    },
1695                ),
1696            ],
1697        };
1698        let csg = leaves(&obj);
1699        let (geom, convexity) =
1700            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap();
1701        assert_eq!(convexity, Convexity::Convex);
1702        let EdgeGeom::Circular(c) = geom else {
1703            panic!("circular");
1704        };
1705        assert!((c.center - DVec3::new(2.0, 1.0, 2.0)).length() < 1e-9);
1706        assert!((c.radius - 0.5).abs() < 1e-9);
1707    }
1708
1709    #[test]
1710    fn partial_circle_rejected_when_hole_leaves_plate() {
1711        // Hole centered on the plate boundary: half the rim is air.
1712        let obj = DynamicObject::Difference {
1713            children: vec![
1714                cube([4.0, 1.0, 4.0]),
1715                translate([4.0, -0.5, 2.0], cylinder(0.5, 2.0)),
1716            ],
1717        };
1718        let csg = leaves(&obj);
1719        assert_eq!(
1720            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap_err(),
1721            EdgeReject::PartialCircle
1722        );
1723    }
1724
1725    #[test]
1726    fn consumed_edge_rejected_and_partial_edge_trimmed() {
1727        // A slab that shaves the top of the cube: the old top edges are gone.
1728        let consumed = DynamicObject::Difference {
1729            children: vec![
1730                cube([2.0, 2.0, 2.0]),
1731                translate([-1.0, 1.5, -1.0], cube([4.0, 2.0, 4.0])),
1732            ],
1733        };
1734        let csg = leaves(&consumed);
1735        assert!(
1736            resolve_edge(&csg, (0, FaceTag::CubePosY), (0, FaceTag::CubePosX)).is_err(),
1737            "an edge shaved off entirely must not resolve"
1738        );
1739
1740        // A notch eating half the edge: the surviving run is trimmed.
1741        let notched = DynamicObject::Difference {
1742            children: vec![
1743                cube([2.0, 2.0, 2.0]),
1744                translate([1.0, 1.0, 1.0], cube([2.0, 2.0, 2.0])),
1745            ],
1746        };
1747        let csg = leaves(&notched);
1748        let (geom, convexity) =
1749            resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubePosY)).unwrap();
1750        assert_eq!(convexity, Convexity::Convex);
1751        let EdgeGeom::Straight(e) = geom else {
1752            panic!("straight");
1753        };
1754        // Original edge spans z ∈ [0, 2] at (2, 2, z); the notch removes z > 1.
1755        let len = (e.end - e.start).length();
1756        assert!(
1757            (len - 1.0).abs() < 0.05,
1758            "surviving run ≈ half the edge, got {len}"
1759        );
1760        let z_max = e.start.z.max(e.end.z);
1761        assert!(z_max < 1.05, "run must stay on the surviving side");
1762    }
1763
1764    #[test]
1765    fn sphere_plane_circle_resolves() {
1766        // Sphere sunk halfway into a plate top.
1767        let obj = DynamicObject::Union {
1768            children: vec![
1769                cube([4.0, 1.0, 4.0]),
1770                translate(
1771                    [2.0, 0.5, 2.0],
1772                    DynamicObject::Sphere {
1773                        radius: 1.0,
1774                        segments: 32,
1775                        stacks: 16,
1776                    },
1777                ),
1778            ],
1779        };
1780        let csg = leaves(&obj);
1781        let (geom, convexity) =
1782            resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::Sphere)).unwrap();
1783        assert_eq!(convexity, Convexity::Concave, "dome meets plate concavely");
1784        let EdgeGeom::Circular(c) = geom else {
1785            panic!("circular");
1786        };
1787        // Circle at plate-top height: radius √(1 − 0.5²).
1788        assert!((c.radius - (1.0f64 - 0.25).sqrt()).abs() < 1e-9);
1789        assert!((c.center - DVec3::new(2.0, 1.0, 2.0)).length() < 1e-9);
1790    }
1791
1792    #[test]
1793    fn leaf_flipped_tracks_difference_nesting() {
1794        // a − (b − c): b flipped once, c twice (back to unflipped).
1795        let obj = DynamicObject::Difference {
1796            children: vec![
1797                cube([4.0, 4.0, 4.0]),
1798                DynamicObject::Difference {
1799                    children: vec![
1800                        translate([1.0, 1.0, 1.0], cube([2.0, 2.0, 2.0])),
1801                        translate([1.5, 1.5, 1.5], cube([1.0, 1.0, 1.0])),
1802                    ],
1803                },
1804            ],
1805        };
1806        let csg = leaves(&obj);
1807        assert_eq!(leaf_flipped(&csg), vec![false, true, false]);
1808    }
1809
1810    #[test]
1811    fn distance_and_polyline_helpers() {
1812        let csg = leaves(&cylinder(1.0, 2.0));
1813        let (geom, _) = resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1814        // A point just outside the rim.
1815        assert!((geom.distance_to(DVec3::new(1.1, 2.0, 0.0)) - 0.1).abs() < 1e-9);
1816        let poly = geom.polyline(16);
1817        assert_eq!(poly.len(), 16);
1818        for p in poly {
1819            assert!((p.y - 2.0).abs() < 1e-9);
1820            assert!((DVec3::new(p.x, 0.0, p.z).length() - 1.0).abs() < 1e-9);
1821        }
1822    }
1823}