Skip to main content

rscad_csg_manifold/
lib.rs

1//! Manifold CSG backend: converts `rscad-core` geometry types into
2//! `manifold-csg` manifolds and cross-sections.
3
4use glam::DVec3;
5use manifold_csg::{CrossSection, JoinType, Manifold};
6use std::collections::HashMap;
7
8use rscad_core::Object;
9use rscad_csg_traits::{CsgBackend, DynamicBackend, ToCsg, ToSketch};
10
11/// Manifold C++ kernel CSG backend.
12pub struct ManifoldBackend;
13
14/// Generation-local provenance of one output triangle.
15///
16/// `original_id` identifies the input Manifold that contributed the surface;
17/// `face_id` distinguishes its logical polygonal faces when Manifold provides
18/// that optional array.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub struct TriangleSourceId {
21    pub original_id: u32,
22    pub face_id: Option<u32>,
23}
24
25/// Render metadata associated with one original input surface set.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct SurfaceMetadata {
28    pub color: Option<[f32; 4]>,
29}
30
31/// A Manifold solid together with metadata keyed by Manifold `OriginalID`.
32#[derive(Clone)]
33pub struct ManifoldSolid {
34    manifold: Manifold,
35    metadata: HashMap<u32, SurfaceMetadata>,
36}
37
38impl ManifoldSolid {
39    fn tracked(manifold: Manifold) -> Self {
40        let mut metadata = HashMap::new();
41        if let Ok(id) = u32::try_from(manifold.original_id()) {
42            metadata.insert(id, SurfaceMetadata::default());
43        }
44        Self { manifold, metadata }
45    }
46
47    fn product(
48        manifold: Manifold,
49        mut left: HashMap<u32, SurfaceMetadata>,
50        right: HashMap<u32, SurfaceMetadata>,
51    ) -> Self {
52        for (id, meta) in right {
53            match left.entry(id) {
54                std::collections::hash_map::Entry::Vacant(entry) => {
55                    entry.insert(meta);
56                }
57                std::collections::hash_map::Entry::Occupied(entry) => {
58                    debug_assert_eq!(
59                        entry.get(),
60                        &meta,
61                        "one Manifold OriginalID carried conflicting surface metadata"
62                    );
63                }
64            }
65        }
66        Self {
67            manifold,
68            metadata: left,
69        }
70    }
71
72    pub fn manifold(&self) -> &Manifold {
73        &self.manifold
74    }
75
76    pub fn into_manifold(self) -> Manifold {
77        self.manifold
78    }
79
80    pub fn metadata(&self) -> &HashMap<u32, SurfaceMetadata> {
81        &self.metadata
82    }
83
84    pub fn surface_metadata(&self, id: TriangleSourceId) -> Option<&SurfaceMetadata> {
85        self.metadata.get(&id.original_id)
86    }
87
88    /// Expand Manifold's triangle runs into one provenance ID per triangle.
89    pub fn triangle_source_ids(&self) -> Result<Vec<TriangleSourceId>, ProvenanceError> {
90        let mesh = self.manifold.to_meshgl();
91        triangle_source_ids_from_parts(
92            mesh.tri_verts().len(),
93            &mesh.face_id(),
94            &mesh.run_index(),
95            &mesh.run_original_id(),
96            self.manifold.original_id(),
97        )
98    }
99}
100
101fn triangle_source_ids_from_parts(
102    triangle_index_count: usize,
103    face_ids: &[u32],
104    run_starts: &[u32],
105    original_ids: &[u32],
106    fallback_original_id: i32,
107) -> Result<Vec<TriangleSourceId>, ProvenanceError> {
108    let triangle_count = triangle_index_count / 3;
109    if !triangle_index_count.is_multiple_of(3) {
110        return Err(ProvenanceError::TriangleIndexCount);
111    }
112    if triangle_count == 0 {
113        return Ok(Vec::new());
114    }
115    if !face_ids.is_empty() && face_ids.len() != triangle_count {
116        return Err(ProvenanceError::FaceIdCount {
117            expected: triangle_count,
118            actual: face_ids.len(),
119        });
120    }
121    if run_starts.is_empty() && original_ids.is_empty() {
122        let original_id =
123            u32::try_from(fallback_original_id).map_err(|_| ProvenanceError::MissingRuns)?;
124        return Ok((0..triangle_count)
125            .map(|triangle| TriangleSourceId {
126                original_id,
127                face_id: face_ids.get(triangle).copied(),
128            })
129            .collect());
130    }
131    if original_ids.is_empty() || run_starts.len() < original_ids.len() {
132        return Err(ProvenanceError::RunCount {
133            starts: run_starts.len(),
134            originals: original_ids.len(),
135        });
136    }
137
138    let mut result = vec![None; triangle_count];
139    for (run, &original_id) in original_ids.iter().enumerate() {
140        let start = run_starts[run] as usize;
141        let end = run_starts
142            .get(run + 1)
143            .map_or(triangle_index_count, |&end| end as usize);
144        if !start.is_multiple_of(3)
145            || !end.is_multiple_of(3)
146            || start > end
147            || end > triangle_index_count
148        {
149            return Err(ProvenanceError::RunBoundary { run, start, end });
150        }
151        for (triangle, slot) in result.iter_mut().enumerate().take(end / 3).skip(start / 3) {
152            if slot.is_some() {
153                return Err(ProvenanceError::OverlappingRun { triangle });
154            }
155            *slot = Some(TriangleSourceId {
156                original_id,
157                face_id: face_ids.get(triangle).copied(),
158            });
159        }
160    }
161
162    result
163        .into_iter()
164        .enumerate()
165        .map(|(triangle, id)| id.ok_or(ProvenanceError::UncoveredTriangle { triangle }))
166        .collect()
167}
168
169impl std::ops::Deref for ManifoldSolid {
170    type Target = Manifold;
171
172    fn deref(&self) -> &Self::Target {
173        &self.manifold
174    }
175}
176
177impl AsRef<Manifold> for ManifoldSolid {
178    fn as_ref(&self) -> &Manifold {
179        &self.manifold
180    }
181}
182
183/// Invalid or incomplete provenance arrays returned by `MeshGL`.
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub enum ProvenanceError {
186    TriangleIndexCount,
187    FaceIdCount {
188        expected: usize,
189        actual: usize,
190    },
191    MissingRuns,
192    RunCount {
193        starts: usize,
194        originals: usize,
195    },
196    RunBoundary {
197        run: usize,
198        start: usize,
199        end: usize,
200    },
201    OverlappingRun {
202        triangle: usize,
203    },
204    UncoveredTriangle {
205        triangle: usize,
206    },
207}
208
209impl std::fmt::Display for ProvenanceError {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "invalid Manifold triangle provenance: {self:?}")
212    }
213}
214
215impl std::error::Error for ProvenanceError {}
216
217impl CsgBackend for ManifoldBackend {
218    type CsgType = ManifoldSolid;
219    type SketchType = CrossSection;
220
221    fn empty_csg() -> ManifoldSolid {
222        ManifoldSolid::tracked(Manifold::batch_union(&[]))
223    }
224    fn empty_sketch() -> CrossSection {
225        CrossSection::batch_union(&[])
226    }
227
228    fn union(a: ManifoldSolid, b: ManifoldSolid) -> ManifoldSolid {
229        let manifold = a.manifold.union(&b.manifold);
230        ManifoldSolid::product(manifold, a.metadata, b.metadata)
231    }
232    fn difference(a: ManifoldSolid, b: ManifoldSolid) -> ManifoldSolid {
233        let manifold = a.manifold.difference(&b.manifold);
234        ManifoldSolid::product(manifold, a.metadata, b.metadata)
235    }
236    fn intersection(a: ManifoldSolid, b: ManifoldSolid) -> ManifoldSolid {
237        let manifold = a.manifold.intersection(&b.manifold);
238        ManifoldSolid::product(manifold, a.metadata, b.metadata)
239    }
240    fn xor(a: ManifoldSolid, b: ManifoldSolid) -> ManifoldSolid {
241        let ab = a.manifold.union(&b.manifold);
242        let inter = a.manifold.intersection(&b.manifold);
243        ManifoldSolid::product(ab.difference(&inter), a.metadata, b.metadata)
244    }
245
246    fn union_2d(a: CrossSection, b: CrossSection) -> CrossSection {
247        a.union(&b)
248    }
249    fn difference_2d(a: CrossSection, b: CrossSection) -> CrossSection {
250        a.difference(&b)
251    }
252    fn intersection_2d(a: CrossSection, b: CrossSection) -> CrossSection {
253        a.intersection(&b)
254    }
255    fn xor_2d(a: CrossSection, b: CrossSection) -> CrossSection {
256        let ab = a.union(&b);
257        let inter = a.intersection(&b);
258        ab.difference(&inter)
259    }
260
261    fn translate(csg: ManifoldSolid, offset: DVec3) -> ManifoldSolid {
262        ManifoldSolid {
263            manifold: csg.manifold.translate(offset.x, offset.y, offset.z),
264            metadata: csg.metadata,
265        }
266    }
267    fn rotate(csg: ManifoldSolid, angles: DVec3) -> ManifoldSolid {
268        ManifoldSolid {
269            manifold: csg.manifold.rotate(angles.x, angles.y, angles.z),
270            metadata: csg.metadata,
271        }
272    }
273    fn scale(csg: ManifoldSolid, factors: DVec3) -> ManifoldSolid {
274        ManifoldSolid {
275            manifold: csg.manifold.scale(factors.x, factors.y, factors.z),
276            metadata: csg.metadata,
277        }
278    }
279    fn mirror(csg: ManifoldSolid, axes: DVec3) -> ManifoldSolid {
280        ManifoldSolid {
281            manifold: csg.manifold.mirror([axes.x, axes.y, axes.z]),
282            metadata: csg.metadata,
283        }
284    }
285
286    fn translate_2d(sketch: CrossSection, offset: DVec3) -> CrossSection {
287        sketch.translate(offset.x, offset.y)
288    }
289    fn rotate_2d(sketch: CrossSection, angles: DVec3) -> CrossSection {
290        sketch.rotate(angles.z)
291    }
292    fn scale_2d(sketch: CrossSection, factors: DVec3) -> CrossSection {
293        sketch.scale(factors.x, factors.y)
294    }
295    fn mirror_2d(sketch: CrossSection, axes: DVec3) -> CrossSection {
296        sketch.mirror(axes.x, axes.y)
297    }
298
299    fn color(mut csg: ManifoldSolid, color: [f32; 4]) -> ManifoldSolid {
300        if csg.metadata.is_empty()
301            && let Ok(id) = u32::try_from(csg.manifold.original_id())
302        {
303            csg.metadata.insert(id, SurfaceMetadata::default());
304        }
305        for metadata in csg.metadata.values_mut() {
306            metadata.color.get_or_insert(color);
307        }
308        csg
309    }
310    fn color_2d(sketch: CrossSection, _color: [f32; 4]) -> CrossSection {
311        sketch
312    }
313
314    fn extrude(
315        sketch: CrossSection,
316        height: f64,
317        twist: f64,
318        scale: f64,
319        slices: usize,
320    ) -> ManifoldSolid {
321        ManifoldSolid::tracked(z_to_y_up(Manifold::extrude_with_options(
322            &sketch,
323            height,
324            slices as i32,
325            twist,
326            scale,
327            scale,
328        )))
329    }
330
331    fn revolve(sketch: CrossSection, angle_degrees: f64, segments: usize) -> ManifoldSolid {
332        ManifoldSolid::tracked(z_to_y_up(Manifold::revolve(
333            &sketch,
334            segments as i32,
335            angle_degrees,
336        )))
337    }
338}
339
340/// Convert a Z-up Manifold primitive to Y-up (Bevy convention).
341fn z_to_y_up(m: Manifold) -> Manifold {
342    m.rotate(-90.0, 0.0, 0.0).mirror([0.0, 0.0, 1.0])
343}
344
345// ── 3D Primitives ──────────────────────────────────────────
346
347impl ToCsg<ManifoldBackend> for rscad_core::Cube {
348    fn to_csg(&self) -> ManifoldSolid {
349        ManifoldSolid::tracked(Manifold::cube(self.x, self.y, self.z, false))
350    }
351}
352
353impl ToCsg<ManifoldBackend> for rscad_core::Sphere {
354    fn to_csg(&self) -> ManifoldSolid {
355        ManifoldSolid::tracked(Manifold::sphere(self.radius, self.fn_ as i32))
356    }
357}
358
359impl ToCsg<ManifoldBackend> for rscad_core::Cylinder {
360    fn to_csg(&self) -> ManifoldSolid {
361        ManifoldSolid::tracked(z_to_y_up(Manifold::cylinder(
362            self.height,
363            self.radius,
364            self.radius,
365            self.fn_ as i32,
366            false,
367        )))
368    }
369}
370
371// ── 2D Primitives ──────────────────────────────────────────
372
373impl ToSketch<ManifoldBackend> for rscad_core::Circle {
374    fn to_sketch(&self) -> CrossSection {
375        CrossSection::circle(self.radius, self.fn_ as i32)
376    }
377}
378
379impl ToSketch<ManifoldBackend> for rscad_core::Square {
380    fn to_sketch(&self) -> CrossSection {
381        CrossSection::square(self.x, self.y, false)
382    }
383}
384
385impl<const N: usize> ToSketch<ManifoldBackend> for rscad_core::Polygon<N> {
386    fn to_sketch(&self) -> CrossSection {
387        let points = self.points_array().map(|p| p.to_array());
388        CrossSection::from_simple_polygon(&points)
389    }
390}
391
392impl ToSketch<ManifoldBackend> for rscad_core::PolygonDynamic {
393    fn to_sketch(&self) -> CrossSection {
394        let points: Vec<[f64; 2]> = self.points().map(|p| p.to_array()).collect();
395        CrossSection::from_simple_polygon(&points)
396    }
397}
398
399// ── Offset (2D-only) ──────────────────────────────────────
400
401impl<T: Object + ToSketch<ManifoldBackend>> ToSketch<ManifoldBackend> for rscad_core::Offset<T> {
402    fn to_sketch(&self) -> CrossSection {
403        use rscad_core::transformations::OffsetType;
404        let join_type = match self.offset_type {
405            OffsetType::Normal => JoinType::Square,
406            OffsetType::Rounded => JoinType::Round,
407        };
408        self.object
409            .to_sketch()
410            .offset(self.offset, join_type, 2.0, 0)
411    }
412}
413
414// ── DynamicObject (generic dispatch via DynamicBackend) ────
415
416impl DynamicBackend for ManifoldBackend {
417    // SKETCH_IS_CSG stays false: 2D nodes in 3D context (and vice versa)
418    // evaluate to empty.
419
420    fn cube(size: [f64; 3]) -> ManifoldSolid {
421        ManifoldSolid::tracked(Manifold::cube(size[0], size[1], size[2], false))
422    }
423    fn sphere(radius: f64, segments: usize, _stacks: usize) -> ManifoldSolid {
424        ManifoldSolid::tracked(Manifold::sphere(radius, segments as i32))
425    }
426    fn cylinder(radius: f64, height: f64, segments: usize) -> ManifoldSolid {
427        ManifoldSolid::tracked(z_to_y_up(Manifold::cylinder(
428            height,
429            radius,
430            radius,
431            segments as i32,
432            false,
433        )))
434    }
435    fn cone(radius: f64, height: f64, segments: usize) -> ManifoldSolid {
436        ManifoldSolid::tracked(z_to_y_up(Manifold::cylinder(
437            height,
438            radius,
439            0.0,
440            segments as i32,
441            false,
442        )))
443    }
444
445    fn circle(radius: f64, segments: usize) -> CrossSection {
446        CrossSection::circle(radius, segments as i32)
447    }
448    fn square(size: [f64; 2]) -> CrossSection {
449        CrossSection::square(size[0], size[1], false)
450    }
451    fn polygon(points: &[[f64; 2]]) -> CrossSection {
452        CrossSection::from_simple_polygon(points)
453    }
454    fn sketch(loops: &[Vec<glam::DVec2>]) -> CrossSection {
455        let loops: Vec<Vec<[f64; 2]>> = loops
456            .iter()
457            .map(|l| l.iter().map(|p| p.to_array()).collect())
458            .collect();
459        // Positive fill: CCW outers fill, CW holes subtract.
460        CrossSection::from_polygons(&loops)
461    }
462
463    fn offset(
464        sketch: CrossSection,
465        delta: f64,
466        round: bool,
467        chamfer: bool,
468        segments: usize,
469    ) -> CrossSection {
470        let join = match (round, chamfer) {
471            (true, _) => JoinType::Round,
472            (false, true) => JoinType::Square,
473            (false, false) => JoinType::Miter,
474        };
475        sketch.offset(delta, join, 2.0, segments as i32)
476    }
477
478    fn union_all(items: Vec<ManifoldSolid>) -> ManifoldSolid {
479        let manifolds: Vec<Manifold> = items.iter().map(|item| item.manifold.clone()).collect();
480        let mut metadata = HashMap::new();
481        for item in items {
482            for (id, meta) in item.metadata {
483                match metadata.entry(id) {
484                    std::collections::hash_map::Entry::Vacant(entry) => {
485                        entry.insert(meta);
486                    }
487                    std::collections::hash_map::Entry::Occupied(entry) => {
488                        debug_assert_eq!(entry.get(), &meta);
489                    }
490                }
491            }
492        }
493        ManifoldSolid {
494            manifold: Manifold::batch_union(&manifolds),
495            metadata,
496        }
497    }
498    fn union_all_2d(items: Vec<CrossSection>) -> CrossSection {
499        CrossSection::batch_union(&items)
500    }
501}
502
503// ── Bevy mesh conversion ───────────────────────────────────
504
505/// Per-vertex `(OriginalID, faceID)` emitted by the Manifold backend.
506///
507/// Manifold's optional/missing `faceID` is encoded as [`UNKNOWN_FACE_ID`].
508#[cfg(feature = "bevy")]
509pub const ATTRIBUTE_SURFACE_ID: bevy_mesh::MeshVertexAttribute =
510    bevy_mesh::MeshVertexAttribute::new(
511        "Rscad_SurfaceId",
512        0x5253_0001,
513        wgpu::VertexFormat::Uint32x2,
514    );
515
516#[cfg(feature = "bevy")]
517pub const UNKNOWN_FACE_ID: u32 = u32::MAX;
518
519#[cfg(feature = "bevy")]
520impl rscad_csg_traits::CsgToMesh for ManifoldBackend {
521    fn to_bevy_mesh(solid: &ManifoldSolid) -> bevy_mesh::Mesh {
522        use bevy_asset::RenderAssetUsages;
523        use bevy_mesh::{Indices, Mesh as BevyMesh, PrimitiveTopology};
524
525        let manifold_mesh = solid.manifold.to_meshgl();
526        let props = manifold_mesh.vert_properties();
527        let n_props = manifold_mesh.num_prop();
528        let indices = manifold_mesh.tri_verts();
529
530        if n_props < 3 {
531            return BevyMesh::new(
532                PrimitiveTopology::TriangleList,
533                RenderAssetUsages::default(),
534            );
535        }
536
537        let n_verts = props.len().checked_div(n_props).unwrap_or(0);
538
539        let positions: Vec<[f32; 3]> = (0..n_verts)
540            .map(|i| {
541                let b = i * n_props;
542                [props[b], props[b + 1], props[b + 2]]
543            })
544            .collect();
545
546        let mut mesh = BevyMesh::new(
547            PrimitiveTopology::TriangleList,
548            RenderAssetUsages::default(),
549        );
550        mesh.insert_attribute(BevyMesh::ATTRIBUTE_POSITION, positions);
551        mesh.insert_indices(Indices::U32(indices));
552        mesh.duplicate_vertices();
553        mesh.compute_flat_normals();
554
555        match triangle_source_ids_from_parts(
556            manifold_mesh.tri_verts().len(),
557            &manifold_mesh.face_id(),
558            &manifold_mesh.run_index(),
559            &manifold_mesh.run_original_id(),
560            solid.manifold.original_id(),
561        ) {
562            Ok(source_ids) => {
563                let surface_ids: Vec<[u32; 2]> = source_ids
564                    .iter()
565                    .flat_map(|id| {
566                        std::iter::repeat_n(
567                            [id.original_id, id.face_id.unwrap_or(UNKNOWN_FACE_ID)],
568                            3,
569                        )
570                    })
571                    .collect();
572                mesh.insert_attribute(ATTRIBUTE_SURFACE_ID, surface_ids);
573
574                let has_colors = source_ids.iter().any(|id| {
575                    solid
576                        .surface_metadata(*id)
577                        .and_then(|metadata| metadata.color)
578                        .is_some()
579                });
580                if has_colors {
581                    let colors: Vec<[f32; 4]> = source_ids
582                        .iter()
583                        .flat_map(|id| {
584                            let color = solid
585                                .surface_metadata(*id)
586                                .and_then(|metadata| metadata.color)
587                                .unwrap_or([1.0; 4]);
588                            std::iter::repeat_n(color, 3)
589                        })
590                        .collect();
591                    mesh.insert_attribute(BevyMesh::ATTRIBUTE_COLOR, colors);
592                }
593            }
594            Err(error) => {
595                tracing::warn!(%error, "Manifold mesh omitted invalid surface provenance");
596            }
597        }
598        mesh
599    }
600}
601
602// ── CsgToStl ───────────────────────────────────────────────
603
604impl rscad_csg_traits::CsgToStl for ManifoldBackend {
605    fn write_stl_binary(
606        solid: &ManifoldSolid,
607        writer: &mut dyn std::io::Write,
608    ) -> std::io::Result<()> {
609        rscad_csg_traits::stl::write_stl_binary(writer, stl_triangles(&solid.manifold).into_iter())
610    }
611
612    fn write_stl_ascii(
613        solid: &ManifoldSolid,
614        writer: &mut dyn std::io::Write,
615    ) -> std::io::Result<()> {
616        rscad_csg_traits::stl::write_stl_ascii(writer, stl_triangles(&solid.manifold).into_iter())
617    }
618}
619
620fn stl_triangles(solid: &Manifold) -> Vec<rscad_csg_traits::stl::StlTriangle> {
621    let (props, n_props, indices) = solid.to_mesh_f32();
622    if n_props < 3 {
623        return vec![];
624    }
625    let get_pos = |i: usize| -> [f32; 3] {
626        let b = i * n_props;
627        [props[b], props[b + 1], props[b + 2]]
628    };
629    indices
630        .chunks_exact(3)
631        .map(|tri| {
632            let v0 = get_pos(tri[0] as usize);
633            let v1 = get_pos(tri[1] as usize);
634            let v2 = get_pos(tri[2] as usize);
635            // Face normal from the edge cross product; degenerate → zero.
636            let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
637            let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
638            let n = [
639                e1[1] * e2[2] - e1[2] * e2[1],
640                e1[2] * e2[0] - e1[0] * e2[2],
641                e1[0] * e2[1] - e1[1] * e2[0],
642            ];
643            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
644            let normal = if len > 0.0 {
645                [n[0] / len, n[1] / len, n[2] / len]
646            } else {
647                [0.0, 0.0, 0.0]
648            };
649            (normal, [v0, v1, v2])
650        })
651        .collect()
652}
653
654// ── Tests ──────────────────────────────────────────────────
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use rscad_core::{Cube, DynamicObject};
660
661    fn colored(color: [f64; 4], child: DynamicObject) -> DynamicObject {
662        DynamicObject::Color {
663            color,
664            child: Box::new(child),
665        }
666    }
667
668    #[test]
669    fn run_boundaries_expand_to_triangle_sources() {
670        let sources = triangle_source_ids_from_parts(
671            18,
672            &[100, 101, 102, 103, 104, 105],
673            &[0, 6, 18],
674            &[10, 20],
675            -1,
676        )
677        .unwrap();
678        assert_eq!(sources.len(), 6);
679        assert!(sources[..2].iter().all(|source| source.original_id == 10));
680        assert!(sources[2..].iter().all(|source| source.original_id == 20));
681        assert_eq!(sources[3].face_id, Some(103));
682    }
683
684    #[test]
685    fn malformed_run_boundaries_are_rejected() {
686        assert!(matches!(
687            triangle_source_ids_from_parts(6, &[], &[0, 4], &[10], -1),
688            Err(ProvenanceError::RunBoundary { .. })
689        ));
690        assert!(matches!(
691            triangle_source_ids_from_parts(6, &[], &[3], &[10], -1),
692            Err(ProvenanceError::UncoveredTriangle { triangle: 0 })
693        ));
694    }
695
696    #[test]
697    fn cube_to_manifold_produces_mesh() {
698        let cube = Cube::new([10]);
699        let m = ToCsg::<ManifoldBackend>::to_csg(&cube);
700        let (props, n_props, indices) = m.to_mesh_f32_with_normals(3);
701        assert!(n_props > 0, "should have vertex properties");
702        assert!(!props.is_empty(), "should have vertex data");
703        assert!(!indices.is_empty(), "should have triangle indices");
704    }
705
706    #[test]
707    fn cube_has_triangle_provenance_for_six_faces() {
708        let solid = ToCsg::<ManifoldBackend>::to_csg(&Cube::new([10]));
709        let (_, _, indices) = solid.to_mesh_f32();
710        let sources = solid.triangle_source_ids().unwrap();
711        assert_eq!(sources.len(), indices.len() / 3);
712        assert!(
713            sources
714                .iter()
715                .all(|id| id.original_id == sources[0].original_id)
716        );
717        let faces: std::collections::HashSet<_> =
718            sources.iter().filter_map(|id| id.face_id).collect();
719        assert_eq!(faces.len(), 6);
720    }
721
722    #[test]
723    fn translate_preserves_mesh() {
724        let shape = rscad_core::Translate::new(Cube::new([5]), 1.0, 2.0, 3.0);
725        let m = ToCsg::<ManifoldBackend>::to_csg(&shape);
726        let (_, _, indices) = m.to_mesh_f32_with_normals(3);
727        assert!(!indices.is_empty());
728    }
729
730    #[test]
731    fn union_combines_two_cubes() {
732        let a = Cube::new([5]);
733        let b = rscad_core::Translate::new(Cube::new([5]), 3.0, 0.0, 0.0);
734        let shape = rscad_core::Union { this: a, other: b };
735        let m = ToCsg::<ManifoldBackend>::to_csg(&shape);
736        let (_, _, indices) = m.to_mesh_f32_with_normals(3);
737        assert!(!indices.is_empty());
738    }
739
740    #[test]
741    fn difference_subtracts() {
742        let a = Cube::new([10]);
743        let b = rscad_core::Translate::new(Cube::new([5]), 2.5, 2.5, 2.5);
744        let shape = rscad_core::Difference { this: a, other: b };
745        let m = ToCsg::<ManifoldBackend>::to_csg(&shape);
746        let (_, _, indices) = m.to_mesh_f32_with_normals(3);
747        assert!(!indices.is_empty());
748    }
749
750    #[test]
751    fn difference_retains_both_operand_colors() {
752        let scene = DynamicObject::Difference {
753            children: vec![
754                colored(
755                    [1.0, 0.0, 0.0, 1.0],
756                    DynamicObject::Cube {
757                        size: [10.0, 10.0, 10.0],
758                    },
759                ),
760                colored(
761                    [0.0, 0.0, 1.0, 1.0],
762                    DynamicObject::Translate {
763                        offset: [2.5, 2.5, 2.5],
764                        child: Box::new(DynamicObject::Cube {
765                            size: [5.0, 5.0, 5.0],
766                        }),
767                    },
768                ),
769            ],
770        };
771        let solid = ToCsg::<ManifoldBackend>::to_csg(&scene);
772        let sources = solid.triangle_source_ids().unwrap();
773        let colors: std::collections::HashSet<[u32; 4]> = sources
774            .iter()
775            .filter_map(|id| solid.surface_metadata(*id)?.color)
776            .map(|color| color.map(f32::to_bits))
777            .collect();
778        assert_eq!(colors.len(), 2);
779        assert!(colors.contains(&[1.0f32.to_bits(), 0, 0, 1.0f32.to_bits()]));
780        assert!(colors.contains(&[0, 0, 1.0f32.to_bits(), 1.0f32.to_bits()]));
781    }
782
783    #[test]
784    fn transforms_preserve_original_id_and_color() {
785        let scene = DynamicObject::Translate {
786            offset: [10.0, 20.0, 30.0],
787            child: Box::new(colored(
788                [0.25, 0.5, 0.75, 1.0],
789                DynamicObject::Cube {
790                    size: [1.0, 1.0, 1.0],
791                },
792            )),
793        };
794        let solid = ToCsg::<ManifoldBackend>::to_csg(&scene);
795        let sources = solid.triangle_source_ids().unwrap();
796        assert!(sources.iter().all(|id| {
797            solid.surface_metadata(*id).and_then(|meta| meta.color) == Some([0.25, 0.5, 0.75, 1.0])
798        }));
799    }
800
801    #[test]
802    fn inner_color_wins_like_bsp_metadata() {
803        let scene = colored(
804            [0.0, 0.0, 1.0, 1.0],
805            colored(
806                [1.0, 0.0, 0.0, 1.0],
807                DynamicObject::Cube {
808                    size: [1.0, 1.0, 1.0],
809                },
810            ),
811        );
812        let solid = ToCsg::<ManifoldBackend>::to_csg(&scene);
813        assert!(
814            solid
815                .metadata()
816                .values()
817                .all(|metadata| { metadata.color == Some([1.0, 0.0, 0.0, 1.0]) })
818        );
819    }
820
821    #[test]
822    fn empty_produces_no_mesh() {
823        let m = ToCsg::<ManifoldBackend>::to_csg(&rscad_core::Empty);
824        let (props, _, indices) = m.to_mesh_f32_with_normals(3);
825        assert!(props.is_empty());
826        assert!(indices.is_empty());
827    }
828
829    #[test]
830    fn linear_extrude_circle() {
831        let circle = rscad_core::Circle::new(5.0, 0.0, 0.0, 32);
832        let extruded = rscad_core::extrude::LinearExtrude::new(circle).with_height(10.0);
833        let m = ToCsg::<ManifoldBackend>::to_csg(&extruded);
834        let (_, _, indices) = m.to_mesh_f32_with_normals(3);
835        assert!(!indices.is_empty());
836    }
837
838    #[test]
839    fn cross_section_square() {
840        let sq = rscad_core::Square::new(5.0, 3.0);
841        let cs = ToSketch::<ManifoldBackend>::to_sketch(&sq);
842        let area = cs.area();
843        assert!((area - 15.0).abs() < 0.01, "square area should be ~15.0");
844    }
845
846    #[test]
847    fn sketch_profile_with_hole() {
848        use rscad_core::sketch::SketchData;
849        let mut data = SketchData::default();
850        data.add_rect(glam::DVec2::ZERO, glam::DVec2::new(2.0, 1.0));
851        data.add_circle(glam::DVec2::new(1.0, 0.5), 0.25);
852        let cs = ToSketch::<ManifoldBackend>::to_sketch(&DynamicObject::Sketch { data });
853        let area = cs.area();
854        let expected = 2.0 - core::f64::consts::PI * 0.25 * 0.25;
855        // Tessellated circle comes in slightly under the exact π r².
856        assert!((area - expected).abs() < 0.02, "area {area} vs {expected}");
857    }
858
859    /// Crossing outers stay CCW after winding normalization, so positive fill
860    /// resolves their overlap as a union.
861    #[test]
862    fn sketch_crossing_outers_union_area() {
863        use rscad_core::sketch::SketchData;
864        let mut data = SketchData::default();
865        data.add_rect(glam::DVec2::ZERO, glam::DVec2::new(2.0, 2.0));
866        data.add_rect(glam::DVec2::new(1.0, 1.0), glam::DVec2::new(3.0, 3.0));
867        let cs = ToSketch::<ManifoldBackend>::to_sketch(&DynamicObject::Sketch { data });
868        let area = cs.area();
869        let expected = 4.0 + 4.0 - 1.0;
870        assert!((area - expected).abs() < 0.01, "area {area} vs {expected}");
871    }
872
873    #[cfg(feature = "bevy")]
874    #[test]
875    fn bevy_mesh_carries_surface_ids_and_mixed_colors() {
876        use bevy_mesh::{Mesh as BevyMesh, VertexAttributeValues};
877        use rscad_csg_traits::CsgToMesh;
878
879        let scene = DynamicObject::Union {
880            children: vec![
881                colored(
882                    [1.0, 0.0, 0.0, 1.0],
883                    DynamicObject::Cube {
884                        size: [1.0, 1.0, 1.0],
885                    },
886                ),
887                DynamicObject::Translate {
888                    offset: [2.0, 0.0, 0.0],
889                    child: Box::new(DynamicObject::Cube {
890                        size: [1.0, 1.0, 1.0],
891                    }),
892                },
893            ],
894        };
895        let solid = ToCsg::<ManifoldBackend>::to_csg(&scene);
896        let mesh = ManifoldBackend::to_bevy_mesh(&solid);
897        assert!(
898            mesh.indices().is_none(),
899            "triangle vertices must be separated"
900        );
901
902        let surface_ids = match mesh.attribute(ATTRIBUTE_SURFACE_ID).unwrap() {
903            VertexAttributeValues::Uint32x2(values) => values,
904            other => panic!("unexpected surface ID format: {other:?}"),
905        };
906        let colors = match mesh.attribute(BevyMesh::ATTRIBUTE_COLOR).unwrap() {
907            VertexAttributeValues::Float32x4(values) => values,
908            other => panic!("unexpected color format: {other:?}"),
909        };
910        assert_eq!(surface_ids.len(), mesh.count_vertices());
911        assert_eq!(colors.len(), mesh.count_vertices());
912        assert!(
913            surface_ids
914                .chunks_exact(3)
915                .all(|tri| tri[0] == tri[1] && tri[1] == tri[2])
916        );
917        assert!(colors.contains(&[1.0, 0.0, 0.0, 1.0]));
918        assert!(colors.contains(&[1.0; 4]));
919    }
920
921    #[cfg(feature = "bevy")]
922    #[test]
923    fn uncolored_bevy_mesh_omits_color_attribute() {
924        use bevy_mesh::Mesh as BevyMesh;
925        use rscad_csg_traits::CsgToMesh;
926
927        let solid = ToCsg::<ManifoldBackend>::to_csg(&Cube::new([1]));
928        let mesh = ManifoldBackend::to_bevy_mesh(&solid);
929        assert!(mesh.attribute(ATTRIBUTE_SURFACE_ID).is_some());
930        assert!(mesh.attribute(BevyMesh::ATTRIBUTE_COLOR).is_none());
931    }
932}