Skip to main content

geop_ops_booleans/
scenes.rs

1//! Reusable named test scenes for the booleans test suite: pairs of solids,
2//! each built in their own fresh [`Part`] purely from `basic_shapes` — no
3//! dependency on any boolean/remesh machinery (all still disabled pending
4//! step-by-step revival, see `mod`'s own doc comment). Later
5//! revival steps grow what they *do* with a [`TestScene`]; for now,
6//! `topology_render_test` just renders each one's raw topology, as a first
7//! check that every scene builds cleanly against the current `Model` API.
8//!
9//! Two families, mirroring what the old (pre-API-change) booleans suite
10//! covered: a systematic offset *grid* (every relative position of two
11//! identical solids, on axis-aligned steps) and a set of hand-picked
12//! box/figure8-vs-cylinder arrangements (holes drilled through, blind
13//! holes, nested/disjoint, corner overlaps, coincident faces, etc.).
14
15use geop_core_math::{scalars::Scalar, vector::Vector3};
16use geop_core_part::Part;
17use geop_core_topology::SolidId;
18use geop_ops_extrude_revolve::{
19    cube::cube_solid, cylinder::revolved_cylinder, figure8_profile::figure8_profile,
20    sphere::sphere_solid,
21};
22use rayon::prelude::*;
23
24/// A named pair of solids, built together in one [`Part`] as the
25/// operations `a` and `b`, ready for whatever a boolean-revival step wants
26/// to do with them.
27pub struct TestScene<S: Scalar> {
28    pub name: String,
29    pub part: Part<S>,
30    pub solid_a: SolidId,
31    pub solid_b: SolidId,
32}
33
34/// Turn an offset value into a filesystem-safe label, e.g. `-0.5` -> `n0p5`.
35fn offset_label(v: f64) -> String {
36    format!("{v:.2}").replace('-', "n").replace('.', "p")
37}
38
39/// A unit cube (side length 1) centered at `(cx, cy, cz)`.
40fn unit_cube_at<S: Scalar>(part: &mut Part<S>, name: &str, cx: f64, cy: f64, cz: f64) -> SolidId {
41    let f = S::from_f64;
42    let min = Vector3::from_array([f(cx - 0.5), f(cy - 0.5), f(cz - 0.5)]);
43    let max = Vector3::from_array([f(cx + 0.5), f(cy + 0.5), f(cz + 0.5)]);
44    cube_solid(part, name, min, max).unwrap()
45}
46
47/// A capped cylinder of `radius`/`height`, axis parallel to z, bottom cap
48/// centered at `(cx, cy, cz)`.
49fn cyl<S: Scalar>(
50    part: &mut Part<S>,
51    name: &str,
52    cx: f64,
53    cy: f64,
54    cz: f64,
55    radius: f64,
56    height: f64,
57) -> SolidId {
58    let f = S::from_f64;
59    revolved_cylinder(
60        part,
61        name,
62        Vector3::from_array([f(cx), f(cy), f(cz)]),
63        f(radius),
64        f(height),
65    )
66    .unwrap()
67}
68
69// ─────────────────────────────── grid tests ────────────────────────────────
70
71/// Two unit cubes: one fixed at the origin, the other swept across every
72/// combination of `[-1, -0.5, 0, 0.5, 1]` along each axis (125 scenes) —
73/// covering every axis-aligned relative position from fully disjoint through
74/// every partial-overlap and face/edge/corner-coincident configuration.
75pub fn box_grid_scenes<S: Scalar>() -> Vec<TestScene<S>> {
76    let offsets = [-1.0_f64, -0.5, 0.0, 0.5, 1.0];
77    let mut scenes = Vec::new();
78    for &ox in &offsets {
79        for &oy in &offsets {
80            for &oz in &offsets {
81                let mut part = Part::<S>::new();
82                let solid_a = unit_cube_at(&mut part, "a", 0.0, 0.0, 0.0);
83                let solid_b = unit_cube_at(&mut part, "b", ox, oy, oz);
84                let name = format!(
85                    "box_grid_{}_{}_{}",
86                    offset_label(ox),
87                    offset_label(oy),
88                    offset_label(oz)
89                );
90                scenes.push(TestScene {
91                    name,
92                    part,
93                    solid_a,
94                    solid_b,
95                });
96            }
97        }
98    }
99    scenes
100}
101
102/// Two unit-radius spheres: one fixed at the origin, the other swept across
103/// every combination of `[-0.5, 0, 0.5]` along each axis, skipping the fully
104/// coincident `(0, 0, 0)` case (26 scenes) — that configuration is a
105/// degenerate coplanar (every point of one sphere's surface touches the
106/// other) rather than a transversal intersection, to be handled separately.
107pub fn sphere_grid_scenes<S: Scalar>() -> Vec<TestScene<S>> {
108    let offsets = [-0.5_f64, 0.0, 0.5];
109    let mut scenes = Vec::new();
110    for &ox in &offsets {
111        for &oy in &offsets {
112            for &oz in &offsets {
113                if ox == 0.0 && oy == 0.0 && oz == 0.0 {
114                    continue;
115                }
116                let mut part = Part::<S>::new();
117                let solid_a = sphere_solid(&mut part, "a", Vector3::zero(), S::ONE).unwrap();
118                let center_b =
119                    Vector3::from_array([S::from_f64(ox), S::from_f64(oy), S::from_f64(oz)]);
120                let solid_b = sphere_solid(&mut part, "b", center_b, S::ONE).unwrap();
121                let name = format!(
122                    "sphere_grid_{}_{}_{}",
123                    offset_label(ox),
124                    offset_label(oy),
125                    offset_label(oz)
126                );
127                scenes.push(TestScene {
128                    name,
129                    part,
130                    solid_a,
131                    solid_b,
132                });
133            }
134        }
135    }
136    scenes
137}
138
139// ────────────────────────── box/figure8 vs cylinder ────────────────────────
140
141/// One hand-picked `(name, cx, cy, cz, radius, height)` cylinder arrangement.
142struct CylinderCase {
143    name: &'static str,
144    cx: f64,
145    cy: f64,
146    cz: f64,
147    radius: f64,
148    height: f64,
149}
150
151/// 12 arrangements against a unit box centered at the origin
152/// (`[-0.5, 0.5]^3`): holes drilled through, blind holes, fully
153/// nested/disjoint, axial/radial partial overlaps, corner/quarter overlaps,
154/// coincident-face imprints, etc. — see the old `cylinder_test`
155/// (pre-API-change) for the geometric reasoning behind each one.
156const BOX_CYLINDER_CASES: &[CylinderCase] = &[
157    CylinderCase {
158        name: "drilled_hole_through",
159        cx: 0.0,
160        cy: 0.0,
161        cz: -1.0,
162        radius: 0.2,
163        height: 2.0,
164    },
165    CylinderCase {
166        name: "blind_hole",
167        cx: 0.0,
168        cy: 0.0,
169        cz: -1.0,
170        radius: 0.2,
171        height: 1.2,
172    },
173    CylinderCase {
174        name: "fully_enclosed",
175        cx: 0.0,
176        cy: 0.0,
177        cz: -0.2,
178        radius: 0.1,
179        height: 0.4,
180    },
181    CylinderCase {
182        name: "fully_outside",
183        cx: 2.0,
184        cy: 2.0,
185        cz: -0.5,
186        radius: 0.2,
187        height: 1.0,
188    },
189    CylinderCase {
190        name: "axial_half_out_top",
191        cx: 0.0,
192        cy: 0.0,
193        cz: 0.0,
194        radius: 0.2,
195        height: 1.0,
196    },
197    CylinderCase {
198        name: "radial_half_out",
199        cx: 0.5,
200        cy: 0.0,
201        cz: -0.2,
202        radius: 0.3,
203        height: 0.4,
204    },
205    CylinderCase {
206        name: "engulfing_disk",
207        cx: 0.0,
208        cy: 0.0,
209        cz: -0.1,
210        radius: 0.9,
211        height: 0.2,
212    },
213    CylinderCase {
214        name: "corner_quarter_overlap",
215        cx: 0.5,
216        cy: 0.5,
217        cz: -0.2,
218        radius: 0.3,
219        height: 0.4,
220    },
221    CylinderCase {
222        name: "sliver_overlap",
223        cx: 1.0,
224        cy: 0.0,
225        cz: -0.2,
226        radius: 0.51,
227        height: 0.4,
228    },
229    CylinderCase {
230        name: "off_axis_hole",
231        cx: 0.3,
232        cy: 0.3,
233        cz: -1.0,
234        radius: 0.15,
235        height: 2.0,
236    },
237    CylinderCase {
238        name: "axial_and_radial_partial",
239        cx: 0.0,
240        cy: 0.0,
241        cz: 0.3,
242        radius: 0.6,
243        height: 0.4,
244    },
245    CylinderCase {
246        name: "cap_coincident_with_faces",
247        cx: 0.0,
248        cy: 0.0,
249        cz: -0.5,
250        radius: 0.2,
251        height: 1.0,
252    },
253];
254
255/// 12 arrangements against `figure8_profile` — outer footprint `x, y in [0,
256/// 1]`, two square holes centered at `(0.1875, 0.5)` and `(0.8125, 0.5)`,
257/// each spanning `y in [1/3, 2/3]`, a narrow neck at `x in [0.375, 0.625]`
258/// joining the two lobes, spanning `z in [-1, 0]`. `cz` here is every case's
259/// old (pre-API-change) value shifted by `-1`, since `figure8_profile` used
260/// to be built at `z in [0, 1]` and is now fixed at `z in [-1, 0]`.
261const FIGURE8_CYLINDER_CASES: &[CylinderCase] = &[
262    CylinderCase {
263        name: "through_left_hole_clear",
264        cx: 0.1875,
265        cy: 0.5,
266        cz: -1.5,
267        radius: 0.05,
268        height: 2.0,
269    },
270    CylinderCase {
271        name: "through_left_hole_oversized",
272        cx: 0.1875,
273        cy: 0.5,
274        cz: -1.5,
275        radius: 0.1,
276        height: 2.0,
277    },
278    CylinderCase {
279        name: "through_right_hole_clear",
280        cx: 0.8125,
281        cy: 0.5,
282        cz: -1.5,
283        radius: 0.05,
284        height: 2.0,
285    },
286    CylinderCase {
287        name: "through_solid_new_hole",
288        cx: 0.1875,
289        cy: 0.15,
290        cz: -1.5,
291        radius: 0.08,
292        height: 2.0,
293    },
294    CylinderCase {
295        name: "through_neck",
296        cx: 0.5,
297        cy: 0.5,
298        cz: -1.5,
299        radius: 0.1,
300        height: 2.0,
301    },
302    CylinderCase {
303        name: "through_neck_oversized",
304        cx: 0.5,
305        cy: 0.5,
306        cz: -1.5,
307        radius: 0.2,
308        height: 2.0,
309    },
310    CylinderCase {
311        name: "straddle_neck_lobe_boundary",
312        cx: 0.4,
313        cy: 0.5,
314        cz: -1.5,
315        radius: 0.1,
316        height: 2.0,
317    },
318    CylinderCase {
319        name: "fully_outside_footprint",
320        cx: 2.0,
321        cy: 2.0,
322        cz: -1.5,
323        radius: 0.1,
324        height: 1.0,
325    },
326    CylinderCase {
327        name: "engulfing_thin_slice",
328        cx: 0.5,
329        cy: 0.5,
330        cz: -0.7,
331        radius: 1.5,
332        height: 0.1,
333    },
334    CylinderCase {
335        name: "cap_coincident_with_faces",
336        cx: 0.1875,
337        cy: 0.15,
338        cz: -1.0,
339        radius: 0.1,
340        height: 1.0,
341    },
342    CylinderCase {
343        name: "quarter_overlap_hole_corner",
344        cx: 0.125,
345        cy: 1.0 / 3.0,
346        cz: -1.5,
347        radius: 0.08,
348        height: 2.0,
349    },
350    CylinderCase {
351        name: "engulfing_both_lobes",
352        cx: 0.5,
353        cy: 0.5,
354        cz: -1.5,
355        radius: 0.7,
356        height: 2.0,
357    },
358];
359
360/// The `BOX_CYLINDER_CASES` arrangements, each as its own `TestScene`
361/// (`solid_a` the box, `solid_b` the cylinder), named `box_cylinder_<case>`.
362pub fn box_cylinder_scenes<S: Scalar>() -> Vec<TestScene<S>> {
363    BOX_CYLINDER_CASES
364        .iter()
365        .map(|c| {
366            let mut part = Part::<S>::new();
367            let solid_a = unit_cube_at(&mut part, "a", 0.0, 0.0, 0.0);
368            let solid_b = cyl(&mut part, "b", c.cx, c.cy, c.cz, c.radius, c.height);
369            TestScene {
370                name: format!("box_cylinder_{}", c.name),
371                part,
372                solid_a,
373                solid_b,
374            }
375        })
376        .collect()
377}
378
379/// The `FIGURE8_CYLINDER_CASES` arrangements, each as its own `TestScene`
380/// (`solid_a` the figure-8 extrusion, `solid_b` the cylinder), named
381/// `figure8_cylinder_<case>`.
382pub fn figure8_cylinder_scenes<S: Scalar>() -> Vec<TestScene<S>> {
383    FIGURE8_CYLINDER_CASES
384        .iter()
385        .map(|c| {
386            let mut part = Part::<S>::new();
387            let solid_a = figure8_profile(&mut part, "a").unwrap();
388            let solid_b = cyl(&mut part, "b", c.cx, c.cy, c.cz, c.radius, c.height);
389            TestScene {
390                name: format!("figure8_cylinder_{}", c.name),
391                part,
392                solid_a,
393                solid_b,
394            }
395        })
396        .collect()
397}
398
399/// Every scene in the suite: both grid sweeps, then both hand-picked
400/// cylinder arrangement sets.
401pub fn all_scenes<S: Scalar>() -> Vec<TestScene<S>> {
402    let mut scenes = box_grid_scenes::<S>();
403    scenes.extend(sphere_grid_scenes::<S>());
404    scenes.extend(box_cylinder_scenes::<S>());
405    scenes.extend(figure8_cylinder_scenes::<S>());
406    scenes
407}
408
409#[cfg(test)]
410mod topology_render_test {
411    use super::*;
412    use geop_core_math::{primitives::Color10, scalars::ScalInF64};
413    use geop_ops_rasterize::rasterize_model_with_face_color;
414
415    /// Render every scene's raw topology (no boolean/remesh operation at
416    /// all — just the two solids as built) to `outputs/topology_tests/`,
417    /// `solid_a`'s faces in blue and `solid_b`'s in purple. This is
418    /// deliberately the *only* thing this first revival step does: confirm
419    /// every scene builds and rasterizes cleanly against the current
420    /// `Model`/`basic_shapes` API before any tracing/remeshing logic gets
421    /// switched back on.
422    ///
423    /// `ScalInF64` only, not `for_all_scalars!`: this sweeps 175 scenes (125
424    /// box-grid + 26 sphere-grid + 24 hand-picked), and interval-arithmetic
425    /// scalars pay a real per-operation cost that isn't worth it for a pure
426    /// rendering smoke test — see `remesh_test`'s own doc comment for the
427    /// same tradeoff.
428    #[test]
429    fn render_all_scenes_topology() {
430        let dir = "outputs/topology_tests";
431        std::fs::create_dir_all(dir).unwrap();
432
433        for scene in all_scenes::<ScalInF64>() {
434            let model = scene.part.topology();
435            let faces_a = model.solid_faces(scene.solid_a).unwrap();
436            let scene_render = rasterize_model_with_face_color(model, 12, |face_id| {
437                if faces_a.contains(&face_id) {
438                    Color10::Blue
439                } else {
440                    Color10::Purple
441                }
442            })
443            .unwrap();
444            scene_render
445                .save_to_file(&format!("{dir}/{}.html", scene.name))
446                .unwrap();
447        }
448    }
449}
450
451/// Run `f` over every scene, spread across threads, and return the results in
452/// scene order.
453///
454/// Each scene owns its own `Part` and shares nothing with the others, so the
455/// sweeps over them are embarrassingly parallel — and slow enough
456/// (a full remesh or boolean per scene, 175 of them) that running them serially
457/// dominates the test suite's wall time. `rayon`'s global pool (fixed at
458/// `available_parallelism` threads, lazily started once per process) does the
459/// spreading, one job per scene — never more than one thread working on any
460/// single scene.
461///
462/// The pool is process-wide and shared by every caller, in this crate or
463/// otherwise, that uses rayon — critically including the several render
464/// tests that each call this function and that `cargo test` runs
465/// concurrently by default. Spawning a fresh `std::thread::scope` of
466/// `available_parallelism` threads per call (the previous approach) let
467/// those tests oversubscribe the machine several times over, which was
468/// enough contention to make an unrelated timing-sensitive test fail
469/// spuriously. Going through the shared pool instead means concurrent
470/// callers queue for the same fixed set of workers rather than each getting
471/// their own, so total parallelism across the whole test binary stays
472/// bounded at the core count.
473///
474/// `par_iter`'s `collect` preserves the source order regardless of which
475/// worker finished a given scene first, so a caller's output does not depend
476/// on scheduling.
477pub fn map_scenes_parallel<S, T, F>(f: F) -> Vec<T>
478where
479    S: Scalar,
480    T: Send,
481    F: Fn(TestScene<S>) -> T + Sync + Send,
482{
483    all_scenes::<S>().into_par_iter().map(f).collect()
484}