Skip to main content

rscad_core/
dynamic.rs

1//! Dynamically-typed CSG object tree.
2//!
3//! [`DynamicObject`] is a serializable, runtime-typed representation of a CSG
4//! object tree.  It serves as the common intermediate representation for:
5//!
6//! - Serialized scene files (RON, JSON)
7//! - OpenSCAD evaluation output
8//!
9//! Every variant maps to an rscad-core concept but uses runtime typing
10//! instead of Rust generics, making it serializable and constructable at
11//! runtime (e.g. from an OpenSCAD evaluator).
12//!
13//! The [`ToDynamic`] trait converts statically-typed rscad-core objects into
14//! `DynamicObject`.  Types without a `DynamicObject` equivalent (e.g.
15//! `Pattern`, `Offset`, `Projection`) intentionally do **not**
16//! implement the trait — attempting to convert them is a compile-time error.
17
18use std::collections::BTreeMap;
19
20use crate::prelude_::*;
21
22/// A dynamically-typed CSG object node.
23///
24/// Implements [`Object`] so it can be used anywhere a static rscad-core object
25/// can, and can be serialized/deserialized when the `serde` feature is enabled.
26#[derive(Debug, Clone, PartialEq)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub enum DynamicObject {
29    // ── 3D Primitives ─────────────────────────────────────
30    Cube {
31        size: [f64; 3],
32    },
33    Sphere {
34        radius: f64,
35        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
36        segments: usize,
37        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
38        stacks: usize,
39    },
40    Cylinder {
41        radius: f64,
42        height: f64,
43        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
44        segments: usize,
45    },
46    Cone {
47        radius: f64,
48        height: f64,
49        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
50        segments: usize,
51    },
52
53    // ── 2D Primitives (for extrusion) ─────────────────────
54    Circle {
55        radius: f64,
56        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
57        segments: usize,
58    },
59    Square {
60        size: [f64; 2],
61    },
62    Polygon {
63        points: Vec<[f64; 2]>,
64    },
65    /// Constraint-based 2D sketch (see [`crate::sketch`]). Lives in its local
66    /// XZ plane like the other 2D primitives.
67    Sketch {
68        data: crate::sketch::SketchData,
69    },
70
71    // ── Boolean Operations ────────────────────────────────
72    /// Implicit union of all children.
73    Union {
74        children: Vec<DynamicObject>,
75    },
76    /// First child minus all subsequent children.
77    Difference {
78        children: Vec<DynamicObject>,
79    },
80    /// Intersection of all children.
81    Intersection {
82        children: Vec<DynamicObject>,
83    },
84
85    // ── Transformations ───────────────────────────────────
86    Translate {
87        offset: [f64; 3],
88        child: Box<DynamicObject>,
89    },
90    /// Rotation in degrees (OpenSCAD convention).
91    Rotate {
92        angles: [f64; 3],
93        child: Box<DynamicObject>,
94    },
95    Scale {
96        factors: [f64; 3],
97        child: Box<DynamicObject>,
98    },
99    Mirror {
100        axis: [f64; 3],
101        child: Box<DynamicObject>,
102    },
103    /// RGBA color (0.0–1.0).
104    Color {
105        color: [f64; 4],
106        child: Box<DynamicObject>,
107    },
108    /// PBR surface finish (metallic/roughness); albedo stays in [`Self::Color`].
109    Material {
110        material: crate::material::MaterialData,
111        child: Box<DynamicObject>,
112    },
113
114    // ── Operations ────────────────────────────────────────
115    LinearExtrude {
116        height: f64,
117        #[cfg_attr(feature = "serde", serde(default))]
118        twist: f64,
119        #[cfg_attr(feature = "serde", serde(default = "default_scale"))]
120        scale: f64,
121        #[cfg_attr(feature = "serde", serde(default = "default_slices"))]
122        slices: usize,
123        child: Box<DynamicObject>,
124    },
125    /// Revolve the child 2D profile about its local Y axis (OpenSCAD
126    /// `rotate_extrude`). Profile-x is the radius (x ≥ 0 expected).
127    Revolve {
128        #[cfg_attr(feature = "serde", serde(default = "default_angle"))]
129        angle_degrees: f64,
130        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
131        segments: usize,
132        child: Box<DynamicObject>,
133    },
134    /// Grow/shrink the child 2D profile by `delta` (OpenSCAD `offset()`).
135    Offset {
136        delta: f64,
137        /// Round corners (`offset(r=…)`); when set, `chamfer` is ignored.
138        #[cfg_attr(feature = "serde", serde(default))]
139        round: bool,
140        /// Cut off miter spikes (`offset(delta=…, chamfer=true)`).
141        #[cfg_attr(feature = "serde", serde(default))]
142        chamfer: bool,
143        /// Arc tessellation for round joins.
144        #[cfg_attr(feature = "serde", serde(default = "default_segments"))]
145        segments: usize,
146        child: Box<DynamicObject>,
147    },
148    /// Chamfer/fillet the child's selected edges. Backends never see this
149    /// variant directly — it lowers to plain CSG masks against the child
150    /// ([`crate::feature::lower`]), re-resolved on every evaluation so the
151    /// feature follows parameter edits.
152    EdgeFeature {
153        data: crate::feature::EdgeFeatureData,
154        child: Box<DynamicObject>,
155    },
156
157    // ── Grouping ──────────────────────────────────────────
158    /// Implicit union of children (convenience wrapper).
159    Group {
160        children: Vec<DynamicObject>,
161    },
162    /// Empty geometry — produces no polygons.
163    Empty,
164}
165
166#[cfg(feature = "serde")]
167fn default_segments() -> usize {
168    32
169}
170
171#[cfg(feature = "serde")]
172fn default_scale() -> f64 {
173    1.0
174}
175
176#[cfg(feature = "serde")]
177fn default_slices() -> usize {
178    1
179}
180
181#[cfg(feature = "serde")]
182fn default_angle() -> f64 {
183    360.0
184}
185
186// ── Top-level transform extraction ──────────────────────────────────────────
187
188/// Result of peeling off top-level transforms from a [`DynamicObject`] tree.
189///
190/// Transforms above the first boolean/primitive/extrude/group/mirror node are
191/// accumulated into a [`DAffine3`] matrix.  The outermost `Color` node (if any)
192/// is extracted separately for use as a material `base_color`.
193pub struct ExtractedTransforms<'a> {
194    /// Accumulated spatial transform (translate × rotate × scale).
195    pub transform: DAffine3,
196    /// Extracted RGBA color (0.0–1.0), outermost `Color` node wins.
197    pub color: Option<[f64; 4]>,
198    /// Extracted surface finish, outermost `Material` node wins.
199    pub material: Option<crate::material::MaterialData>,
200    /// Reference to the remaining subtree below the peeled transforms.
201    pub inner: &'a DynamicObject,
202}
203
204impl DynamicObject {
205    /// Peel off top-level `Translate`/`Rotate`/`Scale`/`Color` nodes that are
206    /// **not** under any boolean operation, producing an accumulated affine
207    /// matrix, an optional color, and a reference to the remaining inner
208    /// subtree.
209    ///
210    /// Extraction stops at booleans (`Union`, `Difference`, `Intersection`),
211    /// `Mirror` (negative scale breaks face winding), `LinearExtrude`, `Group`,
212    /// primitives, and `Empty`.
213    pub fn extract_top_transforms(&self) -> ExtractedTransforms<'_> {
214        let mut transform = DAffine3::IDENTITY;
215        let mut color: Option<[f64; 4]> = None;
216        let mut material: Option<crate::material::MaterialData> = None;
217        let mut current = self;
218
219        loop {
220            match current {
221                DynamicObject::Translate { offset, child } => {
222                    transform *= DAffine3::from_translation(DVec3::from_array(*offset));
223                    current = child;
224                }
225                DynamicObject::Rotate { angles, child } => {
226                    // Rz·Ry·Rx — the OpenSCAD `rotate()` convention shared by
227                    // every CSG backend and the SDF compiler.
228                    let q = DQuat::from_euler(
229                        EulerRot::ZYX,
230                        angles[2].to_radians(),
231                        angles[1].to_radians(),
232                        angles[0].to_radians(),
233                    );
234                    transform *= DAffine3::from_quat(q);
235                    current = child;
236                }
237                DynamicObject::Scale { factors, child } => {
238                    transform *= DAffine3::from_scale(DVec3::from_array(*factors));
239                    current = child;
240                }
241                DynamicObject::Color { color: c, child } => {
242                    if color.is_none() {
243                        color = Some(*c);
244                    }
245                    current = child;
246                }
247                DynamicObject::Material { material: m, child } => {
248                    if material.is_none() {
249                        material = Some(m.clone());
250                    }
251                    current = child;
252                }
253                _ => break,
254            }
255        }
256
257        ExtractedTransforms {
258            transform,
259            color,
260            material,
261            inner: current,
262        }
263    }
264}
265
266// ── Scene file types ────────────────────────────────────────────────────────
267
268/// A scene file containing one or more named CSG trees.
269///
270/// Trees are stored as a [`BTreeMap`] keyed by label so that object names are
271/// preserved in the serialized file and ordering is deterministic.
272///
273/// When loading, a bare [`DynamicObject`] is automatically promoted to a
274/// single-tree `SceneFile` via [`SceneFile::from_single`].
275#[derive(Debug, Clone, PartialEq)]
276#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
277pub struct SceneFile {
278    pub trees: BTreeMap<String, DynamicObject>,
279}
280
281impl SceneFile {
282    /// Wrap a single [`DynamicObject`] as a one-tree scene file.
283    pub fn from_single(obj: DynamicObject) -> Self {
284        Self {
285            trees: BTreeMap::from([("Scene".into(), obj)]),
286        }
287    }
288}
289
290impl Object for DynamicObject {
291    fn name(&self) -> Option<&'static str> {
292        Some(match self {
293            Self::Cube { .. } => "Cube",
294            Self::Sphere { .. } => "Sphere",
295            Self::Cylinder { .. } => "Cylinder",
296            Self::Cone { .. } => "Cone",
297            Self::Circle { .. } => "Circle",
298            Self::Square { .. } => "Square",
299            Self::Polygon { .. } => "Polygon",
300            Self::Sketch { .. } => "Sketch",
301            Self::Union { .. } => "Union",
302            Self::Difference { .. } => "Difference",
303            Self::Intersection { .. } => "Intersection",
304            Self::Translate { .. } => "Translate",
305            Self::Rotate { .. } => "Rotate",
306            Self::Scale { .. } => "Scale",
307            Self::Mirror { .. } => "Mirror",
308            Self::Color { .. } => "Color",
309            Self::Material { .. } => "Material",
310            Self::LinearExtrude { .. } => "LinearExtrude",
311            Self::Revolve { .. } => "Revolve",
312            Self::Offset { .. } => "Offset",
313            Self::EdgeFeature { data, .. } => data.kind.display_name(),
314            Self::Group { .. } => "Group",
315            Self::Empty => "Empty",
316        })
317    }
318}
319
320// ── ToDynamic trait + impls ──────────────────────────────────────────────────
321
322/// Convert a statically-typed rscad-core object into a [`DynamicObject`].
323pub trait ToDynamic {
324    fn to_dynamic(&self) -> DynamicObject;
325}
326
327impl ToDynamic for Cube {
328    fn to_dynamic(&self) -> DynamicObject {
329        DynamicObject::Cube {
330            size: [self.x, self.y, self.z],
331        }
332    }
333}
334
335impl ToDynamic for Sphere {
336    fn to_dynamic(&self) -> DynamicObject {
337        DynamicObject::Sphere {
338            radius: self.radius,
339            segments: self.fn_,
340            stacks: self.stacks,
341        }
342    }
343}
344
345impl ToDynamic for Cylinder {
346    fn to_dynamic(&self) -> DynamicObject {
347        DynamicObject::Cylinder {
348            radius: self.radius,
349            height: self.height,
350            segments: self.fn_,
351        }
352    }
353}
354
355impl ToDynamic for crate::Circle {
356    fn to_dynamic(&self) -> DynamicObject {
357        DynamicObject::Circle {
358            radius: self.radius,
359            segments: self.fn_,
360        }
361    }
362}
363
364impl ToDynamic for crate::Square {
365    fn to_dynamic(&self) -> DynamicObject {
366        DynamicObject::Square {
367            size: [self.x, self.y],
368        }
369    }
370}
371
372impl<T: Object + ToDynamic> ToDynamic for Translate<T> {
373    fn to_dynamic(&self) -> DynamicObject {
374        DynamicObject::Translate {
375            offset: self.translation.into(),
376            child: Box::new(self.object.to_dynamic()),
377        }
378    }
379}
380
381impl<T: Object + ToDynamic> ToDynamic for Rotate<T> {
382    fn to_dynamic(&self) -> DynamicObject {
383        DynamicObject::Rotate {
384            angles: self.rotation.into(),
385            child: Box::new(self.object.to_dynamic()),
386        }
387    }
388}
389
390impl<T: Object + ToDynamic> ToDynamic for Scale<T> {
391    fn to_dynamic(&self) -> DynamicObject {
392        DynamicObject::Scale {
393            factors: self.scale.into(),
394            child: Box::new(self.object.to_dynamic()),
395        }
396    }
397}
398
399impl<T: Object + ToDynamic> ToDynamic for Mirror<T> {
400    fn to_dynamic(&self) -> DynamicObject {
401        DynamicObject::Mirror {
402            axis: self.mirror.into(),
403            child: Box::new(self.object.to_dynamic()),
404        }
405    }
406}
407
408impl<T: Object + ToDynamic> ToDynamic for Colored<T> {
409    fn to_dynamic(&self) -> DynamicObject {
410        DynamicObject::Color {
411            color: [
412                self.color.r as f64 / 255.0,
413                self.color.g as f64 / 255.0,
414                self.color.b as f64 / 255.0,
415                self.color.a as f64 / 255.0,
416            ],
417            child: Box::new(self.object.to_dynamic()),
418        }
419    }
420}
421
422impl<A: Object + ToDynamic, B: Object + ToDynamic> ToDynamic for Union<A, B> {
423    fn to_dynamic(&self) -> DynamicObject {
424        let mut children = Vec::new();
425        flatten_union(&self.this.to_dynamic(), &mut children);
426        flatten_union(&self.other.to_dynamic(), &mut children);
427        DynamicObject::Union { children }
428    }
429}
430
431impl<A: Object + ToDynamic, B: Object + ToDynamic> ToDynamic for Difference<A, B> {
432    fn to_dynamic(&self) -> DynamicObject {
433        DynamicObject::Difference {
434            children: vec![self.this.to_dynamic(), self.other.to_dynamic()],
435        }
436    }
437}
438
439impl<A: Object + ToDynamic, B: Object + ToDynamic> ToDynamic for Intersection<A, B> {
440    fn to_dynamic(&self) -> DynamicObject {
441        DynamicObject::Intersection {
442            children: vec![self.this.to_dynamic(), self.other.to_dynamic()],
443        }
444    }
445}
446
447/// Flatten nested `Union` DynamicObjects into a single children vec.
448fn flatten_union(node: &DynamicObject, out: &mut Vec<DynamicObject>) {
449    match node {
450        DynamicObject::Union { children } => {
451            for child in children {
452                flatten_union(child, out);
453            }
454        }
455        other => out.push(other.clone()),
456    }
457}
458
459impl<O: Object + ToDynamic> ToDynamic for crate::extrude::LinearExtrude<O> {
460    fn to_dynamic(&self) -> DynamicObject {
461        DynamicObject::LinearExtrude {
462            height: self.height,
463            twist: self.twist,
464            scale: self.scale,
465            slices: self.slices,
466            child: Box::new(self.object.to_dynamic()),
467        }
468    }
469}
470
471impl<O: Object + ToDynamic> ToDynamic for crate::extrude::RotateExtrude<O> {
472    fn to_dynamic(&self) -> DynamicObject {
473        DynamicObject::Revolve {
474            angle_degrees: self.angle_degrees,
475            segments: self.fn_,
476            child: Box::new(self.object.to_dynamic()),
477        }
478    }
479}
480
481impl<A: Object + ToDynamic, B: Object + ToDynamic> ToDynamic for TwoContainer<A, B> {
482    fn to_dynamic(&self) -> DynamicObject {
483        DynamicObject::Group {
484            children: vec![self.a.to_dynamic(), self.b.to_dynamic()],
485        }
486    }
487}
488
489impl ToDynamic for Empty {
490    fn to_dynamic(&self) -> DynamicObject {
491        DynamicObject::Empty
492    }
493}
494
495impl ToDynamic for DynamicObject {
496    fn to_dynamic(&self) -> DynamicObject {
497        self.clone()
498    }
499}
500
501impl<T: ToDynamic> ToDynamic for Box<T> {
502    fn to_dynamic(&self) -> DynamicObject {
503        (**self).to_dynamic()
504    }
505}
506
507impl<T: ToDynamic> ToDynamic for Option<T> {
508    fn to_dynamic(&self) -> DynamicObject {
509        match self {
510            Some(inner) => inner.to_dynamic(),
511            None => DynamicObject::Empty,
512        }
513    }
514}
515
516impl<T: ToDynamic> ToDynamic for Vec<T> {
517    fn to_dynamic(&self) -> DynamicObject {
518        DynamicObject::Group {
519            children: self.iter().map(ToDynamic::to_dynamic).collect(),
520        }
521    }
522}
523
524impl<T: ToDynamic, const N: usize> ToDynamic for [T; N] {
525    fn to_dynamic(&self) -> DynamicObject {
526        DynamicObject::Group {
527            children: self.iter().map(ToDynamic::to_dynamic).collect(),
528        }
529    }
530}
531
532impl ToDynamic for () {
533    fn to_dynamic(&self) -> DynamicObject {
534        DynamicObject::Empty
535    }
536}
537
538macro_rules! impl_to_dynamic_tuple {
539    ($($name:ident),+) => {
540        impl<$($name: ToDynamic),+> ToDynamic for ($($name,)+) {
541            fn to_dynamic(&self) -> DynamicObject {
542                #[allow(non_snake_case)]
543                let ($($name,)+) = self;
544                DynamicObject::Group {
545                    children: vec![$($name.to_dynamic()),+],
546                }
547            }
548        }
549    };
550}
551
552impl_to_dynamic_tuple!(A);
553impl_to_dynamic_tuple!(A, B);
554impl_to_dynamic_tuple!(A, B, C);
555impl_to_dynamic_tuple!(A, B, C, D);
556impl_to_dynamic_tuple!(A, B, C, D, E);
557impl_to_dynamic_tuple!(A, B, C, D, E, F);
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::extrude::LinearExtrudeObject;
563
564    #[test]
565    fn cube_converts() {
566        let cube = Cube {
567            x: 10.0,
568            y: 20.0,
569            z: 30.0,
570        };
571        let node = cube.to_dynamic();
572        assert!(matches!(
573            node,
574            DynamicObject::Cube {
575                size: [10.0, 20.0, 30.0]
576            }
577        ));
578    }
579
580    #[test]
581    fn translated_cube_converts() {
582        let obj = Cube {
583            x: 1.0,
584            y: 1.0,
585            z: 1.0,
586        }
587        .translate(5.0, 0.0, 0.0);
588        let node = obj.to_dynamic();
589        assert!(matches!(node, DynamicObject::Translate { .. }));
590    }
591
592    #[test]
593    fn union_flattens() {
594        let a = Cube {
595            x: 1.0,
596            y: 1.0,
597            z: 1.0,
598        };
599        let b = Cube {
600            x: 2.0,
601            y: 2.0,
602            z: 2.0,
603        };
604        let c = Cube {
605            x: 3.0,
606            y: 3.0,
607            z: 3.0,
608        };
609        let obj = a.union(b).union(c);
610        let node = obj.to_dynamic();
611        match node {
612            DynamicObject::Union { children } => assert_eq!(children.len(), 3),
613            other => panic!("expected Union, got {other:?}"),
614        }
615    }
616
617    #[test]
618    fn colored_converts_u8_to_f64() {
619        let obj = Cube {
620            x: 1.0,
621            y: 1.0,
622            z: 1.0,
623        }
624        .colored(Color::RED);
625        let node = obj.to_dynamic();
626        match node {
627            DynamicObject::Color { color, .. } => {
628                assert!((color[0] - 1.0).abs() < f64::EPSILON);
629                assert!((color[1]).abs() < f64::EPSILON);
630                assert!((color[2]).abs() < f64::EPSILON);
631                assert!((color[3] - 1.0).abs() < f64::EPSILON);
632            }
633            other => panic!("expected Color, got {other:?}"),
634        }
635    }
636
637    #[test]
638    fn linear_extrude_converts() {
639        let obj = crate::Circle {
640            radius: 5.0,
641            fa: 0.0,
642            fs: 0.0,
643            fn_: 32,
644        }
645        .linear_extrude(10.0);
646        let node = obj.to_dynamic();
647        match node {
648            DynamicObject::LinearExtrude { height, child, .. } => {
649                assert!((height - 10.0).abs() < f64::EPSILON);
650                assert!(matches!(*child, DynamicObject::Circle { .. }));
651            }
652            other => panic!("expected LinearExtrude, got {other:?}"),
653        }
654    }
655
656    #[test]
657    fn rotate_extrude_converts() {
658        use crate::extrude::RotateExtrudeObject;
659        let obj = crate::Circle {
660            radius: 5.0,
661            fa: 0.0,
662            fs: 0.0,
663            fn_: 32,
664        }
665        .rotate_extrude()
666        .with_angle(180.0);
667        let node = obj.to_dynamic();
668        match node {
669            DynamicObject::Revolve {
670                angle_degrees,
671                child,
672                ..
673            } => {
674                assert!((angle_degrees - 180.0).abs() < f64::EPSILON);
675                assert!(matches!(*child, DynamicObject::Circle { .. }));
676            }
677            other => panic!("expected Revolve, got {other:?}"),
678        }
679    }
680
681    #[test]
682    fn empty_converts() {
683        assert!(matches!(Empty.to_dynamic(), DynamicObject::Empty));
684    }
685
686    #[test]
687    fn option_none_is_empty() {
688        let obj: Option<Cube> = None;
689        assert!(matches!(obj.to_dynamic(), DynamicObject::Empty));
690    }
691
692    #[test]
693    fn two_container_becomes_group() {
694        let obj = Cube {
695            x: 1.0,
696            y: 1.0,
697            z: 1.0,
698        }
699        .and(Sphere {
700            radius: 2.0,
701            stacks: 16,
702            fn_: 16,
703        });
704        let node = obj.to_dynamic();
705        match node {
706            DynamicObject::Group { children } => assert_eq!(children.len(), 2),
707            other => panic!("expected Group, got {other:?}"),
708        }
709    }
710
711    #[test]
712    fn dynamic_object_implements_object() {
713        let obj = DynamicObject::Cube {
714            size: [1.0, 2.0, 3.0],
715        };
716        assert_eq!(obj.name(), Some("Cube"));
717    }
718
719    // ── extract_top_transforms tests ─────────────────────────────
720
721    #[test]
722    fn extract_identity_from_primitive() {
723        let obj = DynamicObject::Cube {
724            size: [1.0, 2.0, 3.0],
725        };
726        let ext = obj.extract_top_transforms();
727        assert_eq!(ext.transform, DAffine3::IDENTITY);
728        assert!(ext.color.is_none());
729        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
730    }
731
732    #[test]
733    fn extract_single_translate() {
734        let obj = DynamicObject::Translate {
735            offset: [1.0, 2.0, 3.0],
736            child: Box::new(DynamicObject::Cube {
737                size: [1.0, 1.0, 1.0],
738            }),
739        };
740        let ext = obj.extract_top_transforms();
741        let expected = DAffine3::from_translation(DVec3::new(1.0, 2.0, 3.0));
742        assert!((ext.transform.translation - expected.translation).length() < 1e-10);
743        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
744    }
745
746    #[test]
747    fn extract_nested_transforms() {
748        // Translate(Rotate(Scale(Cube)))
749        let obj = DynamicObject::Translate {
750            offset: [10.0, 0.0, 0.0],
751            child: Box::new(DynamicObject::Rotate {
752                angles: [0.0, 0.0, 90.0],
753                child: Box::new(DynamicObject::Scale {
754                    factors: [2.0, 2.0, 2.0],
755                    child: Box::new(DynamicObject::Cube {
756                        size: [1.0, 1.0, 1.0],
757                    }),
758                }),
759            }),
760        };
761        let ext = obj.extract_top_transforms();
762        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
763        // Translation should be [10, 0, 0]
764        assert!((ext.transform.translation.x - 10.0).abs() < 1e-10);
765    }
766
767    #[test]
768    fn extract_stops_at_boolean() {
769        let obj = DynamicObject::Translate {
770            offset: [5.0, 0.0, 0.0],
771            child: Box::new(DynamicObject::Union {
772                children: vec![
773                    DynamicObject::Cube {
774                        size: [1.0, 1.0, 1.0],
775                    },
776                    DynamicObject::Sphere {
777                        radius: 1.0,
778                        segments: 16,
779                        stacks: 8,
780                    },
781                ],
782            }),
783        };
784        let ext = obj.extract_top_transforms();
785        assert!((ext.transform.translation.x - 5.0).abs() < 1e-10);
786        assert!(matches!(ext.inner, DynamicObject::Union { .. }));
787    }
788
789    #[test]
790    fn extract_stops_at_mirror() {
791        let obj = DynamicObject::Translate {
792            offset: [5.0, 0.0, 0.0],
793            child: Box::new(DynamicObject::Mirror {
794                axis: [1.0, 0.0, 0.0],
795                child: Box::new(DynamicObject::Cube {
796                    size: [1.0, 1.0, 1.0],
797                }),
798            }),
799        };
800        let ext = obj.extract_top_transforms();
801        assert!((ext.transform.translation.x - 5.0).abs() < 1e-10);
802        assert!(matches!(ext.inner, DynamicObject::Mirror { .. }));
803    }
804
805    #[test]
806    fn extract_color() {
807        let obj = DynamicObject::Color {
808            color: [1.0, 0.0, 0.0, 1.0],
809            child: Box::new(DynamicObject::Cube {
810                size: [1.0, 1.0, 1.0],
811            }),
812        };
813        let ext = obj.extract_top_transforms();
814        assert_eq!(ext.color, Some([1.0, 0.0, 0.0, 1.0]));
815        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
816    }
817
818    #[test]
819    fn extract_color_interleaved_with_transform() {
820        // Translate(Color(Rotate(Cube)))
821        let obj = DynamicObject::Translate {
822            offset: [5.0, 0.0, 0.0],
823            child: Box::new(DynamicObject::Color {
824                color: [0.0, 1.0, 0.0, 1.0],
825                child: Box::new(DynamicObject::Rotate {
826                    angles: [90.0, 0.0, 0.0],
827                    child: Box::new(DynamicObject::Cube {
828                        size: [1.0, 1.0, 1.0],
829                    }),
830                }),
831            }),
832        };
833        let ext = obj.extract_top_transforms();
834        assert!((ext.transform.translation.x - 5.0).abs() < 1e-10);
835        assert_eq!(ext.color, Some([0.0, 1.0, 0.0, 1.0]));
836        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
837    }
838
839    #[test]
840    fn extract_boolean_at_root() {
841        let obj = DynamicObject::Union {
842            children: vec![DynamicObject::Cube {
843                size: [1.0, 1.0, 1.0],
844            }],
845        };
846        let ext = obj.extract_top_transforms();
847        assert_eq!(ext.transform, DAffine3::IDENTITY);
848        assert!(ext.color.is_none());
849        assert!(matches!(ext.inner, DynamicObject::Union { .. }));
850    }
851
852    fn gold() -> crate::material::MaterialData {
853        crate::material::MaterialData {
854            metallic: 1.0,
855            roughness: 0.25,
856            preset: Some("Gold".into()),
857        }
858    }
859
860    #[test]
861    fn extract_material() {
862        let obj = DynamicObject::Material {
863            material: gold(),
864            child: Box::new(DynamicObject::Cube {
865                size: [1.0, 1.0, 1.0],
866            }),
867        };
868        let ext = obj.extract_top_transforms();
869        assert_eq!(ext.material, Some(gold()));
870        assert!(ext.color.is_none());
871        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
872    }
873
874    #[test]
875    fn extract_material_interleaved_with_transform_and_color() {
876        // Translate(Material(Color(Cube))) — all three captured, peeling continues.
877        let obj = DynamicObject::Translate {
878            offset: [5.0, 0.0, 0.0],
879            child: Box::new(DynamicObject::Material {
880                material: gold(),
881                child: Box::new(DynamicObject::Color {
882                    color: [0.0, 1.0, 0.0, 1.0],
883                    child: Box::new(DynamicObject::Cube {
884                        size: [1.0, 1.0, 1.0],
885                    }),
886                }),
887            }),
888        };
889        let ext = obj.extract_top_transforms();
890        assert!((ext.transform.translation.x - 5.0).abs() < 1e-10);
891        assert_eq!(ext.color, Some([0.0, 1.0, 0.0, 1.0]));
892        assert_eq!(ext.material, Some(gold()));
893        assert!(matches!(ext.inner, DynamicObject::Cube { .. }));
894    }
895
896    #[test]
897    fn extract_outermost_material_wins() {
898        let obj = DynamicObject::Material {
899            material: gold(),
900            child: Box::new(DynamicObject::Material {
901                material: crate::material::MaterialData::default(),
902                child: Box::new(DynamicObject::Cube {
903                    size: [1.0, 1.0, 1.0],
904                }),
905            }),
906        };
907        let ext = obj.extract_top_transforms();
908        assert_eq!(ext.material, Some(gold()));
909    }
910}