Skip to main content

geop_ops_booleans/
boolean.rs

1//! Boolean operations on two solids, built on top of [`remesh`].
2//!
3//! The algorithm is deliberately simple, and it is simple *because* remesh
4//! has already done the hard part. After remeshing, every face of either
5//! solid lies wholly inside the other, wholly outside it, or exactly on its
6//! boundary — no face straddles, because every intersection curve has been
7//! imprinted and every face it crossed has been split. That turns a boolean
8//! into a per-face classification followed by a keep/drop table.
9//!
10//! 1. [`remesh`] the two solids against each other.
11//! 2. Classify each face with [`classify_face`], by taking a point strictly
12//!    inside its trimmed region and asking where it sits relative to the
13//!    other solid.
14//! 3. Keep the faces the operator wants (see [`BooleanOp::keeps`]), reversing
15//!    them where the operator needs the material on the other side.
16//! 4. Assemble the survivors into a new solid and discard everything else.
17
18use geop_core_math::{
19    geop_error::{GeopError, GeopResult, WithContext},
20    scalars::Scalar,
21    vector::Vector3,
22};
23use geop_core_part::{Namer, Part};
24use geop_core_topology::{
25    FaceId, Model, ShellId, SolidId,
26    contains::{
27        face::{PointClassification as FacePoint, face_contains, face_interior_point_where},
28        shell::{PointClassification as ShellPoint, shell_contains},
29    },
30};
31use serde::{Deserialize, Serialize};
32
33use crate::remesh::remesh::{RemeshParams, remesh};
34
35/// Which boolean to perform.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum BooleanOp {
39    /// Everything in either solid.
40    Union,
41    /// Only what is in both.
42    Intersection,
43    /// `solid_a` with `solid_b` removed.
44    Difference,
45}
46
47/// Where one solid's face sits relative to the *other* solid.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum FaceClassification {
50    /// Strictly inside the other solid.
51    Inside,
52    /// Strictly outside it.
53    Outside,
54    /// On its boundary, with the two surfaces' normals pointing the same way
55    /// — the two solids touch and lie on the same side of the shared patch.
56    OnSameNormal,
57    /// On its boundary, with the normals opposed — the solids meet along the
58    /// patch from opposite sides.
59    OnOppositeNormal,
60}
61
62/// What to do with a classified face.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64enum Keep {
65    /// Keep it as it is.
66    AsIs,
67    /// Keep it with its normal flipped — the operator wants the material on
68    /// the other side of this patch.
69    Reversed,
70    /// Drop it.
71    Drop,
72}
73
74impl BooleanOp {
75    /// Whether to keep a face classified as `class`, and with which
76    /// orientation. `from_a` says which solid the face came from, because the
77    /// two are not symmetric for [`BooleanOp::Difference`], and because a
78    /// coincident patch must be kept exactly once rather than from both.
79    ///
80    /// The coincident rules are the only subtle ones. A patch shared by both
81    /// solids with **matching** normals bounds the same material on the same
82    /// side, so union and intersection each keep exactly one copy (`a`'s, by
83    /// convention) and difference deletes it — the material behind it is
84    /// removed from both sides at once. A patch shared with **opposing**
85    /// normals is the reverse: it separates the two solids, so union and
86    /// intersection drop it (it is interior to the result) while difference
87    /// keeps `a`'s copy, since that is exactly the surface where `a` is left
88    /// open by removing `b`.
89    fn keeps(self, class: FaceClassification, from_a: bool) -> Keep {
90        use FaceClassification::*;
91        match (self, class) {
92            // Union: the result is bounded by whatever is outside the other.
93            (BooleanOp::Union, Outside) => Keep::AsIs,
94            (BooleanOp::Union, Inside) => Keep::Drop,
95            (BooleanOp::Union, OnSameNormal) => {
96                if from_a {
97                    Keep::AsIs
98                } else {
99                    Keep::Drop
100                }
101            }
102            (BooleanOp::Union, OnOppositeNormal) => Keep::Drop,
103
104            // Intersection: bounded by whatever is inside the other.
105            (BooleanOp::Intersection, Inside) => Keep::AsIs,
106            (BooleanOp::Intersection, Outside) => Keep::Drop,
107            (BooleanOp::Intersection, OnSameNormal) => {
108                if from_a {
109                    Keep::AsIs
110                } else {
111                    Keep::Drop
112                }
113            }
114            (BooleanOp::Intersection, OnOppositeNormal) => Keep::Drop,
115
116            // Difference (a - b) = a intersected with the complement of b, so
117            // `a`'s faces behave as for intersection-with-the-outside, and
118            // `b`'s surviving faces are the ones inside `a`, turned around to
119            // face into the cavity they now bound.
120            (BooleanOp::Difference, Outside) if from_a => Keep::AsIs,
121            (BooleanOp::Difference, Inside) if from_a => Keep::Drop,
122            (BooleanOp::Difference, Inside) => Keep::Reversed,
123            (BooleanOp::Difference, Outside) => Keep::Drop,
124            (BooleanOp::Difference, OnSameNormal) => Keep::Drop,
125            (BooleanOp::Difference, OnOppositeNormal) => {
126                if from_a {
127                    Keep::AsIs
128                } else {
129                    Keep::Drop
130                }
131            }
132        }
133    }
134}
135
136/// Fixed seed for the ray casting behind every containment query here. Both
137/// `face_contains` and `shell_contains` retry until they find a ray grazing
138/// nothing, so their answers are seed-independent; a constant keeps a boolean
139/// reproducible run to run.
140const SEED: u64 = 0xB001_EA47_0000_0001;
141
142/// `solid_a` combined with `solid_b` under `op`, as a new solid in `model`.
143///
144/// `Ok(None)` means the result is **empty**, which is an answer rather than a
145/// failure: intersecting two solids that do not overlap, or subtracting a
146/// solid that wholly contains the first, legitimately leaves nothing. Callers
147/// that treat "no solid" as an error would reject the majority of scenes in
148/// this crate's own test set.
149///
150/// Both input solids are consumed either way: their faces are transferred to
151/// the result or deleted, so neither id is valid afterwards.
152///
153/// The result is named `namer`'s root name, and everything the boolean
154/// creates on the way is named as [`crate::naming`] describes; every face,
155/// edge and vertex it keeps keeps its name.
156pub fn boolean<S: Scalar>(
157    part: &mut Part<S>,
158    namer: &Namer,
159    solid_a: SolidId,
160    solid_b: SolidId,
161    op: BooleanOp,
162    params: RemeshParams<S>,
163) -> GeopResult<Option<SolidId>> {
164    let ctx = |e: GeopError| {
165        e.with_context(format!(
166            "boolean(name={}, solid_a={solid_a}, solid_b={solid_b}, op={op:?})",
167            namer.root()
168        ))
169    };
170
171    remesh(part, namer, solid_a, solid_b, params).with_context(&ctx)?;
172    let model = part.topology();
173
174    let faces_a = model.solid_faces(solid_a).with_context(&ctx)?;
175    let faces_b = model.solid_faces(solid_b).with_context(&ctx)?;
176
177    let mut keep: Vec<FaceId> = Vec::new();
178    let mut reverse: Vec<FaceId> = Vec::new();
179    for (faces, from_a, other) in [(&faces_a, true, solid_b), (&faces_b, false, solid_a)] {
180        for &face_id in faces {
181            let class = classify_face(model, face_id, other, params)
182                .with_context(&ctx)
183                .with_context(&|e: GeopError| {
184                    e.with_context(format!("classifying face {face_id}"))
185                })?;
186            match op.keeps(class, from_a) {
187                Keep::AsIs => keep.push(face_id),
188                Keep::Reversed => {
189                    keep.push(face_id);
190                    reverse.push(face_id);
191                }
192                Keep::Drop => {}
193            }
194        }
195    }
196
197    for &face_id in &reverse {
198        part.reverse_face(face_id).with_context(&ctx)?;
199    }
200
201    part.assemble_solid(&[solid_a, solid_b], &keep, namer.root())
202        .with_context(&ctx)
203}
204
205/// Where `face_id` sits relative to `other_solid`, decided at a single point
206/// strictly inside the face's trimmed region.
207///
208/// One point is enough *because remesh ran first*: every curve along which
209/// the other solid's boundary crosses this face has been imprinted, and the
210/// face split along it, so the face no longer straddles anything. Without
211/// that guarantee this would be unsound, which is why it lives here rather
212/// than as a general-purpose query.
213///
214/// A point that lands *on* the other solid's boundary doesn't decide it by
215/// itself: the face may lie along that boundary over an area (coincident
216/// patches, told apart by their normals), or merely touch it at a point or
217/// along a curve — a cube face resting on a sphere's pole, say, where the
218/// sphere has no normal at all. So such a point is set aside and the next
219/// interior point tried (one per boundary coedge, see
220/// `face_interior_point_where`); any point off the other solid's boundary
221/// classifies the whole face. Only if every one lies on it are the patches
222/// coincident, and their normals are compared.
223pub fn classify_face<S: Scalar>(
224    model: &Model<S>,
225    face_id: FaceId,
226    other_solid: SolidId,
227    params: RemeshParams<S>,
228) -> GeopResult<FaceClassification> {
229    let face = model.get_face(face_id)?;
230    let shells = model.get_solid(other_solid)?.shells.clone();
231    let mut decided = None;
232    let mut on_boundary = Vec::new();
233    face_interior_point_where(
234        model,
235        face_id,
236        params.max_nodes,
237        params.curve_curve_min_subdivision_size,
238        SEED,
239        |u, v| {
240            let point = face.surface.evaluate(u, v)?;
241            for &shell_id in &shells {
242                match shell_contains(
243                    model,
244                    shell_id,
245                    point,
246                    params.max_nodes,
247                    params.curve_curve_min_subdivision_size,
248                    SEED,
249                )? {
250                    ShellPoint::Inside => {
251                        decided = Some(FaceClassification::Inside);
252                        return Ok(true);
253                    }
254                    ShellPoint::Outside => continue,
255                    ShellPoint::OnFace | ShellPoint::OnEdge | ShellPoint::OnVertex => {
256                        on_boundary.push((u, v, point, shell_id));
257                        return Ok(false);
258                    }
259                }
260            }
261            decided = Some(FaceClassification::Outside);
262            Ok(true)
263        },
264    )?;
265    if let Some(classification) = decided {
266        return Ok(classification);
267    }
268
269    // Every interior point lies on the other solid's boundary: the patches
270    // coincide, and which way the shared patch faces is what separates "these
271    // solids touch" from "these solids overlap along this patch". Any point
272    // of the shared patch shows that equally well, so the first where both
273    // normals exist is used — a revolve's cap collapses to its pole, where
274    // it has none.
275    let mut undefined = None;
276    for &(u, v, point, shell_id) in &on_boundary {
277        let normals = face
278            .surface
279            .normal(u, v)
280            .and_then(|n| Ok((n, shell_normal_at(model, shell_id, &point, params)?)));
281        let (this_normal, other_normal) = match normals {
282            Ok(normals) => normals,
283            Err(e) => {
284                undefined = Some(e);
285                continue;
286            }
287        };
288        let alignment = this_normal.prod_dot(&other_normal);
289        return if alignment.definitely_greater(S::ZERO) {
290            Ok(FaceClassification::OnSameNormal)
291        } else if alignment.definitely_less(S::ZERO) {
292            Ok(FaceClassification::OnOppositeNormal)
293        } else {
294            Err(GeopError::new(format!(
295                "boolean: face {face_id} lies on solid {other_solid}'s boundary at {point:?} (its interior point uv=({u:?}, {v:?})), but the two normals ({this_normal:?} and {other_normal:?}) are too close to perpendicular to tell which side is which"
296            )))
297        };
298    }
299    Err(match undefined {
300        Some(e) => e.with_context(format!(
301            "classify_face: face {face_id} lies on solid {other_solid}'s boundary at each of its {} interior points tried, and no normal comparison could be made at any of them",
302            on_boundary.len()
303        )),
304        None => GeopError::new(format!(
305            "classify_face: face {face_id} yielded interior points, yet none was classified or set aside"
306        )),
307    })
308}
309
310/// The normal of whichever face of `shell_id` contains `point`.
311fn shell_normal_at<S: Scalar>(
312    model: &Model<S>,
313    shell_id: ShellId,
314    point: &Vector3<S>,
315    params: RemeshParams<S>,
316) -> GeopResult<Vector3<S>> {
317    for &face_id in &model.get_shell(shell_id)?.faces {
318        let surface = &model.get_face(face_id)?.surface;
319        let Some((u, v)) = geop_core_geometry::contains::surface::surface_could_contain(
320            surface,
321            point,
322            params.max_nodes,
323            params.curve_curve_min_subdivision_size,
324        )?
325        else {
326            continue;
327        };
328        if !matches!(
329            face_contains(
330                model,
331                face_id,
332                u,
333                v,
334                params.max_nodes,
335                params.curve_curve_min_subdivision_size,
336                SEED,
337            )?,
338            FacePoint::Outside
339        ) {
340            return surface.normal(u, v);
341        }
342    }
343    Err(GeopError::new(format!(
344        "boolean: no face of shell {shell_id} contains {point:?}, although the shell reported the point on its boundary"
345    )))
346}
347
348#[cfg(test)]
349mod tests {
350    use super::{BooleanOp, FaceClassification, boolean, classify_face};
351    use crate::{remesh::remesh::RemeshParams, scenes::all_scenes};
352    use geop_core_math::{scalars::ScalInF64, scalars::Scalar, vector::Vector3};
353    use geop_core_part::Namer;
354    use geop_core_topology::{
355        contains::rng::Rng,
356        validation::{ValidationParameters, validate_fast},
357    };
358
359    fn scene(name: &str) -> crate::scenes::TestScene<ScalInF64> {
360        all_scenes::<ScalInF64>()
361            .into_iter()
362            .find(|s| s.name == name)
363            .unwrap_or_else(|| panic!("scene {name} must exist"))
364    }
365
366    /// A fresh operation id, so that every solid and boolean a test builds
367    /// gets names of its own.
368    fn fresh_id() -> String {
369        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
370        format!(
371            "op{}",
372            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
373        )
374    }
375
376    fn namer() -> Namer {
377        Namer::new("boolean", &fresh_id()).unwrap()
378    }
379
380    fn validation() -> ValidationParameters<ScalInF64> {
381        let params = RemeshParams::<ScalInF64>::default();
382        ValidationParameters {
383            max_nodes: params.max_nodes,
384            min_subdivision_size: params.curve_curve_min_subdivision_size,
385            ..ValidationParameters::default()
386        }
387    }
388
389    /// Every face of an un-remeshed cube is outside a cylinder that only
390    /// overlaps part of it — the classification itself, before any operator.
391    #[test]
392    fn classify_face_separates_inside_from_outside() {
393        let mut s = scene("box_cylinder_drilled_hole_through");
394        let params = RemeshParams::<ScalInF64>::default();
395        crate::remesh::remesh::remesh(&mut s.part, &namer(), s.solid_a, s.solid_b, params).unwrap();
396
397        let mut inside = 0;
398        let mut outside = 0;
399        for face_id in s.part.topology().solid_faces(s.solid_a).unwrap() {
400            match classify_face(s.part.topology(), face_id, s.solid_b, params).unwrap() {
401                FaceClassification::Inside => inside += 1,
402                FaceClassification::Outside => outside += 1,
403                _ => {}
404            }
405        }
406        assert!(
407            inside > 0 && outside > 0,
408            "a cylinder drilled through a cube must leave cube faces on both sides: {inside} inside, {outside} outside"
409        );
410    }
411
412    /// Every face must yield an interior point, or classification is
413    /// meaningless — this is the part of the algorithm with no fallback.
414    #[test]
415    fn every_remeshed_face_has_an_interior_point() {
416        let mut s = scene("box_cylinder_drilled_hole_through");
417        let params = RemeshParams::<ScalInF64>::default();
418        crate::remesh::remesh::remesh(&mut s.part, &namer(), s.solid_a, s.solid_b, params).unwrap();
419
420        for solid in [s.solid_a, s.solid_b] {
421            for face_id in s.part.topology().solid_faces(solid).unwrap() {
422                let (u, v) = geop_core_topology::contains::face::face_interior_point(
423                    s.part.topology(),
424                    face_id,
425                    params.max_nodes,
426                    params.curve_curve_min_subdivision_size,
427                    1234,
428                )
429                .unwrap_or_else(|e| panic!("face {face_id}: {e}"));
430                let _ = s
431                    .part
432                    .topology()
433                    .get_face(face_id)
434                    .unwrap()
435                    .surface
436                    .evaluate(u, v)
437                    .unwrap();
438            }
439        }
440    }
441
442    fn check_op(name: &str, op: BooleanOp) {
443        let mut s = scene(name);
444        let params = RemeshParams::<ScalInF64>::default();
445        let result = boolean(&mut s.part, &namer(), s.solid_a, s.solid_b, op, params)
446            .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"))
447            .unwrap_or_else(|| panic!("{name} {op:?}: result is empty"));
448
449        assert!(
450            !s.part.topology().solid_faces(result).unwrap().is_empty(),
451            "{name} {op:?}: result has no faces"
452        );
453        if let Err(errors) = validate_fast(&validation(), s.part.topology()) {
454            panic!(
455                "{name} {op:?}: {} validate_fast error(s): {}",
456                errors.len(),
457                errors[0]
458            );
459        }
460    }
461
462    /// Where a probe point lands relative to the result solid.
463    fn contains(
464        model: &geop_core_topology::Model<ScalInF64>,
465        solid: geop_core_topology::SolidId,
466        p: (f64, f64, f64),
467    ) -> bool {
468        let params = RemeshParams::<ScalInF64>::default();
469        let point = geop_core_math::vector::Vector3::from_array([
470            ScalInF64::from_f64(p.0),
471            ScalInF64::from_f64(p.1),
472            ScalInF64::from_f64(p.2),
473        ]);
474        let shell = model.get_solid(solid).unwrap().shells[0];
475        matches!(
476            geop_core_topology::contains::shell::shell_contains(
477                model,
478                shell,
479                point,
480                params.max_nodes,
481                params.curve_curve_min_subdivision_size,
482                0xA5A5_1234,
483            )
484            .unwrap(),
485            geop_core_topology::contains::shell::PointClassification::Inside
486        )
487    }
488
489    /// The cube is `[-0.5, 0.5]^3`; the cylinder has radius `0.2` on the z
490    /// axis and spans `z in [-1, 1]`. So each probe below is unambiguously in
491    /// one region, and the three operators must disagree about them in
492    /// exactly the way their definitions say. Structural validity says
493    /// nothing about *which* faces were kept — this is what does.
494    const IN_CUBE_ONLY: (f64, f64, f64) = (0.4, 0.4, 0.0);
495    const IN_CYLINDER_ONLY: (f64, f64, f64) = (0.0, 0.0, 0.8);
496    const IN_BOTH: (f64, f64, f64) = (0.0, 0.0, 0.0);
497
498    fn run(
499        op: BooleanOp,
500    ) -> (
501        geop_core_topology::Model<ScalInF64>,
502        geop_core_topology::SolidId,
503    ) {
504        let mut s = scene("box_cylinder_drilled_hole_through");
505        let params = RemeshParams::<ScalInF64>::default();
506        let result = boolean(&mut s.part, &namer(), s.solid_a, s.solid_b, op, params)
507            .unwrap()
508            .expect("this scene's operands overlap, so no operator is empty");
509        (s.part.topology().clone(), result)
510    }
511
512    /// Regression: two cubes meeting along a shared edge, where the traced
513    /// intersection curve *is* that edge — already part of both faces'
514    /// boundaries. Splicing it carved a zero-area sliver instead of splitting
515    /// anything. The direction check was passing because the corrector
516    /// returned a sharpened `(u, v)` sitting 6e-17 off the trim boundary,
517    /// which `face_contains` read as `Inside`; see
518    /// `predictor_corrector_step`.
519    #[test]
520    fn box_grid_n1p00_n0p50_n1p00_difference_succeeds() {
521        let mut s = scene("box_grid_n1p00_n0p50_n1p00");
522        let params = RemeshParams::<ScalInF64>::default();
523        boolean(
524            &mut s.part,
525            &namer(),
526            s.solid_a,
527            s.solid_b,
528            BooleanOp::Difference,
529            params,
530        )
531        .unwrap_or_else(|e| panic!("{e}"));
532    }
533
534    /// The defining property of a boolean, checked by sampling space rather
535    /// than by inspecting topology: for a point `p`, membership in the result
536    /// is a pure function of membership in the two operands.
537    ///
538    /// `union` keeps `p` iff either operand held it, `intersection` iff both
539    /// did, `difference` iff `a` held it and `b` did not. Nothing about faces,
540    /// coedges or orientation enters into it — which is exactly why it is
541    /// worth checking. A result can be structurally perfect and still enclose
542    /// the wrong region, if `BooleanOp::keeps` drops a face it should have
543    /// kept or leaves one facing inward, and no structural check can see that.
544    ///
545    /// Points landing *on* a boundary are skipped rather than asserted about:
546    /// membership there is genuinely ambiguous, and both the operands and the
547    /// result may legitimately disagree about a surface they share.
548    fn check_boolean_matches_point_membership(name: &str, op: BooleanOp, seed: u64) {
549        let mut s = scene(name);
550        let params = RemeshParams::<ScalInF64>::default();
551        let (solid_a, solid_b) = (s.solid_a, s.solid_b);
552
553        // Sampled against the operands *before* the boolean consumes them.
554        let mut rng = Rng::new(seed);
555        let mut samples = Vec::new();
556        while samples.len() < SAMPLE_COUNT {
557            let p = Vector3::from_array([
558                ScalInF64::from_f64(rng.next_f64() * 4.0 - 2.0),
559                ScalInF64::from_f64(rng.next_f64() * 4.0 - 2.0),
560                ScalInF64::from_f64(rng.next_f64() * 4.0 - 2.0),
561            ]);
562            let (Some(in_a), Some(in_b)) = (
563                strictly_inside(s.part.topology(), solid_a, p),
564                strictly_inside(s.part.topology(), solid_b, p),
565            ) else {
566                continue;
567            };
568            samples.push((p, in_a, in_b));
569        }
570
571        let result = boolean(&mut s.part, &namer(), solid_a, solid_b, op, params)
572            .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"));
573
574        for (p, in_a, in_b) in samples {
575            let expected = match op {
576                BooleanOp::Union => in_a || in_b,
577                BooleanOp::Intersection => in_a && in_b,
578                BooleanOp::Difference => in_a && !in_b,
579            };
580            let actual = match result {
581                Some(solid) => match strictly_inside(s.part.topology(), solid, p) {
582                    Some(inside) => inside,
583                    // On the result's own boundary — ambiguous, no claim.
584                    None => continue,
585                },
586                // An empty result contains nothing.
587                None => false,
588            };
589            assert_eq!(
590                actual,
591                expected,
592                "{name} {op:?}: point {p:?} is {} solid A and {} solid B, so the result should {} contain it",
593                if in_a { "inside" } else { "outside" },
594                if in_b { "inside" } else { "outside" },
595                if expected { "" } else { "not" }
596            );
597        }
598    }
599
600    /// How many points each membership check samples.
601    const SAMPLE_COUNT: usize = 20;
602
603    /// `Some(true)`/`Some(false)` for a point strictly inside/outside every
604    /// shell of `solid`; `None` if it lands on a boundary, where membership is
605    /// not a yes/no question.
606    fn strictly_inside(
607        model: &geop_core_topology::Model<ScalInF64>,
608        solid: geop_core_topology::SolidId,
609        p: Vector3<ScalInF64>,
610    ) -> Option<bool> {
611        let params = RemeshParams::<ScalInF64>::default();
612        let mut inside = false;
613        for shell in model.get_solid(solid).ok()?.shells.clone() {
614            match geop_core_topology::contains::shell::shell_contains(
615                model,
616                shell,
617                p,
618                params.max_nodes,
619                params.curve_curve_min_subdivision_size,
620                0x5A3D_1234,
621            ) {
622                Ok(geop_core_topology::contains::shell::PointClassification::Inside) => {
623                    inside = true
624                }
625                Ok(geop_core_topology::contains::shell::PointClassification::Outside) => {}
626                _ => return None,
627            }
628        }
629        Some(inside)
630    }
631
632    /// Scenes with genuinely overlapping operands, so every operator has
633    /// something to do and the samples land on both sides of each boundary.
634    const MEMBERSHIP_SCENES: &[&str] = &[
635        "box_cylinder_drilled_hole_through",
636        "box_cylinder_blind_hole",
637        "figure8_cylinder_through_neck",
638    ];
639
640    #[test]
641    fn union_matches_point_membership() {
642        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
643            check_boolean_matches_point_membership(name, BooleanOp::Union, 0xB001 + i as u64);
644        }
645    }
646
647    #[test]
648    fn intersection_matches_point_membership() {
649        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
650            check_boolean_matches_point_membership(
651                name,
652                BooleanOp::Intersection,
653                0xB101 + i as u64,
654            );
655        }
656    }
657
658    #[test]
659    fn difference_matches_point_membership() {
660        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
661            check_boolean_matches_point_membership(name, BooleanOp::Difference, 0xB201 + i as u64);
662        }
663    }
664
665    #[test]
666    fn union_contains_either_operand() {
667        let (model, r) = run(BooleanOp::Union);
668        assert!(contains(&model, r, IN_CUBE_ONLY), "cube-only point");
669        assert!(contains(&model, r, IN_CYLINDER_ONLY), "cylinder-only point");
670        assert!(contains(&model, r, IN_BOTH), "shared point");
671    }
672
673    #[test]
674    fn intersection_contains_only_the_overlap() {
675        let (model, r) = run(BooleanOp::Intersection);
676        assert!(
677            !contains(&model, r, IN_CUBE_ONLY),
678            "cube-only point must be out"
679        );
680        assert!(
681            !contains(&model, r, IN_CYLINDER_ONLY),
682            "cylinder-only point must be out"
683        );
684        assert!(contains(&model, r, IN_BOTH), "shared point must be in");
685    }
686
687    #[test]
688    fn difference_removes_the_second_operand() {
689        let (model, r) = run(BooleanOp::Difference);
690        assert!(
691            contains(&model, r, IN_CUBE_ONLY),
692            "cube-only point must remain"
693        );
694        assert!(
695            !contains(&model, r, IN_CYLINDER_ONLY),
696            "cylinder-only point must be out"
697        );
698        assert!(
699            !contains(&model, r, IN_BOTH),
700            "the drilled-out region must be gone"
701        );
702    }
703
704    #[test]
705    fn union_of_box_and_cylinder_is_valid() {
706        check_op("box_cylinder_drilled_hole_through", BooleanOp::Union);
707    }
708
709    #[test]
710    fn intersection_of_box_and_cylinder_is_valid() {
711        check_op("box_cylinder_drilled_hole_through", BooleanOp::Intersection);
712    }
713
714    #[test]
715    fn difference_of_box_and_cylinder_is_valid() {
716        check_op("box_cylinder_drilled_hole_through", BooleanOp::Difference);
717    }
718
719    /// A chain of 3 differences (two axis-aligned slots cut from a block,
720    /// then a sphere drilled out of the result) — captured from a browser
721    /// session's timeline, exactly like `scenes`' hand-picked
722    /// cases, built here directly from `basic_shapes`/`boolean` rather than
723    /// through `cad::Op`/wasm. Rendered to `outputs/` so the new
724    /// curvature-adaptive rasterizer's output on a real chained-boolean
725    /// result (flat cut faces plus the sphere's curved ones) can be
726    /// inspected visually, the same way `scenes`' figure8/box
727    /// cases are.
728    // ── Chained booleans on the basic shapes, as the CAD front end builds them ──
729    //
730    // Each solid is created just before the boolean that uses it: every step
731    // validates the *whole* model, and a solid not yet combined with anything
732    // legitimately overlaps the others in space — which the validation would
733    // report as edges crossing faces.
734
735    type M = geop_core_part::Part<ScalInF64>;
736
737    fn v(x: f64, y: f64, z: f64) -> Vector3<ScalInF64> {
738        Vector3::from_array([x, y, z].map(ScalInF64::from_f64))
739    }
740
741    /// `CreateCube(offset, dims)`: the axis-aligned box from `offset` to
742    /// `offset + dims`.
743    fn cube(part: &mut M, offset: [f64; 3], dims: [f64; 3]) -> geop_core_topology::SolidId {
744        let [x, y, z] = offset;
745        let [dx, dy, dz] = dims;
746        let (min, max) = (v(x, y, z), v(x + dx, y + dy, z + dz));
747        geop_ops_extrude_revolve::cube_solid(part, &fresh_id(), min, max).unwrap()
748    }
749
750    /// `CreateSphere(offset, r)`.
751    fn sphere(part: &mut M, center: [f64; 3], r: f64) -> geop_core_topology::SolidId {
752        let [x, y, z] = center;
753        let r = ScalInF64::from_f64(r);
754        geop_ops_extrude_revolve::sphere::sphere_solid(part, &fresh_id(), v(x, y, z), r).unwrap()
755    }
756
757    /// `CreateCylinder(offset, r, h, axis)`: `offset` is the bottom cap's centre.
758    fn cylinder(
759        part: &mut M,
760        base: [f64; 3],
761        r: f64,
762        h: f64,
763        axis: geop_ops_extrude_revolve::cylinder::Axis,
764    ) -> geop_core_topology::SolidId {
765        let [x, y, z] = base;
766        geop_ops_extrude_revolve::cylinder::revolved_cylinder_along_axis(
767            part,
768            &fresh_id(),
769            v(x, y, z),
770            ScalInF64::from_f64(r),
771            ScalInF64::from_f64(h),
772            axis,
773        )
774        .unwrap()
775    }
776
777    /// Run one boolean, requiring it to succeed with a non-empty solid that
778    /// passes `validate_fast` and the full `validate`.
779    fn op(
780        part: &mut M,
781        a: geop_core_topology::SolidId,
782        b: geop_core_topology::SolidId,
783        op: BooleanOp,
784    ) -> geop_core_topology::SolidId {
785        let result = boolean(part, &namer(), a, b, op, RemeshParams::default())
786            .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"))
787            .unwrap_or_else(|| panic!("{op:?} produced an empty solid"));
788        part.check_names().unwrap();
789        let model = part.topology();
790        if let Err(errors) = validate_fast(&validation(), model) {
791            panic!(
792                "{op:?}: {} validate_fast error(s): {}",
793                errors.len(),
794                errors[0]
795            );
796        }
797        // The full validation too: `validate_fast` checks each face on its
798        // own, and passes an edge running through the middle of another face,
799        // or two edges crossing away from any vertex. Not `validate_manifold`:
800        // many of these results are genuinely non-manifold — solids touching
801        // along a line leave edges shared by four faces.
802        if let Err(errors) = geop_core_topology::validation::validate(&validation(), model) {
803            panic!("{op:?}: {} validate error(s): {}", errors.len(), errors[0]);
804        }
805        // Closed, which neither check covers: every edge of the result is used
806        // by an even number of coedges — two where two faces meet, four where
807        // the solid touches itself along the edge. An odd count is a hole.
808        for face in model.solid_faces(result).unwrap() {
809            for coedge in model.iterate_face_coedges(face) {
810                // A bare-vertex boundary has no edge to share.
811                let Ok(edge) = model.get_coedge(coedge).unwrap().edge() else {
812                    continue;
813                };
814                let uses = model.coedges_of_edge(edge).len();
815                if uses % 2 != 0 {
816                    let e = model.get_edge(edge).unwrap();
817                    let at = |v| {
818                        let p = model.get_vertex(v).unwrap().point;
819                        [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
820                    };
821                    panic!(
822                        "{op:?}: edge {edge} of face {face}, from {:?} to {:?}, is used by {uses} coedge(s) — the solid is open there",
823                        at(e.start_vertex),
824                        at(e.end_vertex)
825                    );
826                }
827            }
828        }
829        result
830    }
831
832    /// Reported from the web front end: a cube with a bore through it, the
833    /// sphere inscribed in the cube added back, then the `y > 0` half cut
834    /// away. The last difference failed in `remesh_edges_x_edges` with "could
835    /// not locate vertex on coedge's pcurve" at the cube corner
836    /// `(-0.5, 0, -0.5)`, where the cutting cube's face meets an edge of the
837    /// first result.
838    #[test]
839    fn bored_cube_plus_inscribed_sphere_minus_half() {
840        use geop_ops_extrude_revolve::cylinder::Axis;
841        let mut part = M::new();
842        let block = cube(&mut part, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
843        let bore = cylinder(&mut part, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
844        let bored = op(&mut part, block, bore, BooleanOp::Difference);
845        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
846        let filled = op(&mut part, bored, ball, BooleanOp::Union);
847        let half = cube(&mut part, [-0.5, 0.0, -0.5], [1.0, 1.0, 1.0]);
848        op(&mut part, filled, half, BooleanOp::Difference);
849    }
850
851    /// Reported from the web front end: a cube with a flush inscribed bore
852    /// (tangent to the four sides, caps coplanar with top and bottom), the
853    /// inscribed sphere added back, then everything with `x < 0.05` cut away
854    /// like a section view. The result looked open — two faces missing —
855    /// with spurious triangles in the cutting plane. Not a manifold even when
856    /// right: the bore touches the cube's sides along lines, and the one at
857    /// `x = 0.5` survives the cut.
858    #[test]
859    #[ignore = "still fails: the section plane x = 0.05 cuts the sphere in a circle of radius \
860                0.4975, exactly the bore wall's y = ±0.4975, so the circle touches the wall's section \
861                lines tangentially at (0.05, ±0.4975, 0). Splitting the section face there leaves two \
862                kept faces overlapping on the thin strip between the cube side and the wall (the \
863                spurious triangles), with a wall-section edge used by 3 coedges. Fixed so far: the \
864                missing top/bottom corner faces (stale start-point face; a curve shorter than the \
865                tracer's first step never getting a direction)."]
866    fn flush_bored_cube_plus_inscribed_sphere_section() {
867        use geop_ops_extrude_revolve::cylinder::Axis;
868        let mut part = M::new();
869        let block = cube(&mut part, CORNER, UNIT);
870        let bore = cylinder(&mut part, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
871        let bored = op(&mut part, block, bore, BooleanOp::Difference);
872        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
873        let filled = op(&mut part, bored, ball, BooleanOp::Union);
874        let cutter = cube(&mut part, [-1.45, -1.05, -0.82], [1.5, 2.1, 1.65]);
875        op(&mut part, filled, cutter, BooleanOp::Difference);
876    }
877
878    /// Like [`op`], for a boolean whose correct result is empty.
879    fn op_empty(
880        part: &mut M,
881        a: geop_core_topology::SolidId,
882        b: geop_core_topology::SolidId,
883        op: BooleanOp,
884    ) {
885        let result = boolean(part, &namer(), a, b, op, RemeshParams::default())
886            .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"));
887        assert!(result.is_none(), "{op:?} should be empty");
888    }
889
890    const UNIT: [f64; 3] = [1.0, 1.0, 1.0];
891    const CORNER: [f64; 3] = [-0.5, -0.5, -0.5];
892
893    // Tangent contact. Where two surfaces touch without crossing, the
894    // intersection is a curve or point along which a tiny error in one
895    // surface moves the contact a long way (a perpendicular error `d` shifts a
896    // tangency by `~sqrt(2 R d)`), which is what broke
897    // `bored_cube_plus_inscribed_sphere_minus_half`.
898
899    /// The cylinder touches all four side faces along vertical lines.
900    #[test]
901    fn cube_minus_inscribed_cylinder() {
902        use geop_ops_extrude_revolve::cylinder::Axis;
903        let mut part = M::new();
904        let block = cube(&mut part, CORNER, UNIT);
905        let bore = cylinder(&mut part, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
906        op(&mut part, block, bore, BooleanOp::Difference);
907    }
908
909    /// The cylinder is tangent to the four sides *and* its caps are flush
910    /// with the top and bottom: each cap's rim lies in the cube's face and
911    /// touches that face's four edges at their midpoints. The first step of
912    /// `flush_bored_cube_plus_inscribed_sphere_section`.
913    #[test]
914    fn cube_minus_flush_inscribed_cylinder() {
915        use geop_ops_extrude_revolve::cylinder::Axis;
916        let mut part = M::new();
917        let block = cube(&mut part, CORNER, UNIT);
918        let bore = cylinder(&mut part, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
919        op(&mut part, block, bore, BooleanOp::Difference);
920    }
921
922    /// The sphere touches each face of the cube at its centre.
923    #[test]
924    fn cube_minus_inscribed_sphere() {
925        let mut part = M::new();
926        let block = cube(&mut part, CORNER, UNIT);
927        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
928        op(&mut part, block, ball, BooleanOp::Difference);
929    }
930
931    /// The sphere passes through all eight corners of the cube.
932    #[test]
933    fn cube_intersect_circumscribed_sphere() {
934        let mut part = M::new();
935        let block = cube(&mut part, CORNER, UNIT);
936        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 3f64.sqrt() / 2.0);
937        op(&mut part, block, ball, BooleanOp::Intersection);
938    }
939
940    /// The cylinder's surface contains the cube's four vertical edges.
941    #[test]
942    fn cube_intersect_cylinder_through_its_edges() {
943        use geop_ops_extrude_revolve::cylinder::Axis;
944        let mut part = M::new();
945        let block = cube(&mut part, CORNER, UNIT);
946        let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5f64.sqrt(), 2.0, Axis::Z);
947        op(&mut part, block, tube, BooleanOp::Intersection);
948    }
949
950    /// A coaxial cylinder of the sphere's radius touches it along the
951    /// equator, a whole circle of tangency.
952    #[test]
953    fn sphere_union_tangent_cylinder() {
954        use geop_ops_extrude_revolve::cylinder::Axis;
955        let mut part = M::new();
956        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
957        let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
958        op(&mut part, ball, tube, BooleanOp::Union);
959    }
960
961    /// A cube resting on the sphere's pole, where the sphere's patches all
962    /// collapse to one point: tangent contact at a singular point.
963    #[test]
964    fn sphere_union_cube_touching_its_pole() {
965        let mut part = M::new();
966        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
967        let block = cube(&mut part, [-0.5, -0.5, 0.5], UNIT);
968        op(&mut part, ball, block, BooleanOp::Union);
969    }
970
971    /// A cylinder along x lying on the cube's top face: tangent along a line.
972    #[test]
973    fn cube_union_cylinder_lying_on_top() {
974        use geop_ops_extrude_revolve::cylinder::Axis;
975        let mut part = M::new();
976        let block = cube(&mut part, CORNER, UNIT);
977        let log = cylinder(&mut part, [-1.0, 0.0, 0.75], 0.25, 2.0, Axis::X);
978        op(&mut part, block, log, BooleanOp::Union);
979    }
980
981    // Coincident features: shared faces, edges and corners, coplanar caps, and
982    // identical solids.
983
984    #[test]
985    fn cubes_sharing_a_face_union() {
986        let mut part = M::new();
987        let a = cube(&mut part, CORNER, UNIT);
988        let b = cube(&mut part, [0.5, -0.5, -0.5], UNIT);
989        op(&mut part, a, b, BooleanOp::Union);
990    }
991
992    #[test]
993    fn cubes_sharing_an_edge_union() {
994        let mut part = M::new();
995        let a = cube(&mut part, CORNER, UNIT);
996        let b = cube(&mut part, [0.5, 0.5, -0.5], UNIT);
997        op(&mut part, a, b, BooleanOp::Union);
998    }
999
1000    #[test]
1001    fn cubes_sharing_a_corner_union() {
1002        let mut part = M::new();
1003        let a = cube(&mut part, CORNER, UNIT);
1004        let b = cube(&mut part, [0.5, 0.5, 0.5], UNIT);
1005        op(&mut part, a, b, BooleanOp::Union);
1006    }
1007
1008    /// A half-overlapping cube: two of its faces are coplanar with the first
1009    /// cube's, over part of their area.
1010    #[test]
1011    fn cube_minus_offset_cube_with_coplanar_faces() {
1012        let mut part = M::new();
1013        let a = cube(&mut part, CORNER, UNIT);
1014        let b = cube(&mut part, [0.0, -0.5, -0.5], UNIT);
1015        op(&mut part, a, b, BooleanOp::Difference);
1016    }
1017
1018    /// A hole whose caps are flush with the cube's top and bottom.
1019    #[test]
1020    fn cube_minus_flush_cylinder() {
1021        use geop_ops_extrude_revolve::cylinder::Axis;
1022        let mut part = M::new();
1023        let block = cube(&mut part, CORNER, UNIT);
1024        let bore = cylinder(&mut part, [0.0, 0.0, -0.5], 0.3, 1.0, Axis::Z);
1025        op(&mut part, block, bore, BooleanOp::Difference);
1026    }
1027
1028    #[test]
1029    fn identical_spheres_union() {
1030        let mut part = M::new();
1031        let a = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1032        let b = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1033        op(&mut part, a, b, BooleanOp::Union);
1034    }
1035
1036    #[test]
1037    fn identical_cubes_difference_is_empty() {
1038        let mut part = M::new();
1039        let a = cube(&mut part, CORNER, UNIT);
1040        let b = cube(&mut part, CORNER, UNIT);
1041        op_empty(&mut part, a, b, BooleanOp::Difference);
1042    }
1043
1044    /// A sphere centred on the cube's corner: its three coordinate-plane
1045    /// seams lie exactly in three faces of the cube.
1046    #[test]
1047    fn cube_minus_sphere_on_its_corner() {
1048        let mut part = M::new();
1049        let block = cube(&mut part, CORNER, UNIT);
1050        let ball = sphere(&mut part, [0.5, 0.5, 0.5], 0.5);
1051        op(&mut part, block, ball, BooleanOp::Difference);
1052    }
1053
1054    // Curved against curved.
1055
1056    /// Two equal cylinders crossing at right angles: their intersection
1057    /// curves (two ellipses) cross each other at two singular points.
1058    #[test]
1059    fn steinmetz_cylinders_union() {
1060        use geop_ops_extrude_revolve::cylinder::Axis;
1061        let mut part = M::new();
1062        let a = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1063        let b = cylinder(&mut part, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1064        op(&mut part, a, b, BooleanOp::Union);
1065    }
1066
1067    #[test]
1068    fn steinmetz_cylinders_intersection() {
1069        use geop_ops_extrude_revolve::cylinder::Axis;
1070        let mut part = M::new();
1071        let a = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1072        let b = cylinder(&mut part, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1073        op(&mut part, a, b, BooleanOp::Intersection);
1074    }
1075
1076    /// A bore along the sphere's axis, through both poles.
1077    #[test]
1078    fn sphere_minus_cylinder_through_its_poles() {
1079        use geop_ops_extrude_revolve::cylinder::Axis;
1080        let mut part = M::new();
1081        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1082        let bore = cylinder(&mut part, [0.0, 0.0, -1.0], 0.2, 2.0, Axis::Z);
1083        op(&mut part, ball, bore, BooleanOp::Difference);
1084    }
1085
1086    /// A sphere centred on a cylinder's cap: the cap cuts it along its
1087    /// equator, which is also where its patches meet.
1088    #[test]
1089    fn cylinder_union_sphere_on_its_cap() {
1090        use geop_ops_extrude_revolve::cylinder::Axis;
1091        let mut part = M::new();
1092        let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1093        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.3);
1094        op(&mut part, tube, ball, BooleanOp::Union);
1095    }
1096
1097    /// The same with the sphere's radius equal to the cylinder's: the
1098    /// sphere's equator coincides with the cap's rim, *and* the sphere is
1099    /// tangent to the cylinder's side along that same circle.
1100    #[test]
1101    fn cylinder_union_equal_sphere_on_its_cap() {
1102        use geop_ops_extrude_revolve::cylinder::Axis;
1103        let mut part = M::new();
1104        let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1105        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1106        op(&mut part, tube, ball, BooleanOp::Union);
1107    }
1108
1109    /// External tangency at a point of the sphere's equator (not a pole).
1110    #[test]
1111    fn cube_union_sphere_touching_a_face() {
1112        let mut part = M::new();
1113        let block = cube(&mut part, CORNER, UNIT);
1114        let ball = sphere(&mut part, [1.0, 0.0, 0.0], 0.5);
1115        op(&mut part, block, ball, BooleanOp::Union);
1116    }
1117
1118    /// A sphere half sunk into the cube through the centre of a face.
1119    #[test]
1120    fn cube_union_sphere_on_a_face() {
1121        let mut part = M::new();
1122        let block = cube(&mut part, CORNER, UNIT);
1123        let ball = sphere(&mut part, [0.5, 0.0, 0.0], 0.3);
1124        op(&mut part, block, ball, BooleanOp::Union);
1125    }
1126
1127    /// Inscribed bores along all three axes, one after another (a "jack").
1128    /// Each bore is tangent to four faces, and each later bore crosses the
1129    /// earlier ones at Steinmetz points.
1130    #[test]
1131    #[ignore = "still fails: after the third bore one face's normal points into the solid \
1132                (face_orientation check); not yet investigated. The bores are tangent to the cube's \
1133                faces and cross each other at Steinmetz points, the same degeneracies as elsewhere."]
1134    fn cube_minus_three_inscribed_bores() {
1135        use geop_ops_extrude_revolve::cylinder::Axis;
1136        let mut part = M::new();
1137        let block = cube(&mut part, CORNER, UNIT);
1138        let z = cylinder(&mut part, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
1139        let a = op(&mut part, block, z, BooleanOp::Difference);
1140        let x = cylinder(&mut part, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
1141        let b = op(&mut part, a, x, BooleanOp::Difference);
1142        let y = cylinder(&mut part, [0.0, -0.875, 0.0], 0.5, 1.75, Axis::Y);
1143        op(&mut part, b, y, BooleanOp::Difference);
1144    }
1145
1146    /// Thinner bores, so the cube keeps its faces: two crossing bores.
1147    #[test]
1148    fn cube_minus_two_crossing_bores() {
1149        use geop_ops_extrude_revolve::cylinder::Axis;
1150        let mut part = M::new();
1151        let block = cube(&mut part, CORNER, UNIT);
1152        let z = cylinder(&mut part, [0.0, 0.0, -0.875], 0.3, 1.75, Axis::Z);
1153        let a = op(&mut part, block, z, BooleanOp::Difference);
1154        let x = cylinder(&mut part, [-0.875, 0.0, 0.0], 0.3, 1.75, Axis::X);
1155        op(&mut part, a, x, BooleanOp::Difference);
1156    }
1157
1158    // More tangencies, and a longer chain.
1159
1160    /// Two spheres touching externally at a single point.
1161    #[test]
1162    fn touching_spheres_union() {
1163        let mut part = M::new();
1164        let a = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1165        let b = sphere(&mut part, [1.0, 0.0, 0.0], 0.5);
1166        op(&mut part, a, b, BooleanOp::Union);
1167    }
1168
1169    /// A sphere inside a cylinder of the same radius, touching it along the
1170    /// equator from inside.
1171    #[test]
1172    fn cylinder_minus_inscribed_sphere() {
1173        use geop_ops_extrude_revolve::cylinder::Axis;
1174        let mut part = M::new();
1175        let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1176        let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1177        op(&mut part, tube, ball, BooleanOp::Difference);
1178    }
1179
1180    /// Two parallel cylinders touching along a line.
1181    #[test]
1182    fn touching_parallel_cylinders_union() {
1183        use geop_ops_extrude_revolve::cylinder::Axis;
1184        let mut part = M::new();
1185        let a = cylinder(&mut part, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1186        let b = cylinder(&mut part, [1.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1187        op(&mut part, a, b, BooleanOp::Union);
1188    }
1189
1190    /// A sphere touching one of the cube's edges from outside.
1191    #[test]
1192    fn cube_union_sphere_touching_an_edge() {
1193        let mut part = M::new();
1194        let block = cube(&mut part, CORNER, UNIT);
1195        let d = 0.5 + 0.5 / 2f64.sqrt();
1196        let ball = sphere(&mut part, [d, d, 0.0], 0.5);
1197        op(&mut part, block, ball, BooleanOp::Union);
1198    }
1199
1200    /// A small part as a user might build it: a plate with a boss and a
1201    /// through hole, then a rounded cap on the boss.
1202    #[test]
1203    fn plate_with_boss_hole_and_cap() {
1204        use geop_ops_extrude_revolve::cylinder::Axis;
1205        let mut part = M::new();
1206        let plate = cube(&mut part, [-1.0, -1.0, -0.25], [2.0, 2.0, 0.5]);
1207        let boss = cylinder(&mut part, [0.0, 0.0, 0.25], 0.5, 0.5, Axis::Z);
1208        let a = op(&mut part, plate, boss, BooleanOp::Union);
1209        let cap = sphere(&mut part, [0.0, 0.0, 0.75], 0.5);
1210        let b = op(&mut part, a, cap, BooleanOp::Union);
1211        let hole = cylinder(&mut part, [0.0, 0.0, -0.5], 0.25, 1.5, Axis::Z);
1212        op(&mut part, b, hole, BooleanOp::Difference);
1213    }
1214
1215    #[test]
1216    fn chained_differences_block_with_two_slots_and_a_sphere() {
1217        let f = ScalInF64::from_f64;
1218        let corner = |x: f64, y: f64, z: f64, dx: f64, dy: f64, dz: f64| {
1219            (
1220                Vector3::from_array([f(x), f(y), f(z)]),
1221                Vector3::from_array([f(x + dx), f(y + dy), f(z + dz)]),
1222            )
1223        };
1224
1225        let mut part = M::new();
1226        let (min_a, max_a) = corner(-0.50, -0.50, -0.50, 1.00, 1.00, 1.00);
1227        let a = geop_ops_extrude_revolve::cube_solid(&mut part, "a", min_a, max_a).unwrap();
1228        let (min_b, max_b) = corner(-1.13, -0.30, -0.33, 2.25, 0.60, 0.65);
1229        let b = geop_ops_extrude_revolve::cube_solid(&mut part, "b", min_b, max_b).unwrap();
1230        let params = RemeshParams::<ScalInF64>::default();
1231        let c = boolean(&mut part, &namer(), a, b, BooleanOp::Difference, params)
1232            .unwrap()
1233            .expect("block minus the first slot must be non-empty");
1234
1235        let (min_d, max_d) = corner(-0.33, -0.28, -1.15, 0.65, 0.55, 2.30);
1236        let d = geop_ops_extrude_revolve::cube_solid(&mut part, "d", min_d, max_d).unwrap();
1237        let e = boolean(&mut part, &namer(), c, d, BooleanOp::Difference, params)
1238            .unwrap()
1239            .expect("minus the second slot must be non-empty");
1240
1241        let sphere = geop_ops_extrude_revolve::sphere::sphere_solid(
1242            &mut part,
1243            "s",
1244            Vector3::zero(),
1245            f(0.45),
1246        )
1247        .unwrap();
1248        let result = boolean(
1249            &mut part,
1250            &namer(),
1251            e,
1252            sphere,
1253            BooleanOp::Difference,
1254            params,
1255        )
1256        .unwrap()
1257        .expect("minus the sphere must be non-empty");
1258        part.check_names().unwrap();
1259
1260        let model = part.topology();
1261        assert!(!model.solid_faces(result).unwrap().is_empty());
1262        if let Err(errors) = validate_fast(&validation(), model) {
1263            panic!("{} validate_fast error(s): {}", errors.len(), errors[0]);
1264        }
1265
1266        let scene = geop_ops_rasterize::rasterize_model(model, 8).unwrap();
1267        std::fs::create_dir_all("outputs").unwrap();
1268        scene
1269            .save_to_file("outputs/chained_differences_block_with_two_slots_and_a_sphere.html")
1270            .unwrap();
1271    }
1272
1273    /// Regression test, captured from a browser session: two cubes
1274    /// differenced, then a sphere differenced out of that, then a thin slab
1275    /// cube differenced out of *that*. The second cube and the sphere were
1276    /// anchored to corners of the first cube, picked by kernel id
1277    /// (`VertexId(13)` and `VertexId(9)`); building that cube the same way
1278    /// assigns the same ids, so they are read back from it here.
1279    ///
1280    /// Used to fail inside the final boolean's `classify_face`:
1281    /// `shell_contains` reported a point on the slab's boundary that no face
1282    /// of that shell's `face_contains` agreed contained. Root cause:
1283    /// `shell_contains`'s face coincidence pre-check only asked
1284    /// `surface_could_contain` — proximity to a face's *untrimmed* surface —
1285    /// without checking the point fell within the face's trim. Proximity is
1286    /// not membership (see `AGENTS.md`).
1287    #[test]
1288    fn chained_differences_with_anchored_shapes_and_thin_slab_cutter_succeeds() {
1289        let mut part = M::new();
1290        let block = cube(&mut part, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
1291        let corner = |part: &M, id: u64| {
1292            let p = part
1293                .topology()
1294                .get_vertex(geop_core_topology::VertexId(id))
1295                .unwrap()
1296                .point;
1297            [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
1298        };
1299        let [x, y, z] = corner(&part, 13);
1300        let second = cube(&mut part, [x - 0.5, y - 0.5, z - 0.5], [1.0, 1.0, 1.0]);
1301        let center = corner(&part, 9);
1302        let ball = sphere(&mut part, center, 0.5);
1303        // Only success is asserted, as when this was captured: the full
1304        // `validate` that `op` runs finds an edge of the second result
1305        // crossing a face unsplit, which this regression never covered.
1306        let difference = |part: &mut M, a, b| {
1307            boolean(
1308                part,
1309                &namer(),
1310                a,
1311                b,
1312                BooleanOp::Difference,
1313                RemeshParams::default(),
1314            )
1315            .unwrap()
1316            .expect("the result is not empty")
1317        };
1318        let cut = difference(&mut part, block, second);
1319        let cut = difference(&mut part, cut, ball);
1320        let slab = cube(&mut part, [-1.13, -0.10, -0.15], [2.25, 0.20, 0.30]);
1321        difference(&mut part, cut, slab);
1322        part.check_names().unwrap();
1323    }
1324
1325    /// Regression test: a cylinder drilled through a cube, its height
1326    /// chosen so *both* caps sit exactly flush with the cube's own
1327    /// opposite faces (`cylinder z in [-0.5, 0.5]` exactly matching the
1328    /// cube's), rather than the usual "drilled hole" test scenes (e.g.
1329    /// `box_cylinder_drilled_hole_through`) where the cylinder
1330    /// deliberately extends *past* both faces.
1331    ///
1332    /// That exact-coplanar-cap case used to (1) take ~30s — over 1000x the
1333    /// sub-second time every other boolean test in this file takes — and
1334    /// (2) produce a *wrong* result, cutting the hole through only one of
1335    /// the two coplanar-cap faces and leaving the other fully solid.
1336    ///
1337    /// Root cause: `revolve_at_oriented` builds any flat cap (a
1338    /// doubly-curved pole, like a sphere's, genuinely needs a fan of
1339    /// wedges meeting at a center vertex — a flat cap doesn't, but got the
1340    /// same treatment) as 4 wedges with radial "spoke" edges from rim to
1341    /// center. `find_coincident_pair` (`remesh_edges_x_faces.rs`) couldn't
1342    /// tell those spokes apart from the genuine circular rim boundary, so
1343    /// it tried imprinting all of them — the slowdown, and (via
1344    /// interleaved imprints across the two caps corrupting
1345    /// `Model::splice_edge_into_face`'s face-splitting bookkeeping) the
1346    /// wrong result. Fixed by `is_internal_seam_edge`/`faces_are_coplanar`
1347    /// there: an edge whose two neighboring faces (within its own solid)
1348    /// are already coplanar with each other is never the only carrier of a
1349    /// genuine coincidence, so it's skipped — cutting straight to the rim
1350    /// arcs.
1351    #[test]
1352    fn cube_minus_z_cylinder_with_coplanar_cap_is_fast_and_correct() {
1353        let f = ScalInF64::from_f64;
1354        let mut part = M::new();
1355        let a = geop_ops_extrude_revolve::cube_solid(
1356            &mut part,
1357            "a",
1358            Vector3::from_array([f(-0.5), f(-0.5), f(-0.5)]),
1359            Vector3::from_array([f(0.5), f(0.5), f(0.5)]),
1360        )
1361        .unwrap();
1362        let b = geop_ops_extrude_revolve::cylinder::revolved_cylinder_along_axis(
1363            &mut part,
1364            "b",
1365            Vector3::from_array([f(0.0), f(0.0), f(-0.5)]),
1366            f(0.3),
1367            f(1.0),
1368            geop_ops_extrude_revolve::cylinder::Axis::Z,
1369        )
1370        .unwrap();
1371        let params = RemeshParams::<ScalInF64>::default();
1372
1373        let t0 = std::time::Instant::now();
1374        let result = boolean(&mut part, &namer(), a, b, BooleanOp::Difference, params)
1375            .unwrap()
1376            .unwrap();
1377        let elapsed = t0.elapsed();
1378        let model = part.topology();
1379        // Ballpark-matches `difference_of_box_and_cylinder_is_valid`'s own
1380        // (non-coincident-cap) ~2s in isolation; running inside the full
1381        // suite under CPU contention from other parallel tests has been
1382        // observed up to ~12s. The threshold stays generous — the point is
1383        // catching a regression back toward the old ~32s (a *further*
1384        // 1000x-ish blowup on top of ordinary parallel-run noise), not
1385        // pinning down exact timing.
1386        assert!(
1387            elapsed.as_secs() < 20,
1388            "boolean took {elapsed:?}, expected well under 20s"
1389        );
1390
1391        // Every point on both cap planes' hole boundary must be *outside*
1392        // the result (the cylinder's full bore is open at both ends) —
1393        // catches "hole only cut on one end" directly, unlike
1394        // `validate_fast`.
1395        let shell = model.get_solid(result).unwrap().shells[0];
1396        for z in [f(-0.45), f(0.45)] {
1397            let p = Vector3::from_array([f(0.0), f(0.0), z]);
1398            let outside = matches!(
1399                geop_core_topology::contains::shell::shell_contains(
1400                    model,
1401                    shell,
1402                    p,
1403                    params.max_nodes,
1404                    params.curve_curve_min_subdivision_size,
1405                    0xC7D1
1406                )
1407                .unwrap(),
1408                geop_core_topology::contains::shell::PointClassification::Outside
1409            );
1410            assert!(
1411                outside,
1412                "point {p:?} (near a cap's bore) should be outside the drilled result"
1413            );
1414        }
1415
1416        let scene = geop_ops_rasterize::rasterize_model(model, 8).unwrap();
1417        std::fs::create_dir_all("outputs").unwrap();
1418        scene
1419            .save_to_file("outputs/cube_minus_coplanar_cap_cylinder.html")
1420            .unwrap();
1421    }
1422
1423    /// A bore through a block must actually be empty in the rendered mesh:
1424    /// no triangle may cover the hole. End-to-end cover for the trimming
1425    /// that `geop_ops_rasterize::clip` does per grid cell — where a
1426    /// duplicated vertex in a clipped hole outline, or a concave fragment
1427    /// classified as a whole, used to leave a flap of surface hanging
1428    /// across an opening (isolated in that module's own tests).
1429    #[test]
1430    fn bore_renders_as_a_hole() {
1431        let mut part = M::new();
1432        let block = cube(&mut part, [-1.0, -1.0, 0.0], [2.0, 2.0, 0.5]);
1433        // Radius 0.5 of the 2x2 footprint puts the bore's outline exactly
1434        // through grid cell corners at this resolution, which is what it
1435        // takes to produce the duplicated vertex.
1436        let bore = cylinder(
1437            &mut part,
1438            [0.0, 0.0, -0.5],
1439            0.5,
1440            1.5,
1441            geop_ops_extrude_revolve::cylinder::Axis::Z,
1442        );
1443        op(&mut part, block, bore, BooleanOp::Difference);
1444
1445        let scene = geop_ops_rasterize::rasterize_model(part.topology(), 24).unwrap();
1446        for (triangle, _) in &scene.triangles {
1447            for p in [triangle.a, triangle.b, triangle.c] {
1448                let (x, y, z) = (p[0].to_f64(), p[1].to_f64(), p[2].to_f64());
1449                let r = x.hypot(y);
1450                // Strictly inside the bore, and within the block's height:
1451                // nothing may be drawn there. The margin keeps the bore
1452                // wall's own triangles (at r = 0.6, faceted slightly inwards)
1453                // out of the test.
1454                assert!(
1455                    r > 0.45 || !(0.01..0.49).contains(&z),
1456                    "a triangle corner sits inside the bore at ({x}, {y}, {z})"
1457                );
1458            }
1459        }
1460    }
1461}