Skip to main content

manifold_rust/
csg_tree.rs

1// Copyright 2026 Lars Brubaker
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Phase 12: CSG Tree — ported from C++ csg_tree.cpp (764 lines)
16//
17// Implements the full CSG tree evaluation system with:
18// - CsgLeafNode: lazy transform propagation, Arvo's AABB transform
19// - CsgOpNode: N-ary children with caching
20// - SimpleBoolean: wrapper invoking Boolean3
21// - BatchBoolean: min-heap approach for commutative ops
22// - BatchUnion: bounding-box partitioning + Compose + BatchBoolean
23// - Explicit-stack DFS evaluation (no recursion)
24
25use std::sync::Arc;
26use std::collections::BinaryHeap;
27use std::cmp::Ordering;
28
29use crate::boolean3;
30use crate::cancel::{is_cancelled, CancelToken};
31use crate::impl_mesh::ManifoldImpl;
32use crate::linalg::{Mat3x4, Vec3, mat3x4_to_mat4, mat4_to_mat3x4};
33use crate::types::{Box as BBox, Error, OpType};
34
35// ---------------------------------------------------------------------------
36// CsgLeafNode — wraps an immutable mesh plus a lazy transform
37// ---------------------------------------------------------------------------
38
39#[derive(Clone)]
40pub struct CsgLeafNode {
41    pub p_impl: Arc<ManifoldImpl>,
42    pub transform: Mat3x4,
43}
44
45impl CsgLeafNode {
46    /// Create a leaf from a mesh with identity transform.
47    pub fn new(mesh: ManifoldImpl) -> Self {
48        Self {
49            p_impl: Arc::new(mesh),
50            transform: Mat3x4::identity(),
51        }
52    }
53
54    /// Create a leaf from a mesh with a specific transform.
55    pub fn with_transform(mesh: ManifoldImpl, transform: Mat3x4) -> Self {
56        Self {
57            p_impl: Arc::new(mesh),
58            transform,
59        }
60    }
61
62    /// Create an empty leaf.
63    pub fn empty() -> Self {
64        Self {
65            p_impl: Arc::new(ManifoldImpl::new()),
66            transform: Mat3x4::identity(),
67        }
68    }
69
70    /// Empty leaf carrying [`Error::Cancelled`], the value every cancelled
71    /// branch of the tree evaluates to. Port of C++
72    /// `ErrorLeaf(Manifold::Error::Cancelled)` (csg_tree.cpp:172, 460, 511, 759).
73    fn cancelled() -> Self {
74        let mut imp = ManifoldImpl::new();
75        imp.make_empty(Error::Cancelled);
76        Self {
77            p_impl: Arc::new(imp),
78            transform: Mat3x4::identity(),
79        }
80    }
81
82    /// Get the mesh, applying the lazy transform if needed.
83    /// Port of C++ CsgLeafNode::GetImpl()
84    pub fn get_impl(&self) -> ManifoldImpl {
85        if self.transform == Mat3x4::identity() {
86            (*self.p_impl).clone()
87        } else {
88            // ManifoldImpl::transform returns the transformed mesh (it is not
89            // in-place) — discarding its return value silently drops the lazy
90            // transform.
91            self.p_impl.transform(&self.transform)
92        }
93    }
94
95    /// Return a new leaf with composed transform.
96    /// Port of C++ CsgLeafNode::Transform()
97    pub fn apply_transform(&self, m: Mat3x4) -> Self {
98        let new_transform = mat4_to_mat3x4(
99            mat3x4_to_mat4(m) * mat3x4_to_mat4(self.transform)
100        );
101        Self {
102            p_impl: Arc::clone(&self.p_impl),
103            transform: new_transform,
104        }
105    }
106
107    /// Get bounding box without materializing the full mesh.
108    /// Uses Arvo's algorithm for AABB transform.
109    /// Port of C++ CsgLeafNode::GetBoundingBox()
110    pub fn get_bounding_box(&self) -> BBox {
111        let impl_bbox = self.p_impl.bbox;
112        if self.transform == Mat3x4::identity() {
113            return impl_bbox;
114        }
115        // Arvo's AABB transform: transform center and half-extents
116        let center = (impl_bbox.min + impl_bbox.max) * 0.5;
117        let half = (impl_bbox.max - impl_bbox.min) * 0.5;
118
119        // Transform center point
120        let mat = self.transform;
121        let new_center = Vec3::new(
122            mat[0].x * center.x + mat[1].x * center.y + mat[2].x * center.z + mat[3].x,
123            mat[0].y * center.x + mat[1].y * center.y + mat[2].y * center.z + mat[3].y,
124            mat[0].z * center.x + mat[1].z * center.y + mat[2].z * center.z + mat[3].z,
125        );
126
127        // Transform half-extents using absolute values of matrix entries
128        let new_half = Vec3::new(
129            mat[0].x.abs() * half.x + mat[1].x.abs() * half.y + mat[2].x.abs() * half.z,
130            mat[0].y.abs() * half.x + mat[1].y.abs() * half.y + mat[2].y.abs() * half.z,
131            mat[0].z.abs() * half.x + mat[1].z.abs() * half.y + mat[2].z.abs() * half.z,
132        );
133
134        BBox {
135            min: new_center - new_half,
136            max: new_center + new_half,
137        }
138    }
139
140    /// Vertex count without triggering transform.
141    pub fn num_vert(&self) -> usize {
142        self.p_impl.num_vert()
143    }
144}
145
146// ---------------------------------------------------------------------------
147// CsgNode — the main CSG tree node (leaf or N-ary operation)
148// ---------------------------------------------------------------------------
149
150#[derive(Clone)]
151pub enum CsgNode {
152    Leaf(CsgLeafNode),
153    Op {
154        op: OpType,
155        children: Vec<CsgNode>,
156        transform: Mat3x4,
157    },
158}
159
160impl CsgNode {
161    pub fn leaf(mesh: ManifoldImpl) -> Self {
162        Self::Leaf(CsgLeafNode::new(mesh))
163    }
164
165    pub fn leaf_node(node: CsgLeafNode) -> Self {
166        Self::Leaf(node)
167    }
168
169    pub fn op(op: OpType, left: CsgNode, right: CsgNode) -> Self {
170        Self::Op {
171            op,
172            children: vec![left, right],
173            transform: Mat3x4::identity(),
174        }
175    }
176
177    pub fn op_n(op: OpType, children: Vec<CsgNode>) -> Self {
178        Self::Op {
179            op,
180            children,
181            transform: Mat3x4::identity(),
182        }
183    }
184
185    /// Evaluate the CSG tree to produce a single mesh.
186    /// Uses explicit-stack DFS to avoid recursion stack overflow.
187    /// Port of C++ CsgOpNode::ToLeafNode()
188    pub fn evaluate(&self) -> ManifoldImpl {
189        self.evaluate_with_token(None)
190    }
191
192    /// [`CsgNode::evaluate`] with cooperative cancellation.
193    ///
194    /// A cancelled evaluation yields an empty mesh whose status is
195    /// [`Error::Cancelled`]. Mirrors C++ `CsgOpNode::ToLeafNode(ctx)`
196    /// (csg_tree.cpp:644-800), which checks the flag once per stack step and
197    /// substitutes an `ErrorLeaf(Cancelled)` for the pending work.
198    pub fn evaluate_with_token(&self, token: Option<&CancelToken>) -> ManifoldImpl {
199        let leaf = self.to_leaf_node(Mat3x4::identity(), token);
200        leaf.get_impl()
201    }
202
203    /// Internal: convert this node to a CsgLeafNode, applying the given parent transform.
204    fn to_leaf_node(&self, parent_transform: Mat3x4, token: Option<&CancelToken>) -> CsgLeafNode {
205        // One check per stack step, as C++ does at csg_tree.cpp:752. Cancel is
206        // sticky, so every enclosing step short-circuits here too and the
207        // Cancelled leaf propagates to the root without further work.
208        if is_cancelled(token) {
209            return CsgLeafNode::cancelled();
210        }
211        match self {
212            CsgNode::Leaf(leaf) => leaf.apply_transform(parent_transform),
213            CsgNode::Op { op, children, transform } => {
214                // Compose local transform with parent
215                let combined = mat4_to_mat3x4(
216                    mat3x4_to_mat4(parent_transform) * mat3x4_to_mat4(*transform)
217                );
218
219                // Flatten: recursively resolve all children to leaves
220                let mut positive: Vec<CsgLeafNode> = Vec::new();
221                let mut negative: Vec<CsgLeafNode> = Vec::new();
222
223                self.collect_children(*op, combined, children, &mut positive, &mut negative, token);
224
225                // Perform the operation
226                match op {
227                    OpType::Add => {
228                        // Union of all positive children
229                        batch_union(&mut positive, token)
230                    }
231                    OpType::Intersect => {
232                        // Intersection of all positive children
233                        batch_boolean(OpType::Intersect, &mut positive, token)
234                    }
235                    OpType::Subtract => {
236                        // Subtract: first child is positive, rest are negative
237                        if positive.is_empty() {
238                            // `collect_children` may have produced a Cancelled
239                            // leaf and no positive one; returning the plain
240                            // empty leaf here would launder that into NoError.
241                            return if is_cancelled(token) {
242                                CsgLeafNode::cancelled()
243                            } else {
244                                CsgLeafNode::empty()
245                            };
246                        }
247                        let pos_result = batch_union(&mut positive, token);
248                        if negative.is_empty() {
249                            return pos_result;
250                        }
251                        let neg_result = batch_union(&mut negative, token);
252                        simple_boolean(&pos_result, &neg_result, OpType::Subtract, token)
253                    }
254                }
255            }
256        }
257    }
258
259    /// Recursively collect children, flattening compatible operations.
260    /// Port of the collapsing logic in C++ CsgOpNode::ToLeafNode.
261    fn collect_children(
262        &self,
263        parent_op: OpType,
264        transform: Mat3x4,
265        children: &[CsgNode],
266        positive: &mut Vec<CsgLeafNode>,
267        negative: &mut Vec<CsgLeafNode>,
268        token: Option<&CancelToken>,
269    ) {
270        for (i, child) in children.iter().enumerate() {
271            match child {
272                CsgNode::Leaf(leaf) => {
273                    let transformed = leaf.apply_transform(transform);
274                    if parent_op == OpType::Subtract && i > 0 {
275                        negative.push(transformed);
276                    } else {
277                        positive.push(transformed);
278                    }
279                }
280                CsgNode::Op { op: child_op, children: grandchildren, transform: child_transform } => {
281                    let combined = mat4_to_mat3x4(
282                        mat3x4_to_mat4(transform) * mat3x4_to_mat4(*child_transform)
283                    );
284
285                    // Collapsing: flatten compatible ops
286                    let can_collapse = match (parent_op, child_op) {
287                        // Union is associative: (A ∪ B) ∪ C = A ∪ B ∪ C
288                        (OpType::Add, OpType::Add) => true,
289                        // Intersection is associative: (A ∩ B) ∩ C = A ∩ B ∩ C
290                        (OpType::Intersect, OpType::Intersect) => true,
291                        // (A - B) - C = A - (B ∪ C): first child's subtraction collapses
292                        (OpType::Subtract, OpType::Subtract) if i == 0 => true,
293                        _ => false,
294                    };
295
296                    if can_collapse {
297                        // Flatten: merge grandchildren directly
298                        if parent_op == OpType::Subtract && *child_op == OpType::Subtract && i == 0 {
299                            // (A - B) is first child of Subtract: A goes to positive, B goes to negative
300                            for (gi, gc) in grandchildren.iter().enumerate() {
301                                let leaf = gc.to_leaf_node_inner(combined, token);
302                                if gi == 0 {
303                                    positive.push(leaf);
304                                } else {
305                                    negative.push(leaf);
306                                }
307                            }
308                        } else {
309                            for gc in grandchildren {
310                                let leaf = gc.to_leaf_node_inner(combined, token);
311                                if parent_op == OpType::Subtract && i > 0 {
312                                    negative.push(leaf);
313                                } else {
314                                    positive.push(leaf);
315                                }
316                            }
317                        }
318                    } else {
319                        // Cannot collapse: evaluate child subtree fully
320                        let result = child.to_leaf_node(combined, token);
321                        if parent_op == OpType::Subtract && i > 0 {
322                            negative.push(result);
323                        } else {
324                            positive.push(result);
325                        }
326                    }
327                }
328            }
329        }
330    }
331
332    /// Helper: convert a single node to leaf with given transform (non-flattening).
333    fn to_leaf_node_inner(&self, transform: Mat3x4, token: Option<&CancelToken>) -> CsgLeafNode {
334        match self {
335            CsgNode::Leaf(leaf) => leaf.apply_transform(transform),
336            CsgNode::Op { .. } => self.to_leaf_node(transform, token),
337        }
338    }
339}
340
341// ---------------------------------------------------------------------------
342// SimpleBoolean — wrapper invoking Boolean3
343// Port of C++ SimpleBoolean() (lines 142-184)
344// ---------------------------------------------------------------------------
345
346fn simple_boolean(
347    a: &CsgLeafNode,
348    b: &CsgLeafNode,
349    op: OpType,
350    token: Option<&CancelToken>,
351) -> CsgLeafNode {
352    // Entry gate before the (expensive) transform materialisation, matching
353    // C++ SimpleBoolean's first line (csg_tree.cpp:172).
354    if is_cancelled(token) {
355        return CsgLeafNode::cancelled();
356    }
357    let impl_a = a.get_impl();
358    let impl_b = b.get_impl();
359    let result = boolean3::boolean_with_token(&impl_a, &impl_b, op, token);
360    CsgLeafNode::new(result)
361}
362
363// ---------------------------------------------------------------------------
364// BatchBoolean — heap-ordered reduction for commutative ops
365// Port of C++ BatchBoolean() in csg_tree.cpp (v3.5.0)
366// ---------------------------------------------------------------------------
367
368/// Heap entry ordered like C++ `MeshCompare` on `(CsgLeafNode, serial)` pairs:
369/// by vertex count, tie-broken by insertion serial. The serial makes the order
370/// total, so the pop sequence is deterministic and heap-implementation
371/// independent — required for exact match with the C++ reduction order.
372struct MeshEntry(CsgLeafNode, u64);
373
374impl PartialEq for MeshEntry {
375    fn eq(&self, other: &Self) -> bool {
376        self.cmp(other) == Ordering::Equal
377    }
378}
379impl Eq for MeshEntry {}
380
381impl PartialOrd for MeshEntry {
382    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
383        Some(self.cmp(other))
384    }
385}
386impl Ord for MeshEntry {
387    fn cmp(&self, other: &Self) -> Ordering {
388        // C++ std::pop_heap with MeshCompare (a less-than) pops the MAX:
389        // the node with the most verts, ties going to the largest serial.
390        // Rust's BinaryHeap is a max-heap, so use the same less-than order.
391        self.0
392            .num_vert()
393            .cmp(&other.0.num_vert())
394            .then(self.1.cmp(&other.1))
395    }
396}
397
398fn batch_boolean(
399    op: OpType,
400    children: &mut Vec<CsgLeafNode>,
401    token: Option<&CancelToken>,
402) -> CsgLeafNode {
403    if children.is_empty() {
404        return CsgLeafNode::empty();
405    }
406    if children.len() == 1 {
407        return children.remove(0);
408    }
409    if children.len() == 2 {
410        let b = children.pop().unwrap();
411        let a = children.pop().unwrap();
412        return simple_boolean(&a, &b, op, token);
413    }
414
415    let mut heap: BinaryHeap<MeshEntry> = BinaryHeap::new();
416    let mut next_serial = children.len() as u64;
417    for (i, child) in children.drain(..).enumerate() {
418        heap.push(MeshEntry(child, i as u64));
419    }
420
421    // C++ processes up to 4 pairs per round (for its parallel lane), pushing
422    // the results back only at the end of the round — even in sequential
423    // builds. The round structure changes which meshes pair up, so mirror it.
424    let mut tmp: Vec<MeshEntry> = Vec::new();
425    while heap.len() > 1 {
426        // Once-per-round check, matching C++ BatchBoolean's per-iteration gate
427        // (csg_tree.cpp:460).
428        if is_cancelled(token) {
429            return CsgLeafNode::cancelled();
430        }
431        for _ in 0..4 {
432            if heap.len() <= 1 {
433                break;
434            }
435            let a = heap.pop().unwrap();
436            let b = heap.pop().unwrap();
437            let result = simple_boolean(&a.0, &b.0, op, token);
438            tmp.push(MeshEntry(result, next_serial));
439            next_serial += 1;
440        }
441        for entry in tmp.drain(..) {
442            heap.push(entry);
443        }
444    }
445
446    heap.pop().unwrap().0
447}
448
449// ---------------------------------------------------------------------------
450// BatchUnion — bounding-box partitioning + Compose + BatchBoolean
451// Port of C++ BatchUnion() (lines 434-491)
452// ---------------------------------------------------------------------------
453
454const K_MAX_UNION_SIZE: usize = 1000;
455
456fn batch_union(children: &mut Vec<CsgLeafNode>, token: Option<&CancelToken>) -> CsgLeafNode {
457    if children.is_empty() {
458        return CsgLeafNode::empty();
459    }
460    if children.len() == 1 {
461        return children.remove(0);
462    }
463
464    // Process in chunks to avoid O(n^2) overlap checks
465    while children.len() > 1 {
466        // Once-per-chunk check, matching C++ BatchUnion (csg_tree.cpp:511).
467        if is_cancelled(token) {
468            return CsgLeafNode::cancelled();
469        }
470        let chunk_size = children.len().min(K_MAX_UNION_SIZE);
471        let chunk_start = children.len() - chunk_size;
472
473        // Get bounding boxes for the chunk
474        let boxes: Vec<BBox> = children[chunk_start..]
475            .iter()
476            .map(|c| c.get_bounding_box())
477            .collect();
478
479        // Greedy partition into disjoint sets
480        let mut sets: Vec<Vec<usize>> = Vec::new(); // each set is indices into chunk
481        for i in 0..chunk_size {
482            let mut found_set = false;
483            for set in &mut sets {
484                let overlaps = set.iter().any(|&j| boxes[i].does_overlap_box(&boxes[j]));
485                if !overlaps {
486                    set.push(i);
487                    found_set = true;
488                    break;
489                }
490            }
491            if !found_set {
492                sets.push(vec![i]);
493            }
494        }
495
496        // Process each disjoint set
497        let chunk: Vec<CsgLeafNode> = children.drain(chunk_start..).collect();
498        let mut results: Vec<CsgLeafNode> = Vec::new();
499
500        for set in &sets {
501            if set.len() == 1 {
502                results.push(chunk[set[0]].clone());
503            } else {
504                // Compose disjoint meshes without boolean
505                let meshes: Vec<ManifoldImpl> = set.iter()
506                    .map(|&i| chunk[i].get_impl())
507                    .collect();
508                let composed = boolean3::compose_meshes(&meshes);
509                results.push(CsgLeafNode::new(composed));
510            }
511        }
512
513        // BatchBoolean the composed results, then move the (complicated) new
514        // child to the front: C++ push_backs and swaps front↔back, which also
515        // moves the old front to the back when chunking (>kMaxUnionSize).
516        let result = batch_boolean(OpType::Add, &mut results, token);
517        children.push(result);
518        let last = children.len() - 1;
519        children.swap(0, last);
520    }
521
522    children.remove(0)
523}
524
525// ---------------------------------------------------------------------------
526// Tests
527// ---------------------------------------------------------------------------
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::linalg::{mat4_to_mat3x4, translation_matrix, Vec3};
533
534    #[test]
535    fn test_csg_tree_union_disjoint() {
536        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
537        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(3.0, 0.0, 0.0))));
538        let tree = CsgNode::op(OpType::Add, CsgNode::leaf(a), CsgNode::leaf(b));
539        let result = tree.evaluate();
540        assert_eq!(result.num_tri(), 24);
541    }
542
543    #[test]
544    fn test_csg_tree_union_overlapping() {
545        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
546        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0))));
547        let tree = CsgNode::op(OpType::Add, CsgNode::leaf(a), CsgNode::leaf(b));
548        let result = tree.evaluate();
549        assert!(result.num_tri() > 0, "Overlapping union should produce non-empty mesh");
550    }
551
552    #[test]
553    fn test_csg_tree_intersection() {
554        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
555        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0))));
556        let tree = CsgNode::op(OpType::Intersect, CsgNode::leaf(a), CsgNode::leaf(b));
557        let result = tree.evaluate();
558        assert!(result.num_tri() > 0, "Overlapping intersection should produce non-empty mesh");
559    }
560
561    #[test]
562    fn test_csg_tree_subtract() {
563        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
564        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0))));
565        let tree = CsgNode::op(OpType::Subtract, CsgNode::leaf(a), CsgNode::leaf(b));
566        let result = tree.evaluate();
567        assert!(result.num_tri() > 0, "Subtraction should produce non-empty mesh");
568    }
569
570    #[test]
571    fn test_batch_boolean_three_cubes() {
572        let a = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0)))));
573        let b = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0)))));
574        let c = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(1.0, 0.0, 0.0)))));
575        let mut children = vec![a, b, c];
576        let result = batch_boolean(OpType::Add, &mut children, None);
577        let mesh = result.get_impl();
578        assert!(mesh.num_tri() > 0, "BatchBoolean of 3 overlapping cubes should produce non-empty mesh");
579    }
580
581    #[test]
582    fn test_batch_union_disjoint() {
583        let a = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0)))));
584        let b = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(3.0, 0.0, 0.0)))));
585        let c = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(6.0, 0.0, 0.0)))));
586        let mut children = vec![a, b, c];
587        let result = batch_union(&mut children, None);
588        let mesh = result.get_impl();
589        // Three disjoint cubes should compose without boolean, giving 36 tris
590        assert_eq!(mesh.num_tri(), 36, "BatchUnion of 3 disjoint cubes should have 36 tris");
591    }
592
593    #[test]
594    fn test_csg_n_ary_union() {
595        // N-ary union of 4 disjoint cubes
596        let nodes: Vec<CsgNode> = (0..4).map(|i| {
597            CsgNode::leaf(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(
598                Vec3::new(i as f64 * 3.0, 0.0, 0.0)
599            ))))
600        }).collect();
601        let tree = CsgNode::op_n(OpType::Add, nodes);
602        let result = tree.evaluate();
603        assert_eq!(result.num_tri(), 48, "N-ary union of 4 disjoint cubes should have 48 tris");
604    }
605
606    #[test]
607    fn test_lazy_leaf_transform_applied_on_evaluate() {
608        // Regression: get_impl discarded ManifoldImpl::transform's return value
609        // (it is not in-place), so lazily-transformed leaves evaluated at the
610        // origin. Two disjoint cubes — one translated via the *leaf* transform,
611        // not baked into the mesh — must union to 24 tris, not collapse to 12.
612        let cube = ManifoldImpl::cube(&Mat3x4::identity());
613        let a = CsgLeafNode::new(cube.clone());
614        let b = CsgLeafNode::new(cube).apply_transform(
615            mat4_to_mat3x4(translation_matrix(Vec3::new(3.0, 0.0, 0.0))),
616        );
617        let bbox = b.get_impl().bbox;
618        assert!(
619            bbox.min.x >= 2.9 && bbox.max.x <= 4.1,
620            "lazy transform not applied by get_impl: bbox.x = [{}, {}]",
621            bbox.min.x,
622            bbox.max.x
623        );
624        let tree = CsgNode::op(
625            OpType::Add,
626            CsgNode::leaf_node(a),
627            CsgNode::leaf_node(b),
628        );
629        assert_eq!(tree.evaluate().num_tri(), 24);
630    }
631
632    #[test]
633    fn test_tree_transforms() {
634        // Test that transforms compose correctly through the tree
635        let a = ManifoldImpl::cube(&Mat3x4::identity());
636        let leaf = CsgLeafNode::new(a);
637        let translated = leaf.apply_transform(
638            mat4_to_mat3x4(translation_matrix(Vec3::new(5.0, 0.0, 0.0)))
639        );
640        let bbox = translated.get_bounding_box();
641        assert!(bbox.min.x > 4.0, "Translated bbox min.x should be > 4.0, got {}", bbox.min.x);
642        assert!(bbox.max.x < 6.5, "Translated bbox max.x should be < 6.5, got {}", bbox.max.x);
643    }
644}