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}