Skip to main content

rscad_csg_bsp/
bridge.rs

1//! BSP backend: implements the unified `rscad-csg-traits` traits for `Csg<f64>`.
2
3use glam::DVec3;
4
5use crate::Csg;
6use rscad_csg_traits::{CsgBackend, CsgToStl, DynamicBackend, ToCsg, ToSketch};
7
8/// BSP-based CSG backend.
9pub struct BspBackend;
10
11impl CsgBackend for BspBackend {
12    type CsgType = Csg<f64>;
13    type SketchType = Csg<f64>;
14
15    fn empty_csg() -> Csg<f64> {
16        Csg::new(vec![])
17    }
18    fn empty_sketch() -> Csg<f64> {
19        Csg::new(vec![])
20    }
21
22    fn union(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
23        a.union(b)
24    }
25    fn difference(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
26        a.difference(b)
27    }
28    fn intersection(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
29        a.intersection(b)
30    }
31    fn xor(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
32        a.xor(b)
33    }
34
35    fn union_2d(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
36        a.union(b)
37    }
38    fn difference_2d(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
39        a.difference(b)
40    }
41    fn intersection_2d(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
42        a.intersection(b)
43    }
44    fn xor_2d(a: Csg<f64>, b: Csg<f64>) -> Csg<f64> {
45        a.xor(b)
46    }
47
48    fn translate(csg: Csg<f64>, offset: DVec3) -> Csg<f64> {
49        csg.translate(offset.into())
50    }
51    fn rotate(csg: Csg<f64>, angles: DVec3) -> Csg<f64> {
52        csg.rotate_euler(angles.into())
53    }
54    fn scale(csg: Csg<f64>, factors: DVec3) -> Csg<f64> {
55        csg.scale(factors.into())
56    }
57    fn mirror(csg: Csg<f64>, axes: DVec3) -> Csg<f64> {
58        csg.mirror(axes.into())
59    }
60
61    fn translate_2d(sketch: Csg<f64>, offset: DVec3) -> Csg<f64> {
62        sketch.translate(offset.into())
63    }
64    fn rotate_2d(sketch: Csg<f64>, angles: DVec3) -> Csg<f64> {
65        sketch.rotate_euler(angles.into())
66    }
67    fn scale_2d(sketch: Csg<f64>, factors: DVec3) -> Csg<f64> {
68        sketch.scale(factors.into())
69    }
70    fn mirror_2d(sketch: Csg<f64>, axes: DVec3) -> Csg<f64> {
71        sketch.mirror(axes.into())
72    }
73
74    fn color(mut csg: Csg<f64>, color: [f32; 4]) -> Csg<f64> {
75        apply_color(&mut csg, color);
76        csg
77    }
78    fn color_2d(mut sketch: Csg<f64>, color: [f32; 4]) -> Csg<f64> {
79        apply_color(&mut sketch, color);
80        sketch
81    }
82
83    fn extrude(sketch: Csg<f64>, height: f64, twist: f64, scale: f64, slices: usize) -> Csg<f64> {
84        use crate::Affine3Ext;
85        let along = nalgebra::Vector3::new(0.0, height, 0.0);
86        let transform = nalgebra::Affine3::identity().scale(scale).rotate_y(twist);
87        sketch
88            .extrude(along, transform, slices)
89            .unwrap_or_else(|_| Csg::new(vec![]))
90    }
91
92    fn revolve(sketch: Csg<f64>, angle_degrees: f64, segments: usize) -> Csg<f64> {
93        sketch
94            .lathe(angle_degrees, segments)
95            .unwrap_or_else(|_| Csg::new(vec![]))
96    }
97}
98
99// ── 3D Primitives ──────────────────────────────────────────
100
101impl ToCsg<BspBackend> for rscad_core::Cube {
102    fn to_csg(&self) -> Csg<f64> {
103        crate::cuboid(self.x, self.y, self.z)
104    }
105}
106
107impl ToCsg<BspBackend> for rscad_core::Sphere {
108    fn to_csg(&self) -> Csg<f64> {
109        crate::sphere(self.radius, self.fn_, self.stacks)
110    }
111}
112
113impl ToCsg<BspBackend> for rscad_core::Cylinder {
114    fn to_csg(&self) -> Csg<f64> {
115        crate::cylinder(self.radius, self.height, self.fn_)
116    }
117}
118
119// ── 2D Primitives ──────────────────────────────────────────
120
121impl ToSketch<BspBackend> for rscad_core::Circle {
122    fn to_sketch(&self) -> Csg<f64> {
123        crate::circle(self.radius, self.fn_)
124    }
125}
126
127impl ToSketch<BspBackend> for rscad_core::Square {
128    fn to_sketch(&self) -> Csg<f64> {
129        // `rect` is centered; core `Square` is corner-anchored like the other backends.
130        crate::rect(self.x, self.y).translate(nalgebra::Vector3::new(
131            self.x / 2.0,
132            0.0,
133            self.y / 2.0,
134        ))
135    }
136}
137
138impl<const N: usize> ToSketch<BspBackend> for rscad_core::Polygon<N> {
139    fn to_sketch(&self) -> Csg<f64> {
140        crate::regular_polygon(self.radius, N)
141    }
142}
143
144impl ToSketch<BspBackend> for rscad_core::PolygonDynamic {
145    fn to_sketch(&self) -> Csg<f64> {
146        crate::regular_polygon(self.radius, self.points)
147    }
148}
149
150// ── DynamicObject (generic dispatch via DynamicBackend) ────
151
152impl DynamicBackend for BspBackend {
153    /// 2D and 3D share `Csg<f64>`; bare sketches keep their geometry in 3D
154    /// context (and vice versa) through the identity bridges below.
155    const SKETCH_IS_CSG: bool = true;
156
157    fn cube(size: [f64; 3]) -> Csg<f64> {
158        let [x, y, z] = size;
159        // Factory cuboid is centered; DynamicObject cubes are corner-anchored.
160        factory_prim(|f| f.cuboid(x, y, z)).translate(nalgebra::Vector3::new(
161            x * 0.5,
162            y * 0.5,
163            z * 0.5,
164        ))
165    }
166    fn sphere(radius: f64, segments: usize, stacks: usize) -> Csg<f64> {
167        factory_prim(|f| f.sphere(radius, segments, stacks))
168    }
169    fn cylinder(radius: f64, height: f64, segments: usize) -> Csg<f64> {
170        // Factory cylinder is centered; base belongs on y=0.
171        factory_prim(|f| f.cylinder(radius, height, segments)).translate(nalgebra::Vector3::new(
172            0.0,
173            height * 0.5,
174            0.0,
175        ))
176    }
177    fn cone(radius: f64, height: f64, segments: usize) -> Csg<f64> {
178        factory_prim(|f| f.cone(radius, height, segments)).translate(nalgebra::Vector3::new(
179            0.0,
180            height * 0.5,
181            0.0,
182        ))
183    }
184
185    fn circle(radius: f64, segments: usize) -> Csg<f64> {
186        crate::circle(radius, segments)
187    }
188    fn square(size: [f64; 2]) -> Csg<f64> {
189        // `rect` is centered; DynamicObject squares are corner-anchored.
190        crate::rect(size[0], size[1]).translate(nalgebra::Vector3::new(
191            size[0] / 2.0,
192            0.0,
193            size[1] / 2.0,
194        ))
195    }
196    fn polygon(points: &[[f64; 2]]) -> Csg<f64> {
197        let pts = points
198            .iter()
199            .map(|[x, z]| nalgebra::Point::from([*x, 0.0, *z]))
200            .collect::<Vec<_>>();
201        Csg::from_polygons([crate::Polygon::from_points(pts)
202            .expect("BUG: DynamicObject::Polygon points must form a valid polygon")])
203    }
204    fn sketch(loops: &[Vec<glam::DVec2>]) -> Csg<f64> {
205        // Winding is normalized (CCW outers, CW holes); build every loop
206        // as a CCW flat XZ polygon and subtract the holes.
207        let flat = |l: &[glam::DVec2], reverse: bool| {
208            let pts: Vec<nalgebra::Point3<f64>> = if reverse {
209                l.iter()
210                    .rev()
211                    .map(|p| nalgebra::Point::from([p.x, 0.0, p.y]))
212                    .collect()
213            } else {
214                l.iter()
215                    .map(|p| nalgebra::Point::from([p.x, 0.0, p.y]))
216                    .collect()
217            };
218            crate::Polygon::from_points(pts)
219                .ok()
220                .map(|p| Csg::from_polygons([p]))
221        };
222        let (outers, holes): (Vec<&Vec<glam::DVec2>>, _) = loops
223            .iter()
224            .partition(|l| rscad_core::sketch::signed_area(l) >= 0.0);
225        let base = outers
226            .into_iter()
227            .filter_map(|l| flat(l, false))
228            .fold(Csg::new(vec![]), |acc, c| acc.union(c));
229        holes
230            .into_iter()
231            .filter_map(|l| flat(l, true))
232            .fold(base, |acc, c| acc.difference(c))
233    }
234
235    fn sketch_as_csg(sketch: Csg<f64>) -> Csg<f64> {
236        sketch
237    }
238    fn csg_as_sketch(csg: Csg<f64>) -> Csg<f64> {
239        csg
240    }
241}
242
243// ── CsgToMesh (Bevy) ───────────────────────────────────────
244
245#[cfg(feature = "bridge-bevy")]
246impl rscad_csg_traits::CsgToMesh for BspBackend {
247    fn to_bevy_mesh(solid: &Csg<f64>) -> bevy_mesh::Mesh {
248        solid.clone().cleanup().cast::<f32>().to_bevy_mesh()
249    }
250}
251
252// ── CsgToStl ───────────────────────────────────────────────
253
254impl CsgToStl for BspBackend {
255    fn write_stl_binary(solid: &Csg<f64>, writer: &mut dyn std::io::Write) -> std::io::Result<()> {
256        rscad_csg_traits::stl::write_stl_binary(writer, stl_triangles(solid).into_iter())
257    }
258
259    fn write_stl_ascii(solid: &Csg<f64>, writer: &mut dyn std::io::Write) -> std::io::Result<()> {
260        rscad_csg_traits::stl::write_stl_ascii(writer, stl_triangles(solid).into_iter())
261    }
262}
263
264fn stl_triangles(solid: &Csg<f64>) -> Vec<rscad_csg_traits::stl::StlTriangle> {
265    let cleaned = solid.clone().cleanup();
266    cleaned
267        .polygons()
268        .iter()
269        .flat_map(|polygon| {
270            let tessellated = polygon.tessellate_earcut::<u32>();
271            let normal = polygon.plane().normal();
272            let n = [normal.x as f32, normal.y as f32, normal.z as f32];
273            let verts = tessellated.polygon().vertices();
274            let tris: Vec<u32> = tessellated.triangles().iter().copied().collect();
275            tris.chunks(3)
276                .map(|tri| {
277                    let v = |i: u32| {
278                        let p = verts[i as usize];
279                        [p.x as f32, p.y as f32, p.z as f32]
280                    };
281                    (n, [v(tri[0]), v(tri[1]), v(tri[2])])
282                })
283                .collect::<Vec<_>>()
284        })
285        .collect()
286}
287
288// ── Helpers ────────────────────────────────────────────────
289
290fn apply_color(csg: &mut Csg<f64>, color: [f32; 4]) {
291    for (_, pm) in &mut csg.factory_mut().primitives {
292        if pm.color.is_none() {
293            pm.color = Some(color);
294        }
295    }
296}
297
298fn factory_prim(f: impl FnOnce(&mut crate::CsgFactory<f64>) -> Csg<f64>) -> Csg<f64> {
299    let mut factory = crate::CsgFactory::new();
300    let csg = f(&mut factory);
301    csg.with_factory(factory)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use rscad_core::Cube;
308
309    #[test]
310    fn cube_to_csg_produces_polygons() {
311        let cube = Cube::new([10]);
312        let csg = ToCsg::<BspBackend>::to_csg(&cube);
313        assert!(!csg.polygons().is_empty(), "cube should produce polygons");
314        assert_eq!(csg.polygons().len(), 6);
315    }
316
317    #[test]
318    fn translate_preserves_polygon_count() {
319        let shape = rscad_core::Translate::new(Cube::new([5]), 1.0, 2.0, 3.0);
320        let csg = ToCsg::<BspBackend>::to_csg(&shape);
321        assert_eq!(csg.polygons().len(), 6);
322    }
323
324    #[test]
325    fn union_combines_two_cubes() {
326        let a = Cube::new([5]);
327        let b = rscad_core::Translate::new(Cube::new([5]), 3.0, 0.0, 0.0);
328        let shape = rscad_core::Union { this: a, other: b };
329        let csg = ToCsg::<BspBackend>::to_csg(&shape);
330        assert!(!csg.polygons().is_empty());
331    }
332
333    #[test]
334    fn empty_produces_no_polygons() {
335        assert!(
336            ToCsg::<BspBackend>::to_csg(&rscad_core::Empty)
337                .polygons()
338                .is_empty()
339        );
340        assert!(ToCsg::<BspBackend>::to_csg(&()).polygons().is_empty());
341    }
342
343    // ── Lathe (revolve) ────────────────────────────────────
344
345    /// Signed volume via the divergence theorem; positive iff face windings
346    /// are consistently outward.
347    fn signed_volume(csg: &Csg<f64>) -> f64 {
348        csg.polygons()
349            .iter()
350            .map(|poly| {
351                let vs = poly.vertices();
352                let v0 = vs[0].coords;
353                (1..vs.len() - 1)
354                    .map(|i| v0.dot(&vs[i].coords.cross(&vs[i + 1].coords)) / 6.0)
355                    .sum::<f64>()
356            })
357            .sum()
358    }
359
360    /// Washer profile: x ∈ [1, 2], z ∈ [0, 1] in the XZ sketch plane.
361    fn washer_profile() -> Csg<f64> {
362        crate::rect(1.0, 1.0).translate(nalgebra::Vector3::new(1.5, 0.0, 0.5))
363    }
364
365    #[test]
366    fn lathe_full_circle_volume_and_bounds() {
367        let solid = washer_profile().lathe(360.0, 64).expect("lathe");
368        // Exact washer volume is π(R²−r²)h = 3π ≈ 9.42; inscribed polygon
369        // rings come in slightly under. Positive sign pins outward windings.
370        let vol = signed_volume(&solid);
371        assert!(vol > 8.8 && vol < 9.5, "volume {vol}");
372
373        let (mut max_r, mut min_y, mut max_y) = (0.0f64, f64::MAX, f64::MIN);
374        for p in solid.polygons() {
375            for v in p.vertices() {
376                max_r = max_r.max((v.x * v.x + v.z * v.z).sqrt());
377                min_y = min_y.min(v.y);
378                max_y = max_y.max(v.y);
379            }
380        }
381        assert!((max_r - 2.0).abs() < 1e-9, "radius {max_r}");
382        assert!(min_y.abs() < 1e-9 && (max_y - 1.0).abs() < 1e-9);
383    }
384
385    #[test]
386    fn lathe_partial_angle_is_capped_quarter() {
387        let solid = washer_profile().lathe(90.0, 16).expect("lathe");
388        let vol = signed_volume(&solid);
389        // A closed quarter sweep has ~1/4 the full volume; an uncapped sweep
390        // would not produce a consistent signed volume.
391        assert!((vol - 9.42 / 4.0).abs() < 0.3, "volume {vol}");
392    }
393
394    #[test]
395    fn lathe_on_axis_profile_drops_degenerates() {
396        // Profile touching the axis (x ∈ [0, 1]): inner wall collapses.
397        let profile = crate::rect(1.0, 1.0).translate(nalgebra::Vector3::new(0.5, 0.0, 0.5));
398        let solid = profile.lathe(360.0, 32).expect("lathe");
399        let vol = signed_volume(&solid);
400        // Solid cylinder r=1 h=1: π ≈ 3.14 (inscribed slightly less).
401        assert!(vol > 2.9 && vol < 3.2, "volume {vol}");
402    }
403}