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    // Engine selection: CSG evaluation honors the process-global default
360    // (types::BooleanConfig). With the default (Exact) this call resolves to
361    // boolean3::boolean_with_token — behavior byte-identical to before the
362    // robust engine existed.
363    let result = boolean3::boolean_dispatch(
364        &impl_a,
365        &impl_b,
366        op,
367        crate::types::BooleanConfig::default_engine(),
368        token,
369    );
370    CsgLeafNode::new(result)
371}
372
373// ---------------------------------------------------------------------------
374// BatchBoolean — heap-ordered reduction for commutative ops
375// Port of C++ BatchBoolean() in csg_tree.cpp (v3.5.0)
376// ---------------------------------------------------------------------------
377
378/// Heap entry ordered like C++ `MeshCompare` on `(CsgLeafNode, serial)` pairs:
379/// by vertex count, tie-broken by insertion serial. The serial makes the order
380/// total, so the pop sequence is deterministic and heap-implementation
381/// independent — required for exact match with the C++ reduction order.
382struct MeshEntry(CsgLeafNode, u64);
383
384impl PartialEq for MeshEntry {
385    fn eq(&self, other: &Self) -> bool {
386        self.cmp(other) == Ordering::Equal
387    }
388}
389impl Eq for MeshEntry {}
390
391impl PartialOrd for MeshEntry {
392    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
393        Some(self.cmp(other))
394    }
395}
396impl Ord for MeshEntry {
397    fn cmp(&self, other: &Self) -> Ordering {
398        // C++ std::pop_heap with MeshCompare (a less-than) pops the MAX:
399        // the node with the most verts, ties going to the largest serial.
400        // Rust's BinaryHeap is a max-heap, so use the same less-than order.
401        self.0
402            .num_vert()
403            .cmp(&other.0.num_vert())
404            .then(self.1.cmp(&other.1))
405    }
406}
407
408fn batch_boolean(
409    op: OpType,
410    children: &mut Vec<CsgLeafNode>,
411    token: Option<&CancelToken>,
412) -> CsgLeafNode {
413    if children.is_empty() {
414        return CsgLeafNode::empty();
415    }
416    if children.len() == 1 {
417        return children.remove(0);
418    }
419    if children.len() == 2 {
420        let b = children.pop().unwrap();
421        let a = children.pop().unwrap();
422        return simple_boolean(&a, &b, op, token);
423    }
424
425    let mut heap: BinaryHeap<MeshEntry> = BinaryHeap::new();
426    let mut next_serial = children.len() as u64;
427    for (i, child) in children.drain(..).enumerate() {
428        heap.push(MeshEntry(child, i as u64));
429    }
430
431    // C++ processes up to 4 pairs per round (for its parallel lane), pushing
432    // the results back only at the end of the round — even in sequential
433    // builds. The round structure changes which meshes pair up, so mirror it.
434    let mut tmp: Vec<MeshEntry> = Vec::new();
435    while heap.len() > 1 {
436        // Once-per-round check, matching C++ BatchBoolean's per-iteration gate
437        // (csg_tree.cpp:460).
438        if is_cancelled(token) {
439            return CsgLeafNode::cancelled();
440        }
441        for _ in 0..4 {
442            if heap.len() <= 1 {
443                break;
444            }
445            let a = heap.pop().unwrap();
446            let b = heap.pop().unwrap();
447            let result = simple_boolean(&a.0, &b.0, op, token);
448            tmp.push(MeshEntry(result, next_serial));
449            next_serial += 1;
450        }
451        for entry in tmp.drain(..) {
452            heap.push(entry);
453        }
454    }
455
456    heap.pop().unwrap().0
457}
458
459// ---------------------------------------------------------------------------
460// BatchUnion — bounding-box partitioning + Compose + BatchBoolean
461// Port of C++ BatchUnion() (lines 434-491)
462// ---------------------------------------------------------------------------
463
464const K_MAX_UNION_SIZE: usize = 1000;
465
466fn batch_union(children: &mut Vec<CsgLeafNode>, token: Option<&CancelToken>) -> CsgLeafNode {
467    if children.is_empty() {
468        return CsgLeafNode::empty();
469    }
470    if children.len() == 1 {
471        return children.remove(0);
472    }
473
474    // Process in chunks to avoid O(n^2) overlap checks
475    while children.len() > 1 {
476        // Once-per-chunk check, matching C++ BatchUnion (csg_tree.cpp:511).
477        if is_cancelled(token) {
478            return CsgLeafNode::cancelled();
479        }
480        let chunk_size = children.len().min(K_MAX_UNION_SIZE);
481        let chunk_start = children.len() - chunk_size;
482
483        // Get bounding boxes for the chunk
484        let boxes: Vec<BBox> = children[chunk_start..]
485            .iter()
486            .map(|c| c.get_bounding_box())
487            .collect();
488
489        // Greedy partition into disjoint sets
490        let mut sets: Vec<Vec<usize>> = Vec::new(); // each set is indices into chunk
491        for i in 0..chunk_size {
492            let mut found_set = false;
493            for set in &mut sets {
494                let overlaps = set.iter().any(|&j| boxes[i].does_overlap_box(&boxes[j]));
495                if !overlaps {
496                    set.push(i);
497                    found_set = true;
498                    break;
499                }
500            }
501            if !found_set {
502                sets.push(vec![i]);
503            }
504        }
505
506        // Process each disjoint set
507        let chunk: Vec<CsgLeafNode> = children.drain(chunk_start..).collect();
508        let mut results: Vec<CsgLeafNode> = Vec::new();
509
510        for set in &sets {
511            if set.len() == 1 {
512                results.push(chunk[set[0]].clone());
513            } else {
514                // Compose disjoint meshes without boolean
515                let meshes: Vec<ManifoldImpl> = set.iter()
516                    .map(|&i| chunk[i].get_impl())
517                    .collect();
518                let composed = boolean3::compose_meshes(&meshes);
519                results.push(CsgLeafNode::new(composed));
520            }
521        }
522
523        // BatchBoolean the composed results, then move the (complicated) new
524        // child to the front: C++ push_backs and swaps front↔back, which also
525        // moves the old front to the back when chunking (>kMaxUnionSize).
526        let result = batch_boolean(OpType::Add, &mut results, token);
527        children.push(result);
528        let last = children.len() - 1;
529        children.swap(0, last);
530    }
531
532    children.remove(0)
533}
534
535// ---------------------------------------------------------------------------
536// Tests
537// ---------------------------------------------------------------------------
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::linalg::{mat4_to_mat3x4, translation_matrix, Vec3};
543
544    #[test]
545    fn test_csg_tree_union_disjoint() {
546        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
547        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(3.0, 0.0, 0.0))));
548        let tree = CsgNode::op(OpType::Add, CsgNode::leaf(a), CsgNode::leaf(b));
549        let result = tree.evaluate();
550        assert_eq!(result.num_tri(), 24);
551    }
552
553    #[test]
554    fn test_csg_tree_union_overlapping() {
555        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
556        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0))));
557        let tree = CsgNode::op(OpType::Add, CsgNode::leaf(a), CsgNode::leaf(b));
558        let result = tree.evaluate();
559        assert!(result.num_tri() > 0, "Overlapping union should produce non-empty mesh");
560    }
561
562    #[test]
563    fn test_csg_tree_intersection() {
564        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
565        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0))));
566        let tree = CsgNode::op(OpType::Intersect, CsgNode::leaf(a), CsgNode::leaf(b));
567        let result = tree.evaluate();
568        assert!(result.num_tri() > 0, "Overlapping intersection should produce non-empty mesh");
569    }
570
571    #[test]
572    fn test_csg_tree_subtract() {
573        let a = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0))));
574        let b = ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0))));
575        let tree = CsgNode::op(OpType::Subtract, CsgNode::leaf(a), CsgNode::leaf(b));
576        let result = tree.evaluate();
577        assert!(result.num_tri() > 0, "Subtraction should produce non-empty mesh");
578    }
579
580    #[test]
581    fn test_batch_boolean_three_cubes() {
582        let a = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0)))));
583        let b = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.5, 0.0, 0.0)))));
584        let c = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(1.0, 0.0, 0.0)))));
585        let mut children = vec![a, b, c];
586        let result = batch_boolean(OpType::Add, &mut children, None);
587        let mesh = result.get_impl();
588        assert!(mesh.num_tri() > 0, "BatchBoolean of 3 overlapping cubes should produce non-empty mesh");
589    }
590
591    #[test]
592    fn test_batch_union_disjoint() {
593        let a = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(0.0, 0.0, 0.0)))));
594        let b = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(3.0, 0.0, 0.0)))));
595        let c = CsgLeafNode::new(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(Vec3::new(6.0, 0.0, 0.0)))));
596        let mut children = vec![a, b, c];
597        let result = batch_union(&mut children, None);
598        let mesh = result.get_impl();
599        // Three disjoint cubes should compose without boolean, giving 36 tris
600        assert_eq!(mesh.num_tri(), 36, "BatchUnion of 3 disjoint cubes should have 36 tris");
601    }
602
603    #[test]
604    fn test_csg_n_ary_union() {
605        // N-ary union of 4 disjoint cubes
606        let nodes: Vec<CsgNode> = (0..4).map(|i| {
607            CsgNode::leaf(ManifoldImpl::cube(&mat4_to_mat3x4(translation_matrix(
608                Vec3::new(i as f64 * 3.0, 0.0, 0.0)
609            ))))
610        }).collect();
611        let tree = CsgNode::op_n(OpType::Add, nodes);
612        let result = tree.evaluate();
613        assert_eq!(result.num_tri(), 48, "N-ary union of 4 disjoint cubes should have 48 tris");
614    }
615
616    #[test]
617    fn test_lazy_leaf_transform_applied_on_evaluate() {
618        // Regression: get_impl discarded ManifoldImpl::transform's return value
619        // (it is not in-place), so lazily-transformed leaves evaluated at the
620        // origin. Two disjoint cubes — one translated via the *leaf* transform,
621        // not baked into the mesh — must union to 24 tris, not collapse to 12.
622        let cube = ManifoldImpl::cube(&Mat3x4::identity());
623        let a = CsgLeafNode::new(cube.clone());
624        let b = CsgLeafNode::new(cube).apply_transform(
625            mat4_to_mat3x4(translation_matrix(Vec3::new(3.0, 0.0, 0.0))),
626        );
627        let bbox = b.get_impl().bbox;
628        assert!(
629            bbox.min.x >= 2.9 && bbox.max.x <= 4.1,
630            "lazy transform not applied by get_impl: bbox.x = [{}, {}]",
631            bbox.min.x,
632            bbox.max.x
633        );
634        let tree = CsgNode::op(
635            OpType::Add,
636            CsgNode::leaf_node(a),
637            CsgNode::leaf_node(b),
638        );
639        assert_eq!(tree.evaluate().num_tri(), 24);
640    }
641
642    #[test]
643    fn test_tree_transforms() {
644        // Test that transforms compose correctly through the tree
645        let a = ManifoldImpl::cube(&Mat3x4::identity());
646        let leaf = CsgLeafNode::new(a);
647        let translated = leaf.apply_transform(
648            mat4_to_mat3x4(translation_matrix(Vec3::new(5.0, 0.0, 0.0)))
649        );
650        let bbox = translated.get_bounding_box();
651        assert!(bbox.min.x > 4.0, "Translated bbox min.x should be > 4.0, got {}", bbox.min.x);
652        assert!(bbox.max.x < 6.5, "Translated bbox max.x should be < 6.5, got {}", bbox.max.x);
653    }
654}