Skip to main content

geop_lib_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 `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//!
18//! [`boolean_up_to_next`] is the same with one more step between 3 and 4:
19//! of the second solid, a tool, it keeps only the first piece its start
20//! reaches on the side of the first solid's boundary the operator wants.
21
22use std::collections::{HashMap, HashSet};
23
24use geop_core_geometry::{intersection::curve_surface_intersect, nurb_surface::NurbSurface3D};
25use geop_core_math::{
26    geop_error::{GeopError, GeopResult, WithContext},
27    scalars::Scalar,
28    union_find::UnionFind,
29    vector::Vector3,
30    with_context,
31};
32use geop_core_topology::{
33    EdgeId, FaceId, Model, SolidId, VertexId,
34    contains::{
35        face::face_interior_point_where,
36        shell::{Classified, PointClassification as ShellPoint, solid_contains, solid_face_at},
37    },
38};
39use geop_ops_model::{BooleanOp, NamedModel, Namer};
40
41use crate::naming::{name_lone_pieces, origins_by_name};
42use crate::remesh::{Origins, RemeshParams, remesh};
43
44/// Where one solid's face sits relative to the *other* solid.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub(crate) enum FaceClassification {
47    /// Strictly inside the other solid.
48    Inside,
49    /// Strictly outside it.
50    Outside,
51    /// On its boundary, with the two surfaces' normals pointing the same way
52    /// — the two solids touch and lie on the same side of the shared patch.
53    OnSameNormal,
54    /// On its boundary, with the normals opposed — the solids meet along the
55    /// patch from opposite sides.
56    OnOppositeNormal,
57}
58
59/// What to do with a classified face.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61enum Keep {
62    /// Keep it as it is.
63    AsIs,
64    /// Keep it with its normal flipped — the operator wants the material on
65    /// the other side of this patch.
66    Reversed,
67    /// Drop it.
68    Drop,
69}
70
71/// Whether to keep a face classified as `class`, and with which
72/// orientation. `from_a` says which solid the face came from, because the
73/// two are not symmetric for [`BooleanOp::Difference`], and because a
74/// coincident patch must be kept exactly once rather than from both.
75///
76/// The coincident rules are the only subtle ones. A patch shared by both
77/// solids with **matching** normals bounds the same material on the same
78/// side, so union and intersection each keep exactly one copy (`a`'s, by
79/// convention) and difference deletes it — the material behind it is
80/// removed from both sides at once. A patch shared with **opposing**
81/// normals is the reverse: it separates the two solids, so union and
82/// intersection drop it (it is interior to the result) while difference
83/// keeps `a`'s copy, since that is exactly the surface where `a` is left
84/// open by removing `b`.
85fn keeps(op: BooleanOp, class: FaceClassification, from_a: bool) -> Keep {
86    use FaceClassification::*;
87    match (op, class) {
88        // Union: the result is bounded by whatever is outside the other.
89        (BooleanOp::Union, Outside) => Keep::AsIs,
90        (BooleanOp::Union, OnSameNormal) if from_a => Keep::AsIs,
91        (BooleanOp::Union, Inside | OnSameNormal | OnOppositeNormal) => Keep::Drop,
92
93        // Intersection: bounded by whatever is inside the other.
94        (BooleanOp::Intersection, Inside) => Keep::AsIs,
95        (BooleanOp::Intersection, OnSameNormal) if from_a => Keep::AsIs,
96        (BooleanOp::Intersection, Outside | OnSameNormal | OnOppositeNormal) => Keep::Drop,
97
98        // Difference (a - b) = a intersected with the complement of b, so
99        // `a`'s faces behave as for intersection-with-the-outside, and
100        // `b`'s surviving faces are the ones inside `a`, turned around to
101        // face into the cavity they now bound.
102        (BooleanOp::Difference, Outside | OnOppositeNormal) if from_a => Keep::AsIs,
103        (BooleanOp::Difference, Inside) if !from_a => Keep::Reversed,
104        (BooleanOp::Difference, Inside | Outside | OnSameNormal | OnOppositeNormal) => Keep::Drop,
105
106        // Symmetric difference: (a - b) and (b - a), each bounded by its
107        // own faces outside the other and the other's faces inside it,
108        // turned around. Where the two lie along each other, the material
109        // is on one side of both (in both, so in neither piece) or on
110        // either side (in one each, so inside the result): no boundary.
111        (BooleanOp::SymmetricDifference, Outside) => Keep::AsIs,
112        (BooleanOp::SymmetricDifference, Inside) => Keep::Reversed,
113        (BooleanOp::SymmetricDifference, OnSameNormal | OnOppositeNormal) => Keep::Drop,
114    }
115}
116
117/// Fixed seed for the ray casting behind every containment query here. Both
118/// `face_contains` and `shell_contains` retry until they find a ray grazing
119/// nothing, so their answers are seed-independent; a constant keeps a boolean
120/// reproducible run to run.
121const SEED: u64 = 0xB001_EA47_0000_0001;
122
123/// `solid_a` combined with `solid_b` under `op`, as a new solid in `model`.
124///
125/// `Ok(None)` means the result is **empty**, which is an answer rather than a
126/// failure: intersecting two solids that do not overlap, or subtracting a
127/// solid that wholly contains the first, legitimately leaves nothing. Callers
128/// that treat "no solid" as an error would reject the majority of scenes in
129/// this crate's own test set.
130///
131/// Both input solids are consumed either way: their faces are transferred to
132/// the result or deleted, so neither id is valid afterwards.
133///
134/// The result is named `namer`'s root name, and everything the boolean
135/// creates on the way is named as `naming` describes; every face,
136/// edge and vertex it keeps keeps its name.
137pub fn boolean(
138    model: &mut NamedModel,
139    namer: &Namer,
140    solid_a: SolidId,
141    solid_b: SolidId,
142    op: BooleanOp,
143    params: RemeshParams,
144) -> GeopResult<Option<SolidId>> {
145    combine(model, namer, solid_a, solid_b, op, None, params)?.stopped()
146}
147
148/// A tool going up to the next face — the second operand of a boolean — by
149/// the names its start and end faces had before it (see `naming`),
150/// and whether only the piece of it taken is kept, `alone`, as a solid of
151/// its own, rather than combined with the first operand.
152#[derive(Clone, Copy, Debug)]
153struct ToolEnds<'a> {
154    start: &'a str,
155    end: &'a str,
156    alone: bool,
157}
158
159/// What combining a tool up to the next face came to: stopped all round,
160/// and what that left — or not, the target meeting part of the profile, or
161/// none, and the rest going on past it to the tool's end. A caller may then
162/// go another way (see `combine_sides` of the extrude and revolve); the
163/// error says where the tool ran on, for one that does not.
164#[derive(Debug)]
165pub enum Reach<T> {
166    Stopped(T),
167    RunsOn(GeopError),
168}
169
170impl<T> Reach<T> {
171    /// What it left, if stopped all round, else why not as an error.
172    pub fn stopped(self) -> GeopResult<T> {
173        match self {
174            Reach::Stopped(t) => Ok(t),
175            Reach::RunsOn(e) => Err(e),
176        }
177    }
178}
179
180/// Like [`boolean`] — `target` combined with `tool` — but "up to next":
181/// only the first piece of `tool` its face `start` reaches is combined.
182///
183/// For a [`BooleanOp::Union`] that is the first piece of the tool outside
184/// the target — growing from `start` until it runs into the target; for a
185/// [`BooleanOp::Difference`] or [`BooleanOp::Intersection`], the first
186/// piece inside it — cutting, or keeping, from where the tool enters the
187/// target until it comes out of it again. The tool has to be long enough to
188/// reach past whatever stops it: if the piece also reaches its face `end`,
189/// nothing stopped it, and it [`Reach::RunsOn`]. `start` and `end` are the
190/// names those faces had before the boolean (see `naming`).
191pub fn boolean_up_to_next(
192    model: &mut NamedModel,
193    namer: &Namer,
194    target: SolidId,
195    tool: SolidId,
196    op: BooleanOp,
197    (start, end): (&str, &str),
198    params: RemeshParams,
199) -> GeopResult<Reach<Option<SolidId>>> {
200    let up_to_next = ToolEnds {
201        start,
202        end,
203        alone: false,
204    };
205    combine(model, namer, target, tool, op, Some(up_to_next), params)
206}
207
208/// The first piece of `tool` its face `start` reaches outside `stops` — up
209/// to the next face of any of them — as a solid of its own, named
210/// `namer`'s root, `stops` left as they are: what a new body going up to the
211/// next face is. [`Reach::RunsOn`], as [`boolean_up_to_next`] does, if
212/// nothing stops the tool before its face `end`.
213///
214/// The tool is cut by a copy of the solids it stops at, so that only the
215/// copy is imprinted and consumed.
216pub fn piece_up_to_next(
217    model: &mut NamedModel,
218    namer: &Namer,
219    tool: SolidId,
220    stops: &[SolidId],
221    (start, end): (&str, &str),
222    params: RemeshParams,
223) -> GeopResult<Reach<SolidId>> {
224    let ctx = with_context!(
225        "piece_up_to_next(name={}, tool={tool}, stops={stops:?})",
226        namer.root()
227    );
228    let copy = copy_of(model, namer, stops).with_context(&ctx)?;
229    // As a join of the tool to the copy, picking only the piece — the copy
230    // first, as in every other combination with a tool.
231    let up_to_next = ToolEnds {
232        start,
233        end,
234        alone: true,
235    };
236    let reach = combine(
237        model,
238        namer,
239        copy,
240        tool,
241        BooleanOp::Union,
242        Some(up_to_next),
243        params,
244    )
245    .with_context(&ctx)?;
246    Ok(match reach {
247        Reach::Stopped(piece) => Reach::Stopped(
248            piece.ok_or_else(|| ctx(GeopError::new("up to next: nothing of the tool is left")))?,
249        ),
250        Reach::RunsOn(e) => Reach::RunsOn(ctx(e)),
251    })
252}
253
254/// A copy of the solids `solids` as one solid that shares nothing with
255/// them, named by `namer` as a copy: what an operation that leaves them as
256/// they are imprints and consumes instead.
257pub(crate) fn copy_of(
258    model: &mut NamedModel,
259    namer: &Namer,
260    solids: &[SolidId],
261) -> GeopResult<SolidId> {
262    let mut faces = Vec::new();
263    for &solid in solids {
264        faces.extend(model.topology().solid_faces(solid)?);
265    }
266    let copy = model.copy_faces(&faces, Some(namer.name(&["copy"])), |name| {
267        namer.name(&["copy", name])
268    })?;
269    Ok(copy.solid.expect("built as a solid"))
270}
271
272/// [`boolean`], or with `up_to_next` [`boolean_up_to_next`] — or, with the
273/// tool kept alone, [`piece_up_to_next`].
274fn combine(
275    model: &mut NamedModel,
276    namer: &Namer,
277    solid_a: SolidId,
278    solid_b: SolidId,
279    op: BooleanOp,
280    up_to_next: Option<ToolEnds>,
281    params: RemeshParams,
282) -> GeopResult<Reach<Option<SolidId>>> {
283    let ctx = with_context!(
284        "boolean(name={}, solid_a={solid_a}, solid_b={solid_b}, op={op:?}, up_to_next={up_to_next:?})",
285        namer.root()
286    );
287    let (parted, between_two) = given_topology(model, &[solid_a, solid_b]).with_context(&ctx)?;
288
289    // A remesh error names entities by id, and ids say nothing of what the
290    // entities are: name them, as they are where it failed.
291    let origins = remesh(model, namer, solid_a, solid_b, params)
292        .map_err(|e| {
293            let names = model.names_mentioned(&e.to_string());
294            e.with_context(format!("where it names {}", names.join(", ")))
295        })
296        .with_context(&ctx)?;
297    let topology = model.topology();
298
299    let faces_a = topology.solid_faces(solid_a).with_context(&ctx)?;
300    let faces_b = topology.solid_faces(solid_b).with_context(&ctx)?;
301
302    let mut decisions: HashMap<FaceId, (FaceClassification, Keep)> = HashMap::new();
303    let mut keep: Vec<FaceId> = Vec::new();
304    let mut reverse: Vec<FaceId> = Vec::new();
305    let mut classified = Classified::default();
306    // Every face that could meet the other solid's boundary is classified
307    // at its own interior points, each checked against the others: a face
308    // a missed intersection curve leaves straddling is named. A face whose
309    // trimmed patch is apart from every one of the other solid's cannot
310    // straddle it, and lies on the side of any face it shares an edge with
311    // that is inside or outside, all of the edge being apart from the
312    // other solid's boundary too. Those take their side from such a
313    // neighbour, and only a set of them no classified face borders is
314    // classified, at one face: what a cut leaves untouched — all but a few
315    // faces of a gear once it has many teeth — costs no ray.
316    let (patches_a, patches_b) = (
317        trimmed_patches(topology, &faces_a).with_context(&ctx)?,
318        trimmed_patches(topology, &faces_b).with_context(&ctx)?,
319    );
320    for (patches, from_a, other, others) in [
321        (&patches_a, true, solid_b, &patches_b),
322        (&patches_b, false, solid_a, &patches_a),
323    ] {
324        let classify = |face_id: FaceId, classified: &mut Classified| {
325            classify_face(topology, face_id, other, params, classified)
326                .map_err(|e| {
327                    let names = model.names_mentioned(&e.to_string());
328                    e.with_context(format!("where it names {}", names.join(", ")))
329                })
330                .with_context(&ctx)
331                .with_context(with_context!(
332                    "classifying face {face_id} ({:?}, of {})",
333                    model.name_of(face_id),
334                    if from_a {
335                        "the first solid"
336                    } else {
337                        "the second solid"
338                    }
339                ))
340        };
341        let mut apart = std::collections::BTreeSet::new();
342        let mut sided = std::collections::VecDeque::new();
343        for (face_id, patch) in patches {
344            if others.iter().any(|(_, o)| patch.could_overlap(o)) {
345                let class = classify(*face_id, &mut classified)?;
346                decisions.insert(*face_id, (class, keeps(op, class, from_a)));
347                sided.push_back((*face_id, class));
348            } else {
349                apart.insert(*face_id);
350            }
351        }
352        loop {
353            while let Some((face_id, class)) = sided.pop_front() {
354                if !matches!(
355                    class,
356                    FaceClassification::Inside | FaceClassification::Outside
357                ) {
358                    continue;
359                }
360                for edge in topology.face_edges(face_id) {
361                    for coedge in topology.coedges_of_edge(edge) {
362                        let next = topology.get_coedge(coedge).with_context(&ctx)?.face;
363                        if apart.remove(&next) {
364                            decisions.insert(next, (class, keeps(op, class, from_a)));
365                            sided.push_back((next, class));
366                        }
367                    }
368                }
369            }
370            let Some(face_id) = apart.pop_first() else {
371                break;
372            };
373            let class = classify(face_id, &mut classified)?;
374            decisions.insert(face_id, (class, keeps(op, class, from_a)));
375            sided.push_back((face_id, class));
376        }
377    }
378    if let Some(ToolEnds { start, end, alone }) = up_to_next {
379        let tool: HashSet<FaceId> = faces_b.iter().copied().collect();
380        // The piece of the kind the operator takes from the target, and
381        // what of the target the tool leaves untouched — but for an
382        // intersection, which keeps only what both hold, or a piece kept
383        // alone.
384        let (wanted_inside, keep_untouched) = match op {
385            BooleanOp::Union => (false, !alone),
386            BooleanOp::Difference => (true, true),
387            BooleanOp::Intersection => (true, false),
388            BooleanOp::SymmetricDifference => {
389                return Err(ctx(GeopError::new(
390                    "up to next: a symmetric difference keeps every piece of the tool, so it reaches no next face; use a blind extent",
391                )));
392            }
393        };
394        let reached = match reach_up_to_next(
395            topology,
396            &tool,
397            (wanted_inside, keep_untouched),
398            &origins.faces,
399            (start, end),
400            &mut decisions,
401        )
402        .with_context(&ctx)?
403        {
404            Reach::Stopped(reached) => reached,
405            Reach::RunsOn(e) => return Ok(Reach::RunsOn(ctx(e))),
406        };
407        if alone {
408            keep_alone(&reached, &mut decisions);
409        }
410    }
411    // In the order the faces were classified, so the result's shell lists
412    // its faces the same way every run.
413    for &face_id in faces_a.iter().chain(&faces_b) {
414        match decisions[&face_id].1 {
415            Keep::AsIs => keep.push(face_id),
416            Keep::Reversed => {
417                keep.push(face_id);
418                reverse.push(face_id);
419            }
420            Keep::Drop => {}
421        }
422    }
423    check_closed(model, &decisions).with_context(&ctx)?;
424
425    for &face_id in &reverse {
426        model.reverse_face(face_id).with_context(&ctx)?;
427    }
428
429    let Some(solid) = model
430        .assemble_solid(&[solid_a.into(), solid_b.into()], &keep, namer.root())
431        .with_context(&ctx)?
432    else {
433        return Ok(Reach::Stopped(None));
434    };
435    // What the boolean left in two is made one where it lies on one surface:
436    // pieces of one face side by side, split along an edge of the other
437    // solid that is no edge of the result, and a face of each solid running
438    // on into the other's. What the operands already had stays as they were
439    // given: an edge that parted the same two faces there, a cylinder's
440    // quarters or a face a step split on purpose, and a vertex that was
441    // already only between two edges.
442    let by_name = origins_by_name(model, &origins, solid).with_context(&ctx)?;
443    let stays = edges_parting_as_given(model, solid, &origins, &parted).with_context(&ctx)?;
444    let merged = model
445        .merge_faces_on_one_surface(
446            solid.into(),
447            |e| !stays.contains(&e),
448            |v| !between_two.contains(&v),
449        )
450        .with_context(&ctx)?;
451    match merged {
452        geop_core_topology::Body::Solid(solid) => {
453            name_lone_pieces(model, &by_name).with_context(&ctx)?;
454            Ok(Reach::Stopped(Some(solid)))
455        }
456        geop_core_topology::Body::Sheet(_) => Err(ctx(GeopError::new(
457            "merging the faces of the result on one surface left no solid",
458        ))),
459    }
460}
461
462/// See [`given_topology`].
463type GivenTopology = (HashMap<String, [String; 2]>, HashSet<VertexId>);
464
465/// What of `solids` a boolean leaves as it was given: per edge, by name, the
466/// names of the two faces it parts, sorted; and the vertices only two edges
467/// meet at.
468fn given_topology(model: &NamedModel, solids: &[SolidId]) -> GeopResult<GivenTopology> {
469    let topology = model.topology();
470    let mut parted: HashMap<String, Vec<String>> = HashMap::new();
471    let mut edges_at: HashMap<VertexId, HashSet<EdgeId>> = HashMap::new();
472    for &solid in solids {
473        for face in topology.solid_faces(solid)? {
474            let name = model.name(face)?;
475            for edge in topology.face_edges(face) {
476                parted
477                    .entry(model.name(edge)?.to_string())
478                    .or_default()
479                    .push(name.to_string());
480                let e = topology.get_edge(edge)?;
481                for v in [e.start_vertex, e.end_vertex] {
482                    edges_at.entry(v).or_default().insert(edge);
483                }
484            }
485        }
486    }
487    let parted = (parted.into_iter())
488        .filter_map(|(edge, mut faces)| {
489            faces.sort();
490            let [f, g] = <[String; 2]>::try_from(faces).ok()?;
491            Some((edge, [f, g]))
492        })
493        .collect();
494    let between_two = (edges_at.into_iter())
495        .filter(|(_, edges)| edges.len() == 2)
496        .map(|(v, _)| v)
497        .collect();
498    Ok((parted, between_two))
499}
500
501/// The edges of `solid`, a boolean's result, that part pieces of the two
502/// faces the edge they are a piece of parted in the operands (`parted`, see
503/// [`given_topology`]): an edge the operands had between those faces, which
504/// the boolean did not make. An edge of one operand that now parts a face
505/// of each is the boolean's seam, whatever curve it runs along.
506fn edges_parting_as_given(
507    model: &NamedModel,
508    solid: SolidId,
509    origins: &Origins,
510    parted: &HashMap<String, [String; 2]>,
511) -> GeopResult<HashSet<EdgeId>> {
512    let topology = model.topology();
513    let mut faces_of: HashMap<EdgeId, Vec<&String>> = HashMap::new();
514    for face in topology.solid_faces(solid)? {
515        if let Some(origin) = origins.faces.get(&face) {
516            for edge in topology.face_edges(face) {
517                faces_of.entry(edge).or_default().push(origin);
518            }
519        }
520    }
521    Ok((faces_of.into_iter())
522        .filter_map(|(edge, mut faces)| {
523            let given = parted.get(origins.edges.get(&edge)?)?;
524            faces.sort();
525            (faces.len() == 2 && *faces[0] == given[0] && *faces[1] == given[1]).then_some(edge)
526        })
527        .collect())
528}
529
530/// Narrows `decisions` down to the piece of the tool (the faces `tool`) that
531/// its start reaches first, see [`boolean_up_to_next`].
532///
533/// The target's boundary cuts the tool's material into pieces, alternately
534/// outside and inside the target. Each piece is bounded by tool faces —
535/// all on one side of the target, the side its classification says — and
536/// by target faces inside the tool, each of which bounds two pieces: the
537/// inside one on its material's side, the outside one on the other. (Remesh
538/// split every face along every crossing, so no face straddles two pieces.)
539/// Faces sharing an edge bound the same piece if their material lies on the
540/// same side of the target, which makes the pieces connected sets of face
541/// *sides*, and two pieces neighbours when a target face lies between them.
542///
543/// From the piece bounded by the start face, the piece wanted is the first
544/// of the kind wanted — outside the target, growing until it runs into the
545/// target, or inside it, cutting or keeping from where it enters the target
546/// until it leaves it. That is the start's own piece, if it is of that
547/// kind, or else the pieces next to it. The tool's other pieces are
548/// dropped, and the target's faces between them kept if `keep_untouched`,
549/// as if the tool had not been there, or else dropped too.
550fn reach_up_to_next(
551    model: &Model,
552    tool: &HashSet<FaceId>,
553    (wanted_inside, keep_untouched): (bool, bool),
554    origins: &HashMap<FaceId, String>,
555    (start, end): (&str, &str),
556    decisions: &mut HashMap<FaceId, (FaceClassification, Keep)>,
557) -> GeopResult<Reach<Reached>> {
558    use FaceClassification::*;
559    // Which side of the target the material behind a tool face lies on.
560    let inside = |class: FaceClassification| matches!(class, Inside | OnSameNormal);
561    let mut tool_faces: Vec<FaceId> = tool.iter().copied().collect();
562    tool_faces.sort_by_key(|f| f.0);
563    let mut between: Vec<FaceId> = decisions
564        .iter()
565        .filter(|(face, (class, _))| !tool.contains(face) && *class == Inside)
566        .map(|(&face, _)| face)
567        .collect();
568    between.sort_by_key(|f| f.0);
569    // Nodes: every tool face, then each target face between pieces by its
570    // inner side, then by its outer side.
571    let (n, m) = (tool_faces.len(), between.len());
572    let (inner, outer) = (|j: usize| n + j, |j: usize| n + m + j);
573    let tool_index: HashMap<FaceId, usize> = tool_faces
574        .iter()
575        .enumerate()
576        .map(|(i, &f)| (f, i))
577        .collect();
578    let between_index: HashMap<FaceId, usize> =
579        between.iter().enumerate().map(|(j, &f)| (f, j)).collect();
580    let class = |face: &FaceId| decisions[face].0;
581
582    let mut users: HashMap<EdgeId, Vec<FaceId>> = HashMap::new();
583    for &face in tool_faces.iter().chain(&between) {
584        for edge in model.face_edges(face) {
585            users.entry(edge).or_default().push(face);
586        }
587    }
588    let mut pieces = UnionFind::new(n + 2 * m);
589    for faces in users.values() {
590        let (mut in_node, mut out_node) = (None, None);
591        let join = |slot: &mut Option<usize>, node: usize, pieces: &mut UnionFind| match *slot {
592            Some(other) => pieces.union(other, node),
593            None => *slot = Some(node),
594        };
595        for face in faces {
596            if let Some(&i) = tool_index.get(face) {
597                let slot = if inside(class(face)) {
598                    &mut in_node
599                } else {
600                    &mut out_node
601                };
602                join(slot, i, &mut pieces);
603            } else {
604                let j = between_index[face];
605                join(&mut in_node, inner(j), &mut pieces);
606                join(&mut out_node, outer(j), &mut pieces);
607            }
608        }
609    }
610    // A tool face's piece is on its material's side; a target face's inner
611    // side is in an inside piece, its outer side in an outside one.
612    let piece_inside = |node: usize| match node {
613        i if i < n => inside(class(&tool_faces[i])),
614        i => i < n + m,
615    };
616    let mut kind: HashMap<usize, bool> = HashMap::new();
617    for node in 0..n + 2 * m {
618        kind.insert(pieces.find(node), piece_inside(node));
619    }
620    let mut neighbours: HashMap<usize, HashSet<usize>> = HashMap::new();
621    for j in 0..m {
622        let (a, b) = (pieces.find(inner(j)), pieces.find(outer(j)));
623        neighbours.entry(a).or_default().insert(b);
624        neighbours.entry(b).or_default().insert(a);
625    }
626
627    let origin_is = |face: &FaceId, name: &str| origins.get(face).is_some_and(|o| o == name);
628    // The start may lie partly on either side of the target — a rib's
629    // profile running on into the walls it stands between. Its pieces of
630    // the kind wanted are the first ones it reaches; only where it has none
631    // are the pieces next to it the first. Taking the pieces next to its
632    // other fragments too would take a second piece, reached through the
633    // target: the rest of the tool on the walls' far side.
634    let start_pieces: HashSet<usize> = tool_faces
635        .iter()
636        .enumerate()
637        .filter(|(_, face)| origin_is(face, start))
638        .map(|(i, _)| pieces.find(i))
639        .collect();
640    let mut reached: HashSet<usize> = start_pieces
641        .iter()
642        .copied()
643        .filter(|p| kind[p] == wanted_inside)
644        .collect();
645    if reached.is_empty() {
646        for piece in &start_pieces {
647            let next = neighbours.get(piece).into_iter().flatten();
648            reached.extend(next.filter(|p| kind[p] == wanted_inside));
649        }
650    }
651    if reached.is_empty() {
652        let starts: Vec<String> = tool_faces
653            .iter()
654            .enumerate()
655            .filter(|(_, face)| origin_is(face, start))
656            .map(|(i, face)| {
657                let piece = pieces.find(i);
658                format!(
659                    "{face} ({:?}, in an {} piece with {} neighbour(s))",
660                    class(face),
661                    if kind[&piece] { "inside" } else { "outside" },
662                    neighbours.get(&piece).map_or(0, HashSet::len)
663                )
664            })
665            .collect();
666        let mut tool_origins: Vec<&str> = tool_faces
667            .iter()
668            .filter_map(|f| origins.get(f).map(String::as_str))
669            .collect();
670        tool_origins.sort_unstable();
671        tool_origins.dedup();
672        return Err(GeopError::new(format!(
673            "up to next: from its start {start:?}, the profile meets nothing of the target to {} — does it go the right way? (the start's faces: [{}]; {} piece(s), {m} target face(s) between them; the tool's faces come from {tool_origins:?})",
674            if wanted_inside {
675                "cut into"
676            } else {
677                "grow up to"
678            },
679            starts.join(", "),
680            kind.len(),
681        )));
682    }
683    for (i, face) in tool_faces.iter().enumerate() {
684        let in_reach = reached.contains(&pieces.find(i));
685        if in_reach && origin_is(face, end) {
686            return Ok(Reach::RunsOn(GeopError::new(format!(
687                "up to next: nothing stops the profile: it reaches the end {end:?} without meeting a face of the target all around"
688            ))));
689        }
690        if !in_reach {
691            decisions.get_mut(face).expect("classified").1 = Keep::Drop;
692        }
693    }
694    let mut bounding = Reached {
695        tool: HashSet::new(),
696        target: HashSet::new(),
697    };
698    for (i, face) in tool_faces.iter().enumerate() {
699        if reached.contains(&pieces.find(i)) {
700            bounding.tool.insert(*face);
701        }
702    }
703    for (j, face) in between.iter().enumerate() {
704        let facing = if wanted_inside { inner(j) } else { outer(j) };
705        if reached.contains(&pieces.find(facing)) {
706            bounding.target.insert(*face);
707        } else {
708            // Untouched by the piece taking part: the target's boundary
709            // there stays — unless only what the tool reaches is kept.
710            decisions.get_mut(face).expect("classified").1 = if keep_untouched {
711                Keep::AsIs
712            } else {
713                Keep::Drop
714            };
715        }
716    }
717    Ok(Reach::Stopped(bounding))
718}
719
720/// What [`reach_up_to_next`] found the tool's taken piece bounded by: the
721/// tool's faces, and the target's faces between it and the rest.
722struct Reached {
723    tool: HashSet<FaceId>,
724    target: HashSet<FaceId>,
725}
726
727/// Turns the decisions of a union of a target with the piece of a tool it
728/// `reached` into those of the piece alone: its own faces as they are —
729/// where it lies on the target too, its start drawn on a face of the target,
730/// included — and the target's faces bounding it turned around to face out
731/// of it. Nothing else of either.
732fn keep_alone(reached: &Reached, decisions: &mut HashMap<FaceId, (FaceClassification, Keep)>) {
733    use FaceClassification::*;
734    for (face, (class, keep)) in decisions.iter_mut() {
735        *keep = if reached.tool.contains(face) && matches!(class, Outside | OnOppositeNormal) {
736            Keep::AsIs
737        } else if reached.target.contains(face) {
738            Keep::Reversed
739        } else {
740            Keep::Drop
741        };
742    }
743}
744
745/// Fails unless the kept faces close up: every edge they use must be used an
746/// even number of times by them — twice where two faces meet, four times
747/// where the result touches itself along the edge, which a solid may (see
748/// `validation::manifold_check`). An odd count is a hole in
749/// the result, so a face was kept or dropped wrongly, or remesh left a face
750/// straddling the other solid; the error lists every face along that edge
751/// with its name and classification, which tells the two apart.
752fn check_closed(
753    model: &NamedModel,
754    decisions: &HashMap<FaceId, (FaceClassification, Keep)>,
755) -> GeopResult<()> {
756    let topology = model.topology();
757    let mut uses: HashMap<EdgeId, usize> = HashMap::new();
758    for (&face_id, &(_, decision)) in decisions {
759        if decision == Keep::Drop {
760            continue;
761        }
762        for edge in topology.face_edges(face_id) {
763            *uses.entry(edge).or_default() += 1;
764        }
765    }
766    let Some((&edge, &count)) = uses
767        .iter()
768        .filter(|(_, n)| *n % 2 != 0)
769        .min_by_key(|(e, _)| e.0)
770    else {
771        return Ok(());
772    };
773    let e = topology.get_edge(edge)?;
774    let faces: Vec<String> = topology
775        .coedges_of_edge(edge)
776        .into_iter()
777        .map(|coedge| {
778            let face = topology.get_coedge(coedge)?.face;
779            let name = model.name_of(face).unwrap_or("?");
780            let decision = match decisions.get(&face) {
781                Some((class, decision)) => format!("{class:?} -> {decision:?}"),
782                None => "not in either solid".to_string(),
783            };
784            Ok(format!(
785                "{face} {name:?}: {decision}, bounded by {:?}",
786                topology.face_corners(face)?
787            ))
788        })
789        .collect::<GeopResult<_>>()?;
790    Err(GeopError::new(format!(
791        "boolean: the kept faces use edge {edge} (from {:?} to {:?}) {count} time(s), leaving the result open there; faces along it: [{}]",
792        topology.get_vertex(e.start_vertex)?.point,
793        topology.get_vertex(e.end_vertex)?.point,
794        faces.join(", ")
795    )))
796}
797
798/// Each of `faces` with the part of its surface over its trim's parameter
799/// box: the box around its pcurves' control points, which holds the loops
800/// and so all the face (a face bounded by a bare vertex is its whole
801/// surface). Its control points' box holds the face, and is apart from
802/// another's where the faces are, however large the surface they were cut
803/// from — a gear's cap cut by every tooth is one surface.
804fn trimmed_patches(model: &Model, faces: &[FaceId]) -> GeopResult<Vec<(FaceId, NurbSurface3D)>> {
805    faces
806        .iter()
807        .map(|&face_id| {
808            let surface = &model.get_face(face_id)?.surface;
809            let mut bounds: Option<[Scalar; 2]> = None;
810            for coedge in model.iterate_face_coedges(face_id) {
811                for p in &model.get_coedge(coedge)?.pcurve.control_points {
812                    let uv = [p[0].div(p[2])?, p[1].div(p[2])?];
813                    bounds = Some(match bounds {
814                        None => uv,
815                        Some(b) => [b[0].union(uv[0]), b[1].union(uv[1])],
816                    });
817                }
818            }
819            let patch = match bounds {
820                Some([u, v]) => {
821                    surface.sub_surface((u.lower(), u.upper()), (v.lower(), v.upper()))?
822                }
823                None => surface.clone(),
824            };
825            Ok((face_id, patch))
826        })
827        .collect()
828}
829
830thread_local! {
831    static FACES_CLASSIFIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
832}
833
834/// How many faces this thread's booleans have classified at points of
835/// their own, against the other solid, ever: what classifying costs grows
836/// with this, so a test can bound the work of a cut by the faces it
837/// touches rather than time it.
838pub fn faces_classified() -> usize {
839    FACES_CLASSIFIED.get()
840}
841
842/// Where `face_id` sits relative to `other_solid`, decided at points strictly
843/// inside the face's trimmed region.
844///
845/// One point would be enough *because remesh ran first*: every curve along
846/// which the other solid's boundary crosses this face has been imprinted, and
847/// the face split along it, so the face no longer straddles anything. Without
848/// that guarantee this would be unsound, which is why it lives here rather
849/// than as a general-purpose query. Every interior point offered (one or two
850/// per boundary coedge, see `face_interior_point_where`) is classified anyway,
851/// and points that disagree are an error: a missed intersection curve then
852/// names the face it left straddling, instead of surfacing as a result that
853/// is open somewhere else — or, by luck of which point came first, not at all.
854///
855/// A point that lands *on* the other solid's boundary doesn't decide it by
856/// itself: the face may lie along that boundary over an area (coincident
857/// patches, told apart by their normals), or merely touch it at a point or
858/// along a curve — a cube face resting on a sphere's pole, say, where the
859/// sphere has no normal at all. So such a point is set aside and the next
860/// interior point tried (one per boundary coedge, see
861/// `face_interior_point_where`); any point off the other solid's boundary
862/// classifies the whole face. Only if every one lies on it are the patches
863/// coincident, and their normals are compared.
864///
865/// Each point is classified from the nearest point `classified` already
866/// holds — of this face or one classified before — along the segment
867/// between them (see `shell_contains_from`), and added to it: one ray
868/// across the whole solid for the first point of a boolean, and short ones
869/// after it, instead of one across the solid for every point.
870pub(crate) fn classify_face(
871    model: &Model,
872    face_id: FaceId,
873    other_solid: SolidId,
874    params: RemeshParams,
875    classified: &mut Classified,
876) -> GeopResult<FaceClassification> {
877    FACES_CLASSIFIED.set(FACES_CLASSIFIED.get() + 1);
878    let face = model.get_face(face_id)?;
879    let mut decided: Vec<(FaceClassification, Vector3)> = Vec::new();
880    let mut on_boundary = Vec::new();
881    let (max_nodes, epsilon) = (params.max_nodes, params.curve_curve_min_subdivision_size);
882    face_interior_point_where(model, face_id, max_nodes, epsilon, SEED, |u, v| {
883        let point = face.surface.evaluate(u, v)?;
884        match solid_contains(
885            model,
886            other_solid,
887            point,
888            max_nodes,
889            epsilon,
890            SEED,
891            classified,
892        )? {
893            ShellPoint::Inside => decided.push((FaceClassification::Inside, point)),
894            ShellPoint::Outside => decided.push((FaceClassification::Outside, point)),
895            ShellPoint::OnFace | ShellPoint::OnEdge | ShellPoint::OnVertex => {
896                on_boundary.push((u, v, point));
897            }
898        }
899        Ok(false)
900    })?;
901    if let Some(&(classification, point)) = decided.first() {
902        if let Some((other, other_point)) = decided.iter().find(|(c, _)| *c != classification) {
903            // The face's boundary: which curves were imprinted into it, and
904            // so — between the two points — which one was not.
905            let loops = model.face_corners(face_id)?;
906            // Where the missing curve may run from: the other solid's edges
907            // at the face's corners that do not bound it, and what the
908            // edge x face search makes of each against the face's surface —
909            // an edge lying on the face that was not imprinted shows here.
910            let other_edges: HashSet<EdgeId> = model.iter_body_edges(other_solid)?.collect();
911            let bounding: HashSet<EdgeId> = model.face_edges(face_id).collect();
912            let mut corners: Vec<_> = loops.iter().flatten().map(|&(v, _)| v).collect();
913            corners.sort();
914            corners.dedup();
915            let mut leaving = Vec::new();
916            for vertex in corners {
917                for edge in model.edges_at_vertex(vertex) {
918                    if !other_edges.contains(&edge) || bounding.contains(&edge) {
919                        continue;
920                    }
921                    let found = curve_surface_intersect(
922                        &model.get_edge(edge)?.curve,
923                        &face.surface,
924                        params.max_edge_intersections,
925                        max_nodes,
926                        epsilon,
927                    );
928                    leaving.push(format!("{edge} at {vertex}: {found:?}"));
929                }
930            }
931            return Err(GeopError::new(format!(
932                "classify_face: face {face_id} straddles solid {other_solid}'s boundary — {point:?} is {classification:?}, {other_point:?} is {other:?} — so remesh left an intersection curve unimprinted; the face is bounded by (corners, outer loop first) {loops:?}; the other solid's edges at its corners, against its surface: {leaving:?}"
933            )));
934        }
935        return Ok(classification);
936    }
937
938    // Every interior point lies on the other solid's boundary: the patches
939    // coincide, and which way the shared patch faces is what separates "these
940    // solids touch" from "these solids overlap along this patch". Any point
941    // of the shared patch shows that equally well, so the first where both
942    // normals exist is used — a revolve's cap collapses to its pole, where
943    // it has none.
944    let mut undefined = None;
945    for &(u, v, point) in &on_boundary {
946        let normals = face
947            .surface
948            .normal(u, v)
949            .and_then(|n| Ok((n, solid_normal_at(model, other_solid, &point, params)?)));
950        let (this_normal, other_normal) = match normals {
951            Ok(normals) => normals,
952            Err(e) => {
953                undefined = Some(e);
954                continue;
955            }
956        };
957        let alignment = this_normal.prod_dot(&other_normal);
958        return if alignment.definitely_greater(Scalar::ZERO) {
959            Ok(FaceClassification::OnSameNormal)
960        } else if alignment.definitely_less(Scalar::ZERO) {
961            Ok(FaceClassification::OnOppositeNormal)
962        } else {
963            Err(GeopError::new(format!(
964                "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"
965            )))
966        };
967    }
968    Err(match undefined {
969        Some(e) => e.with_context(format!(
970            "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",
971            on_boundary.len()
972        )),
973        None => GeopError::new(format!(
974            "classify_face: face {face_id} yielded interior points, yet none was classified or set aside"
975        )),
976    })
977}
978
979/// The normal of whichever face of `solid_id` contains `point`.
980fn solid_normal_at(
981    model: &Model,
982    solid_id: SolidId,
983    point: &Vector3,
984    params: RemeshParams,
985) -> GeopResult<Vector3> {
986    let at = solid_face_at(
987        model,
988        solid_id,
989        point,
990        params.max_nodes,
991        params.curve_curve_min_subdivision_size,
992        SEED,
993    )?;
994    match at {
995        Some((face_id, u, v)) => model.get_face(face_id)?.surface.normal(u, v),
996        None => Err(GeopError::new(format!(
997            "boolean: no face of solid {solid_id} contains {point:?}, although the solid reported the point on its boundary"
998        ))),
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::{BooleanOp, Classified, FaceClassification, boolean, classify_face};
1005    use crate::{
1006        remesh::RemeshParams,
1007        scenes::{scene, validation},
1008    };
1009    use geop_core_math::{scalars::Scalar, vector::Vector3};
1010    use geop_core_topology::{contains::rng::Rng, validation::validate_fast};
1011    use geop_lib_sweep::shapes::{Axis, block, v3};
1012    use geop_ops_model::Namer;
1013
1014    /// A fresh operation id, so that every solid and boolean a test builds
1015    /// gets names of its own.
1016    fn fresh_id() -> String {
1017        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1018        format!(
1019            "op{}",
1020            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1021        )
1022    }
1023
1024    fn namer() -> Namer {
1025        Namer::new("boolean", &fresh_id()).unwrap()
1026    }
1027
1028    /// Every face of an un-remeshed cube is outside a cylinder that only
1029    /// overlaps part of it — the classification itself, before any operator.
1030    #[test]
1031    fn classify_face_separates_inside_from_outside() {
1032        let mut s = scene("box_cylinder_drilled_hole_through");
1033        let params = RemeshParams::default();
1034        crate::remesh::remesh(&mut s.model, &namer(), s.solid_a, s.solid_b, params).unwrap();
1035
1036        let mut inside = 0;
1037        let mut outside = 0;
1038        for face_id in s.model.topology().solid_faces(s.solid_a).unwrap() {
1039            let mut classified = Classified::default();
1040            match classify_face(
1041                s.model.topology(),
1042                face_id,
1043                s.solid_b,
1044                params,
1045                &mut classified,
1046            )
1047            .unwrap()
1048            {
1049                FaceClassification::Inside => inside += 1,
1050                FaceClassification::Outside => outside += 1,
1051                _ => {}
1052            }
1053        }
1054        assert!(
1055            inside > 0 && outside > 0,
1056            "a cylinder drilled through a cube must leave cube faces on both sides: {inside} inside, {outside} outside"
1057        );
1058    }
1059
1060    /// Every face must yield an interior point, or classification is
1061    /// meaningless — this is the part of the algorithm with no fallback.
1062    #[test]
1063    fn every_remeshed_face_has_an_interior_point() {
1064        let mut s = scene("box_cylinder_drilled_hole_through");
1065        let params = RemeshParams::default();
1066        crate::remesh::remesh(&mut s.model, &namer(), s.solid_a, s.solid_b, params).unwrap();
1067
1068        for solid in [s.solid_a, s.solid_b] {
1069            for face_id in s.model.topology().solid_faces(solid).unwrap() {
1070                let (u, v) = geop_core_topology::contains::face::face_interior_point(
1071                    s.model.topology(),
1072                    face_id,
1073                    params.max_nodes,
1074                    params.curve_curve_min_subdivision_size,
1075                    1234,
1076                )
1077                .unwrap_or_else(|e| panic!("face {face_id}: {e}"));
1078                let _ = s
1079                    .model
1080                    .topology()
1081                    .get_face(face_id)
1082                    .unwrap()
1083                    .surface
1084                    .evaluate(u, v)
1085                    .unwrap();
1086            }
1087        }
1088    }
1089
1090    fn check_op(name: &str, op: BooleanOp) {
1091        let mut s = scene(name);
1092        let params = RemeshParams::default();
1093        let result = boolean(&mut s.model, &namer(), s.solid_a, s.solid_b, op, params)
1094            .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"))
1095            .unwrap_or_else(|| panic!("{name} {op:?}: result is empty"));
1096
1097        assert!(
1098            !s.model.topology().solid_faces(result).unwrap().is_empty(),
1099            "{name} {op:?}: result has no faces"
1100        );
1101        if let Err(errors) = validate_fast(&validation(), s.model.topology()) {
1102            panic!(
1103                "{name} {op:?}: {} validate_fast error(s): {}",
1104                errors.len(),
1105                errors[0]
1106            );
1107        }
1108    }
1109
1110    /// Where a probe point lands relative to the result solid.
1111    fn contains(
1112        model: &geop_core_topology::Model,
1113        solid: geop_core_topology::SolidId,
1114        p: (f64, f64, f64),
1115    ) -> bool {
1116        let params = RemeshParams::default();
1117        let point = geop_core_math::vector::Vector3::from_array([
1118            Scalar::from_f64(p.0),
1119            Scalar::from_f64(p.1),
1120            Scalar::from_f64(p.2),
1121        ]);
1122        let shell = model.get_solid(solid).unwrap().shells[0];
1123        matches!(
1124            geop_core_topology::contains::shell::shell_contains(
1125                model,
1126                shell,
1127                point,
1128                params.max_nodes,
1129                params.curve_curve_min_subdivision_size,
1130                0xA5A5_1234,
1131            )
1132            .unwrap(),
1133            geop_core_topology::contains::shell::PointClassification::Inside
1134        )
1135    }
1136
1137    /// The cube is `[-0.5, 0.5]^3`; the cylinder has radius `0.2` on the z
1138    /// axis and spans `z in [-1, 1]`. So each probe below is unambiguously in
1139    /// one region, and the three operators must disagree about them in
1140    /// exactly the way their definitions say. Structural validity says
1141    /// nothing about *which* faces were kept — this is what does.
1142    const IN_CUBE_ONLY: (f64, f64, f64) = (0.4, 0.4, 0.0);
1143    const IN_CYLINDER_ONLY: (f64, f64, f64) = (0.0, 0.0, 0.8);
1144    const IN_BOTH: (f64, f64, f64) = (0.0, 0.0, 0.0);
1145
1146    fn run(op: BooleanOp) -> (geop_core_topology::Model, geop_core_topology::SolidId) {
1147        let mut s = scene("box_cylinder_drilled_hole_through");
1148        let params = RemeshParams::default();
1149        let result = boolean(&mut s.model, &namer(), s.solid_a, s.solid_b, op, params)
1150            .unwrap()
1151            .expect("this scene's operands overlap, so no operator is empty");
1152        (s.model.topology().clone(), result)
1153    }
1154
1155    /// Regression: two cubes meeting along a shared edge, where the traced
1156    /// intersection curve *is* that edge — already part of both faces'
1157    /// boundaries. Splicing it carved a zero-area sliver instead of splitting
1158    /// anything. The direction check was passing because the corrector
1159    /// returned a sharpened `(u, v)` sitting 6e-17 off the trim boundary,
1160    /// which `face_contains` read as `Inside`; see
1161    /// `predictor_corrector_step`.
1162    #[test]
1163    fn box_grid_n1p00_n0p50_n1p00_difference_succeeds() {
1164        let mut s = scene("box_grid_n1p00_n0p50_n1p00");
1165        let params = RemeshParams::default();
1166        boolean(
1167            &mut s.model,
1168            &namer(),
1169            s.solid_a,
1170            s.solid_b,
1171            BooleanOp::Difference,
1172            params,
1173        )
1174        .unwrap_or_else(|e| panic!("{e}"));
1175    }
1176
1177    /// The defining property of a boolean, checked by sampling space rather
1178    /// than by inspecting topology: for a point `p`, membership in the result
1179    /// is a pure function of membership in the two operands.
1180    ///
1181    /// `union` keeps `p` iff either operand held it, `intersection` iff both
1182    /// did, `difference` iff `a` held it and `b` did not. Nothing about faces,
1183    /// coedges or orientation enters into it — which is exactly why it is
1184    /// worth checking. A result can be structurally perfect and still enclose
1185    /// the wrong region, if `BooleanOp::keeps` drops a face it should have
1186    /// kept or leaves one facing inward, and no structural check can see that.
1187    ///
1188    /// Points landing *on* a boundary are skipped rather than asserted about:
1189    /// membership there is genuinely ambiguous, and both the operands and the
1190    /// result may legitimately disagree about a surface they share.
1191    fn check_boolean_matches_point_membership(name: &str, op: BooleanOp, seed: u64) {
1192        let mut s = scene(name);
1193        let params = RemeshParams::default();
1194        let (solid_a, solid_b) = (s.solid_a, s.solid_b);
1195
1196        // Sampled against the operands *before* the boolean consumes them.
1197        let mut rng = Rng::new(seed);
1198        let mut samples = Vec::new();
1199        while samples.len() < SAMPLE_COUNT {
1200            let p = Vector3::from_array([
1201                Scalar::from_f64(rng.next_f64() * 4.0 - 2.0),
1202                Scalar::from_f64(rng.next_f64() * 4.0 - 2.0),
1203                Scalar::from_f64(rng.next_f64() * 4.0 - 2.0),
1204            ]);
1205            let (Some(in_a), Some(in_b)) = (
1206                strictly_inside(s.model.topology(), solid_a, p),
1207                strictly_inside(s.model.topology(), solid_b, p),
1208            ) else {
1209                continue;
1210            };
1211            samples.push((p, in_a, in_b));
1212        }
1213
1214        let result = boolean(&mut s.model, &namer(), solid_a, solid_b, op, params)
1215            .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"));
1216
1217        for (p, in_a, in_b) in samples {
1218            let expected = match op {
1219                BooleanOp::Union => in_a || in_b,
1220                BooleanOp::Intersection => in_a && in_b,
1221                BooleanOp::Difference => in_a && !in_b,
1222                BooleanOp::SymmetricDifference => in_a != in_b,
1223            };
1224            let actual = match result {
1225                Some(solid) => match strictly_inside(s.model.topology(), solid, p) {
1226                    Some(inside) => inside,
1227                    // On the result's own boundary — ambiguous, no claim.
1228                    None => continue,
1229                },
1230                // An empty result contains nothing.
1231                None => false,
1232            };
1233            assert_eq!(
1234                actual,
1235                expected,
1236                "{name} {op:?}: point {p:?} is {} solid A and {} solid B, so the result should {} contain it",
1237                if in_a { "inside" } else { "outside" },
1238                if in_b { "inside" } else { "outside" },
1239                if expected { "" } else { "not" }
1240            );
1241        }
1242    }
1243
1244    /// How many points each membership check samples.
1245    const SAMPLE_COUNT: usize = 20;
1246
1247    /// `Some(true)`/`Some(false)` for a point strictly inside/outside every
1248    /// shell of `solid`; `None` if it lands on a boundary, where membership is
1249    /// not a yes/no question.
1250    fn strictly_inside(
1251        model: &geop_core_topology::Model,
1252        solid: geop_core_topology::SolidId,
1253        p: Vector3,
1254    ) -> Option<bool> {
1255        let params = RemeshParams::default();
1256        let mut inside = false;
1257        for shell in model.get_solid(solid).ok()?.shells.clone() {
1258            match geop_core_topology::contains::shell::shell_contains(
1259                model,
1260                shell,
1261                p,
1262                params.max_nodes,
1263                params.curve_curve_min_subdivision_size,
1264                0x5A3D_1234,
1265            ) {
1266                Ok(geop_core_topology::contains::shell::PointClassification::Inside) => {
1267                    inside = true
1268                }
1269                Ok(geop_core_topology::contains::shell::PointClassification::Outside) => {}
1270                _ => return None,
1271            }
1272        }
1273        Some(inside)
1274    }
1275
1276    /// Scenes with genuinely overlapping operands, so every operator has
1277    /// something to do and the samples land on both sides of each boundary.
1278    const MEMBERSHIP_SCENES: &[&str] = &[
1279        "box_cylinder_drilled_hole_through",
1280        "box_cylinder_blind_hole",
1281        "figure8_cylinder_through_neck",
1282    ];
1283
1284    #[test]
1285    fn union_matches_point_membership() {
1286        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1287            check_boolean_matches_point_membership(name, BooleanOp::Union, 0xB001 + i as u64);
1288        }
1289    }
1290
1291    /// What is in one solid but not the other: the drilled box's material
1292    /// outside the cylinder and the cylinder's outside the box, as one solid
1293    /// whose two pieces meet along the rims.
1294    #[test]
1295    fn symmetric_difference_matches_point_membership() {
1296        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1297            check_boolean_matches_point_membership(
1298                name,
1299                BooleanOp::SymmetricDifference,
1300                0xB201 + i as u64,
1301            );
1302        }
1303    }
1304
1305    #[test]
1306    fn intersection_matches_point_membership() {
1307        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1308            check_boolean_matches_point_membership(
1309                name,
1310                BooleanOp::Intersection,
1311                0xB101 + i as u64,
1312            );
1313        }
1314    }
1315
1316    #[test]
1317    fn difference_matches_point_membership() {
1318        for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1319            check_boolean_matches_point_membership(name, BooleanOp::Difference, 0xB201 + i as u64);
1320        }
1321    }
1322
1323    #[test]
1324    fn union_contains_either_operand() {
1325        let (model, r) = run(BooleanOp::Union);
1326        assert!(contains(&model, r, IN_CUBE_ONLY), "cube-only point");
1327        assert!(contains(&model, r, IN_CYLINDER_ONLY), "cylinder-only point");
1328        assert!(contains(&model, r, IN_BOTH), "shared point");
1329    }
1330
1331    #[test]
1332    fn intersection_contains_only_the_overlap() {
1333        let (model, r) = run(BooleanOp::Intersection);
1334        assert!(
1335            !contains(&model, r, IN_CUBE_ONLY),
1336            "cube-only point must be out"
1337        );
1338        assert!(
1339            !contains(&model, r, IN_CYLINDER_ONLY),
1340            "cylinder-only point must be out"
1341        );
1342        assert!(contains(&model, r, IN_BOTH), "shared point must be in");
1343    }
1344
1345    #[test]
1346    fn difference_removes_the_second_operand() {
1347        let (model, r) = run(BooleanOp::Difference);
1348        assert!(
1349            contains(&model, r, IN_CUBE_ONLY),
1350            "cube-only point must remain"
1351        );
1352        assert!(
1353            !contains(&model, r, IN_CYLINDER_ONLY),
1354            "cylinder-only point must be out"
1355        );
1356        assert!(
1357            !contains(&model, r, IN_BOTH),
1358            "the drilled-out region must be gone"
1359        );
1360    }
1361
1362    #[test]
1363    fn union_of_box_and_cylinder_is_valid() {
1364        check_op("box_cylinder_drilled_hole_through", BooleanOp::Union);
1365    }
1366
1367    #[test]
1368    fn intersection_of_box_and_cylinder_is_valid() {
1369        check_op("box_cylinder_drilled_hole_through", BooleanOp::Intersection);
1370    }
1371
1372    #[test]
1373    fn difference_of_box_and_cylinder_is_valid() {
1374        check_op("box_cylinder_drilled_hole_through", BooleanOp::Difference);
1375    }
1376
1377    /// A bore through a block leaves rims where its side meets the block's
1378    /// top and bottom, whether the bore's caps lie in those faces (the rims
1379    /// are the caps' own edges, imprinted) or stand out of them (the rims
1380    /// are traced where the side crosses the faces, and fitted like any
1381    /// other traced curve). Either way four rim edges lie on each face, and
1382    /// the volume is the block's less the bore's.
1383    #[test]
1384    fn rims_of_a_bore_whether_flush_or_standing_out() {
1385        use geop_core_topology::Body;
1386        let radius = 0.3;
1387        for (base, height) in [(0.0, 1.0), (-0.5, 2.0)] {
1388            let mut model = M::new();
1389            let block = cube(&mut model, [0., 0., 0.], [1., 1., 1.]);
1390            let bore = cylinder(&mut model, [0.5, 0.5, base], radius, height, Axis::Z);
1391            let result = op(&mut model, block, bore, BooleanOp::Difference);
1392            let topology = model.topology();
1393            let curved = topology
1394                .iter_body_edges(Body::Solid(result))
1395                .unwrap()
1396                .filter(|&e| {
1397                    topology
1398                        .get_edge(e)
1399                        .unwrap()
1400                        .curve
1401                        .as_line()
1402                        .unwrap()
1403                        .is_none()
1404                })
1405                .count();
1406            // Four quarters on each rim.
1407            assert_eq!(curved, 8, "base {base}");
1408            let r = Scalar::from_f64(radius);
1409            let expected = Scalar::ONE.sub(Scalar::PI.mul(r).mul(r));
1410            let volume = topology.mass_properties(result).unwrap().volume;
1411            assert!(volume.could_be_equal(expected), "base {base}: {volume:?}");
1412        }
1413    }
1414
1415    // ── Chained booleans on the basic shapes, as the CAD front end builds them ──
1416    //
1417    // Each solid is created just before the boolean that uses it: every step
1418    // validates the *whole* model, and a solid not yet combined with anything
1419    // legitimately overlaps the others in space — which the validation would
1420    // report as edges crossing faces.
1421
1422    type M = geop_ops_model::NamedModel;
1423
1424    /// `CreateCube(offset, dims)`: the axis-aligned box from `offset` to
1425    /// `offset + dims`.
1426    fn cube(model: &mut M, offset: [f64; 3], dims: [f64; 3]) -> geop_core_topology::SolidId {
1427        let max = [0, 1, 2].map(|k| offset[k] + dims[k]);
1428        block(model, &fresh_id(), offset, max)
1429    }
1430
1431    /// `CreateSphere(offset, r)`.
1432    fn sphere(model: &mut M, center: [f64; 3], r: f64) -> geop_core_topology::SolidId {
1433        let [x, y, z] = center;
1434        let r = Scalar::from_f64(r);
1435        geop_lib_sweep::shapes::sphere_solid(model, &fresh_id(), v3(x, y, z), r).unwrap()
1436    }
1437
1438    /// `CreateCylinder(offset, r, h, axis)`: `offset` is the bottom cap's centre.
1439    fn cylinder(
1440        model: &mut M,
1441        base: [f64; 3],
1442        r: f64,
1443        h: f64,
1444        axis: Axis,
1445    ) -> geop_core_topology::SolidId {
1446        let [x, y, z] = base;
1447        geop_lib_sweep::shapes::revolved_cylinder_along_axis(
1448            model,
1449            &fresh_id(),
1450            v3(x, y, z),
1451            Scalar::from_f64(r),
1452            Scalar::from_f64(h),
1453            axis,
1454        )
1455        .unwrap()
1456    }
1457
1458    /// Run one boolean, requiring it to succeed with a non-empty solid that
1459    /// passes `validate_fast` and the full `validate`.
1460    fn op(
1461        model: &mut M,
1462        a: geop_core_topology::SolidId,
1463        b: geop_core_topology::SolidId,
1464        op: BooleanOp,
1465    ) -> geop_core_topology::SolidId {
1466        let result = boolean(model, &namer(), a, b, op, RemeshParams::default())
1467            .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"))
1468            .unwrap_or_else(|| panic!("{op:?} produced an empty solid"));
1469        model.check_names().unwrap();
1470        let topology = model.topology();
1471        if let Err(errors) = validate_fast(&validation(), topology) {
1472            panic!(
1473                "{op:?}: {} validate_fast error(s): {}",
1474                errors.len(),
1475                errors[0]
1476            );
1477        }
1478        // The full validation too, as `assert_valid` holds a solid to it:
1479        // `validate_fast` checks each face on its own, and passes an edge
1480        // running through the middle of another face, or two edges crossing
1481        // away from any vertex. A result touching itself along a line,
1482        // its edge shared by four faces, is a valid solid.
1483        if let Err(errors) =
1484            geop_core_topology::validation::validate_manifold(&validation(), topology)
1485        {
1486            panic!("{op:?}: {} validate error(s): {}", errors.len(), errors[0]);
1487        }
1488        // Closed, which neither check covers: every edge of the result is used
1489        // by an even number of coedges — two where two faces meet, four where
1490        // the solid touches itself along the edge. An odd count is a hole.
1491        for face in topology.solid_faces(result).unwrap() {
1492            for coedge in topology.iterate_face_coedges(face) {
1493                // A bare-vertex boundary has no edge to share.
1494                let Ok(edge) = topology.get_coedge(coedge).unwrap().edge() else {
1495                    continue;
1496                };
1497                let uses = topology.coedges_of_edge(edge).len();
1498                if !uses.is_multiple_of(2) {
1499                    let e = topology.get_edge(edge).unwrap();
1500                    let at = |v| {
1501                        let p = topology.get_vertex(v).unwrap().point;
1502                        [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
1503                    };
1504                    panic!(
1505                        "{op:?}: edge {edge} of face {face}, from {:?} to {:?}, is used by {uses} coedge(s) — the solid is open there",
1506                        at(e.start_vertex),
1507                        at(e.end_vertex)
1508                    );
1509                }
1510            }
1511        }
1512        result
1513    }
1514
1515    /// Reported from the web front end: a cube with a bore through it, the
1516    /// sphere inscribed in the cube added back, then the `y > 0` half cut
1517    /// away. The last difference failed in `remesh_edges_x_edges` with "could
1518    /// not locate vertex on coedge's pcurve" at the cube corner
1519    /// `(-0.5, 0, -0.5)`, where the cutting cube's face meets an edge of the
1520    /// first result.
1521    #[test]
1522    #[ignore = "slow: 6 s — run with `cargo test -- --ignored`"]
1523    fn bored_cube_plus_inscribed_sphere_minus_half() {
1524        let mut model = M::new();
1525        let block = cube(&mut model, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
1526        let bore = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
1527        let bored = op(&mut model, block, bore, BooleanOp::Difference);
1528        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1529        let filled = op(&mut model, bored, ball, BooleanOp::Union);
1530        let half = cube(&mut model, [-0.5, 0.0, -0.5], [1.0, 1.0, 1.0]);
1531        op(&mut model, filled, half, BooleanOp::Difference);
1532    }
1533
1534    /// Reported from the web front end: a cube with a flush inscribed bore
1535    /// (tangent to the four sides, caps coplanar with top and bottom), the
1536    /// inscribed sphere added back, then everything with `x < 0.05` cut away
1537    /// like a section view. The result looked open — two faces missing —
1538    /// with spurious triangles in the cutting plane. Not a manifold even when
1539    /// right: the bore touches the cube's sides along lines, and the one at
1540    /// `x = 0.5` survives the cut.
1541    #[test]
1542    #[ignore = "slow: 5 s — run with `cargo test -- --ignored`"]
1543    fn flush_bored_cube_plus_inscribed_sphere_section() {
1544        let mut model = M::new();
1545        let block = cube(&mut model, CORNER, UNIT);
1546        let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1547        let bored = op(&mut model, block, bore, BooleanOp::Difference);
1548        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1549        let filled = op(&mut model, bored, ball, BooleanOp::Union);
1550        let cutter = cube(&mut model, [-1.45, -1.05, -0.82], [1.5, 2.1, 1.65]);
1551        op(&mut model, filled, cutter, BooleanOp::Difference);
1552    }
1553
1554    /// Like [`op`], for a boolean whose correct result is empty.
1555    fn op_empty(
1556        model: &mut M,
1557        a: geop_core_topology::SolidId,
1558        b: geop_core_topology::SolidId,
1559        op: BooleanOp,
1560    ) {
1561        let result = boolean(model, &namer(), a, b, op, RemeshParams::default())
1562            .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"));
1563        assert!(result.is_none(), "{op:?} should be empty");
1564    }
1565
1566    const UNIT: [f64; 3] = [1.0, 1.0, 1.0];
1567    const CORNER: [f64; 3] = [-0.5, -0.5, -0.5];
1568
1569    // Tangent contact. Where two surfaces touch without crossing, the
1570    // intersection is a curve or point along which a tiny error in one
1571    // surface moves the contact a long way (a perpendicular error `d` shifts a
1572    // tangency by `~sqrt(2 R d)`), which is what broke
1573    // `bored_cube_plus_inscribed_sphere_minus_half`.
1574
1575    /// The cylinder touches all four side faces along vertical lines.
1576    #[test]
1577    fn cube_minus_inscribed_cylinder() {
1578        let mut model = M::new();
1579        let block = cube(&mut model, CORNER, UNIT);
1580        let bore = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
1581        op(&mut model, block, bore, BooleanOp::Difference);
1582    }
1583
1584    /// The cylinder is tangent to the four sides *and* its caps are flush
1585    /// with the top and bottom: each cap's rim lies in the cube's face and
1586    /// touches that face's four edges at their midpoints. The first step of
1587    /// `flush_bored_cube_plus_inscribed_sphere_section`.
1588    #[test]
1589    fn cube_minus_flush_inscribed_cylinder() {
1590        let mut model = M::new();
1591        let block = cube(&mut model, CORNER, UNIT);
1592        let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1593        op(&mut model, block, bore, BooleanOp::Difference);
1594    }
1595
1596    /// The sphere touches each face of the cube at its centre.
1597    #[test]
1598    fn cube_minus_inscribed_sphere() {
1599        let mut model = M::new();
1600        let block = cube(&mut model, CORNER, UNIT);
1601        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1602        op(&mut model, block, ball, BooleanOp::Difference);
1603    }
1604
1605    /// The sphere passes through all eight corners of the cube.
1606    #[test]
1607    fn cube_intersect_circumscribed_sphere() {
1608        let mut model = M::new();
1609        let block = cube(&mut model, CORNER, UNIT);
1610        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 3f64.sqrt() / 2.0);
1611        op(&mut model, block, ball, BooleanOp::Intersection);
1612    }
1613
1614    /// The cylinder's surface contains the cube's four vertical edges.
1615    #[test]
1616    fn cube_intersect_cylinder_through_its_edges() {
1617        let mut model = M::new();
1618        let block = cube(&mut model, CORNER, UNIT);
1619        let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5f64.sqrt(), 2.0, Axis::Z);
1620        op(&mut model, block, tube, BooleanOp::Intersection);
1621    }
1622
1623    /// A coaxial cylinder of the sphere's radius touches it along the
1624    /// equator, a whole circle of tangency.
1625    #[test]
1626    fn sphere_union_tangent_cylinder() {
1627        let mut model = M::new();
1628        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1629        let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1630        op(&mut model, ball, tube, BooleanOp::Union);
1631    }
1632
1633    /// A cube resting on the sphere's pole, where the sphere's patches all
1634    /// collapse to one point: tangent contact at a singular point.
1635    #[test]
1636    fn sphere_union_cube_touching_its_pole() {
1637        let mut model = M::new();
1638        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1639        let block = cube(&mut model, [-0.5, -0.5, 0.5], UNIT);
1640        op(&mut model, ball, block, BooleanOp::Union);
1641    }
1642
1643    /// A cylinder along x lying on the cube's top face: tangent along a line.
1644    #[test]
1645    fn cube_union_cylinder_lying_on_top() {
1646        let mut model = M::new();
1647        let block = cube(&mut model, CORNER, UNIT);
1648        let log = cylinder(&mut model, [-1.0, 0.0, 0.75], 0.25, 2.0, Axis::X);
1649        op(&mut model, block, log, BooleanOp::Union);
1650    }
1651
1652    // Coincident features: shared faces, edges and corners, coplanar caps, and
1653    // identical solids.
1654
1655    #[test]
1656    fn cubes_sharing_a_face_union() {
1657        let mut model = M::new();
1658        let a = cube(&mut model, CORNER, UNIT);
1659        let b = cube(&mut model, [0.5, -0.5, -0.5], UNIT);
1660        op(&mut model, a, b, BooleanOp::Union);
1661    }
1662
1663    #[test]
1664    fn cubes_sharing_an_edge_union() {
1665        let mut model = M::new();
1666        let a = cube(&mut model, CORNER, UNIT);
1667        let b = cube(&mut model, [0.5, 0.5, -0.5], UNIT);
1668        op(&mut model, a, b, BooleanOp::Union);
1669    }
1670
1671    #[test]
1672    fn cubes_sharing_a_corner_union() {
1673        let mut model = M::new();
1674        let a = cube(&mut model, CORNER, UNIT);
1675        let b = cube(&mut model, [0.5, 0.5, 0.5], UNIT);
1676        op(&mut model, a, b, BooleanOp::Union);
1677    }
1678
1679    /// A half-overlapping cube: two of its faces are coplanar with the first
1680    /// cube's, over part of their area.
1681    #[test]
1682    fn cube_minus_offset_cube_with_coplanar_faces() {
1683        let mut model = M::new();
1684        let a = cube(&mut model, CORNER, UNIT);
1685        let b = cube(&mut model, [0.0, -0.5, -0.5], UNIT);
1686        op(&mut model, a, b, BooleanOp::Difference);
1687    }
1688
1689    /// A hole whose caps are flush with the cube's top and bottom.
1690    #[test]
1691    fn cube_minus_flush_cylinder() {
1692        let mut model = M::new();
1693        let block = cube(&mut model, CORNER, UNIT);
1694        let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.3, 1.0, Axis::Z);
1695        op(&mut model, block, bore, BooleanOp::Difference);
1696    }
1697
1698    /// A plate 6 by 3 and a blind hole to drill into it from its top: a
1699    /// cylinder of radius 0.33 around `(3, 1.5)`, its start cap flush with
1700    /// the top.
1701    fn plate_and_blind_hole(
1702        model: &mut M,
1703    ) -> (geop_core_topology::SolidId, geop_core_topology::SolidId) {
1704        let plate = cube(model, [0.0, 0.0, 0.0], [6.0, 3.0, 0.5]);
1705        let hole = cylinder(model, [3.0, 1.5, 0.2], 0.33, 0.3, Axis::Z);
1706        (plate, hole)
1707    }
1708
1709    /// The plate and hole of [`plate_and_blind_hole`] remeshed, and the disc
1710    /// the remesh cuts from the plate's top, inside the hole's rim.
1711    fn plate_and_blind_hole_remeshed(
1712        model: &mut M,
1713    ) -> (geop_core_topology::SolidId, geop_core_topology::FaceId) {
1714        let (plate, hole) = plate_and_blind_hole(model);
1715        crate::remesh::remesh(model, &namer(), plate, hole, RemeshParams::default()).unwrap();
1716        let topology = model.topology();
1717        if let Err(errors) = validate_fast(&validation(), topology) {
1718            panic!("{} validate_fast error(s): {}", errors.len(), errors[0]);
1719        }
1720        let in_rim = |p: &[f64; 3]| (p[0] - 3.0).hypot(p[1] - 1.5) < 0.34 && p[2] > 0.49;
1721        let disc = topology
1722            .solid_faces(plate)
1723            .unwrap()
1724            .into_iter()
1725            .find(|&face| {
1726                let corners = topology.face_corners(face).unwrap();
1727                corners.len() == 1 && corners[0].iter().all(|(_, p)| in_rim(p))
1728            })
1729            .expect("a face of the plate inside the hole's rim");
1730        (hole, disc)
1731    }
1732
1733    /// Reported as the parametric plate made 6 wide with an M6 hole: the
1734    /// result was left open along the hole's rim, as the disc cut from the
1735    /// top was classified outside the hole rather than on its cap.
1736    #[test]
1737    fn plate_minus_blind_hole_from_its_top() {
1738        let mut model = M::new();
1739        let (plate, hole) = plate_and_blind_hole(&mut model);
1740        op(&mut model, plate, hole, BooleanOp::Difference);
1741    }
1742
1743    /// A bolt hole patterned round a revolved flange: the sixth copy's centre
1744    /// landed `3.6e-4` beside the flange cap's quarter seam, the angle of the
1745    /// seed given to a thousandth of a degree. The hole's cap is flush with
1746    /// the flange's, and its own quarter seam, a radial line from its centre
1747    /// pole, crosses the flange's seam `3.6e-4` from that pole: a genuine
1748    /// transversal crossing of two straight edges, which the remesh refused
1749    /// as a near-duplicate of the pole and panicked on. Here on a plain
1750    /// cylinder: the tool's `+x` seam from `(-3.6e-4, 28)` crosses the
1751    /// cylinder's `+y` seam at `(0, 28)`, and the piece of the tool's seam
1752    /// between the two is `3.6e-4` long.
1753    #[test]
1754    fn blind_hole_whose_cap_seam_crosses_the_caps_seam_beside_its_centre() {
1755        let mut model = M::new();
1756        let disc = cylinder(&mut model, [0.0, 0.0, 0.0], 40.0, 6.0, Axis::Z);
1757        let hole = cylinder(
1758            &mut model,
1759            [-0.000_355_652_98, 27.999_383_99, 3.0],
1760            3.3,
1761            3.0,
1762            Axis::Z,
1763        );
1764        op(&mut model, disc, hole, BooleanOp::Difference);
1765    }
1766
1767    /// The disc cut from the plate's top lies on the hole's start cap, so
1768    /// every point a boolean classifies it by is on the hole's boundary.
1769    #[test]
1770    fn blind_hole_cap_covers_the_disc_cut_from_the_top() {
1771        use geop_core_topology::contains::{
1772            face::face_interior_point_where,
1773            shell::{PointClassification, shell_contains},
1774        };
1775        let mut model = M::new();
1776        let (hole, disc) = plate_and_blind_hole_remeshed(&mut model);
1777        let topology = model.topology();
1778        let params = RemeshParams::default();
1779        let shell = topology.get_solid(hole).unwrap().shells[0];
1780        let mut checked = 0;
1781        face_interior_point_where(
1782            topology,
1783            disc,
1784            params.max_nodes,
1785            params.curve_curve_min_subdivision_size,
1786            super::SEED,
1787            |u, v| {
1788                let point = topology.get_face(disc)?.surface.evaluate(u, v)?;
1789                let class = shell_contains(
1790                    topology,
1791                    shell,
1792                    point,
1793                    params.max_nodes,
1794                    params.curve_curve_min_subdivision_size,
1795                    super::SEED,
1796                )?;
1797                assert!(
1798                    !matches!(
1799                        class,
1800                        PointClassification::Inside | PointClassification::Outside
1801                    ),
1802                    "{point:?} of the disc, at uv=({u:?}, {v:?}), is {class:?}"
1803                );
1804                checked += 1;
1805                Ok(false)
1806            },
1807        )
1808        .unwrap();
1809        assert!(checked > 0);
1810    }
1811
1812    /// The point the disc cut from the plate's top was once classified by:
1813    /// beside it — 0.44 from the hole's axis, the rim 0.33 — and outside it,
1814    /// as a point and as the box around it `face_interior_point_where` asks
1815    /// about. With the boolean's seed, the first ray from the box grazes the
1816    /// rim; cast from the box itself, it counted that as one crossing.
1817    #[test]
1818    fn point_beside_the_disc_cut_from_the_top_is_outside_it() {
1819        use geop_core_topology::contains::face::{PointClassification, face_contains};
1820        let mut model = M::new();
1821        let (_, disc) = plate_and_blind_hole_remeshed(&mut model);
1822        let topology = model.topology();
1823        let (u, v) = (
1824            Scalar::from_f64(0.42698015887332674),
1825            Scalar::from_f64(0.5220523370528238),
1826        );
1827        let point = topology
1828            .get_face(disc)
1829            .unwrap()
1830            .surface
1831            .evaluate(u, v)
1832            .unwrap();
1833        assert!(
1834            (point[0].to_f64() - 3.0).hypot(point[1].to_f64() - 1.5) > 0.4,
1835            "{point:?}"
1836        );
1837        let params = RemeshParams::default();
1838        let epsilon = params.curve_curve_min_subdivision_size;
1839        let neighbourhood = |t: Scalar| t.sub(epsilon).union(t.add(epsilon));
1840        let mut wrong = Vec::new();
1841        for (u, v) in [(u, v), (neighbourhood(u), neighbourhood(v))] {
1842            for seed in (0..64).chain([super::SEED]) {
1843                let class =
1844                    face_contains(topology, disc, u, v, params.max_nodes, epsilon, seed).unwrap();
1845                if class != PointClassification::Outside {
1846                    wrong.push(format!("{class:?} at ({u:?}, {v:?}) with seed {seed}"));
1847                }
1848            }
1849        }
1850        assert!(wrong.is_empty(), "{}", wrong.join("\n"));
1851    }
1852
1853    #[test]
1854    fn identical_spheres_union() {
1855        let mut model = M::new();
1856        let a = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1857        let b = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1858        op(&mut model, a, b, BooleanOp::Union);
1859    }
1860
1861    #[test]
1862    fn identical_cubes_difference_is_empty() {
1863        let mut model = M::new();
1864        let a = cube(&mut model, CORNER, UNIT);
1865        let b = cube(&mut model, CORNER, UNIT);
1866        op_empty(&mut model, a, b, BooleanOp::Difference);
1867    }
1868
1869    /// Every operator on two copies of one shape — same geometry, separate
1870    /// topology with ids of their own — so that each face of either copy
1871    /// coincides with a face of the other over its whole area, and every edge
1872    /// and vertex lies exactly on one of the other copy's. Union and
1873    /// intersection must give the shape back, difference must be empty.
1874    fn check_duplicated_shape(make: impl Fn(&mut M) -> geop_core_topology::SolidId) {
1875        let probes = [(0.0, 0.0, 0.0), (0.2, -0.1, 0.15), (0.9, 0.0, 0.0)];
1876        let inside_original = {
1877            let mut model = M::new();
1878            let shape = make(&mut model);
1879            probes.map(|p| contains(model.topology(), shape, p))
1880        };
1881        for operator in [BooleanOp::Union, BooleanOp::Intersection] {
1882            let mut model = M::new();
1883            let a = make(&mut model);
1884            let b = make(&mut model);
1885            let result = op(&mut model, a, b, operator);
1886            for (p, inside) in probes.iter().zip(inside_original) {
1887                assert_eq!(
1888                    contains(model.topology(), result, *p),
1889                    inside,
1890                    "{operator:?} of two copies must contain {p:?} exactly when one copy does"
1891                );
1892            }
1893        }
1894        let mut model = M::new();
1895        let a = make(&mut model);
1896        let b = make(&mut model);
1897        op_empty(&mut model, a, b, BooleanOp::Difference);
1898    }
1899
1900    #[test]
1901    fn duplicated_cube_all_operators() {
1902        check_duplicated_shape(|model| cube(model, CORNER, UNIT));
1903    }
1904
1905    #[test]
1906    #[ignore = "slow: 6 s — run with `cargo test -- --ignored`"]
1907    fn duplicated_sphere_all_operators() {
1908        check_duplicated_shape(|model| sphere(model, [0.0, 0.0, 0.0], 0.5));
1909    }
1910
1911    #[test]
1912    #[ignore = "slow: 8 s — run with `cargo test -- --ignored`"]
1913    fn duplicated_cylinder_all_operators() {
1914        check_duplicated_shape(|model| cylinder(model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z));
1915    }
1916
1917    /// A sphere centred on the cube's corner: its three coordinate-plane
1918    /// seams lie exactly in three faces of the cube.
1919    #[test]
1920    fn cube_minus_sphere_on_its_corner() {
1921        let mut model = M::new();
1922        let block = cube(&mut model, CORNER, UNIT);
1923        let ball = sphere(&mut model, [0.5, 0.5, 0.5], 0.5);
1924        op(&mut model, block, ball, BooleanOp::Difference);
1925    }
1926
1927    // Curved against curved.
1928
1929    /// Two equal cylinders crossing at right angles: their intersection
1930    /// curves (two ellipses) cross each other at two singular points.
1931    #[test]
1932    fn steinmetz_cylinders_union() {
1933        let mut model = M::new();
1934        let a = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1935        let b = cylinder(&mut model, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1936        op(&mut model, a, b, BooleanOp::Union);
1937    }
1938
1939    #[test]
1940    fn steinmetz_cylinders_intersection() {
1941        let mut model = M::new();
1942        let a = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1943        let b = cylinder(&mut model, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1944        op(&mut model, a, b, BooleanOp::Intersection);
1945    }
1946
1947    /// A bore along the sphere's axis, through both poles.
1948    #[test]
1949    fn sphere_minus_cylinder_through_its_poles() {
1950        let mut model = M::new();
1951        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1952        let bore = cylinder(&mut model, [0.0, 0.0, -1.0], 0.2, 2.0, Axis::Z);
1953        op(&mut model, ball, bore, BooleanOp::Difference);
1954    }
1955
1956    /// A sphere centred on a cylinder's cap: the cap cuts it along its
1957    /// equator, which is also where its patches meet.
1958    #[test]
1959    fn cylinder_union_sphere_on_its_cap() {
1960        let mut model = M::new();
1961        let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1962        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.3);
1963        op(&mut model, tube, ball, BooleanOp::Union);
1964    }
1965
1966    /// The same with the sphere's radius equal to the cylinder's: the
1967    /// sphere's equator coincides with the cap's rim, *and* the sphere is
1968    /// tangent to the cylinder's side along that same circle.
1969    #[test]
1970    fn cylinder_union_equal_sphere_on_its_cap() {
1971        let mut model = M::new();
1972        let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1973        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1974        op(&mut model, tube, ball, BooleanOp::Union);
1975    }
1976
1977    /// External tangency at a point of the sphere's equator (not a pole).
1978    #[test]
1979    fn cube_union_sphere_touching_a_face() {
1980        let mut model = M::new();
1981        let block = cube(&mut model, CORNER, UNIT);
1982        let ball = sphere(&mut model, [1.0, 0.0, 0.0], 0.5);
1983        op(&mut model, block, ball, BooleanOp::Union);
1984    }
1985
1986    /// A sphere half sunk into the cube through the centre of a face.
1987    #[test]
1988    fn cube_union_sphere_on_a_face() {
1989        let mut model = M::new();
1990        let block = cube(&mut model, CORNER, UNIT);
1991        let ball = sphere(&mut model, [0.5, 0.0, 0.0], 0.3);
1992        op(&mut model, block, ball, BooleanOp::Union);
1993    }
1994
1995    /// Two inscribed bores at right angles: the first two steps of
1996    /// `cube_minus_three_inscribed_bores`. The second bore's wall meets the
1997    /// first's in two Steinmetz ellipses, which end exactly where both bores
1998    /// touch the cube's faces — at the midpoints of its edges.
1999    #[test]
2000    #[ignore = "slow: 4 s — run with `cargo test -- --ignored`"]
2001    fn cube_minus_two_inscribed_bores() {
2002        let mut model = M::new();
2003        let block = cube(&mut model, CORNER, UNIT);
2004        let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2005        let a = op(&mut model, block, z, BooleanOp::Difference);
2006        let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2007        op(&mut model, a, x, BooleanOp::Difference);
2008    }
2009
2010    /// Remesh `a` against `b`, then require an edge shared by faces of both
2011    /// between each pair of points in `arcs`, and the full validation to
2012    /// pass.
2013    ///
2014    /// The full validation cannot see a missing intersection arc that ends
2015    /// at vertices both solids share: faces sharing a vertex are not
2016    /// searched for crossings. A boolean only notices one as a face it cannot
2017    /// classify, or a result that is open, far from the cause. So the arcs a
2018    /// scene must have are named, and on failure the edges it does have are
2019    /// listed.
2020    fn check_remesh_imprints(
2021        model: &mut M,
2022        a: geop_core_topology::SolidId,
2023        b: geop_core_topology::SolidId,
2024        arcs: &[[[f64; 3]; 2]],
2025    ) {
2026        crate::remesh::remesh(model, &namer(), a, b, RemeshParams::default())
2027            .unwrap_or_else(|e| panic!("remesh failed: {e}"));
2028        let topology = model.topology();
2029        let faces_a = topology.solid_faces(a).unwrap();
2030        let faces_b = topology.solid_faces(b).unwrap();
2031        let mut shared = Vec::new();
2032        for (&edge_id, edge) in &topology.edges {
2033            let faces: Vec<_> = topology
2034                .coedges_of_edge(edge_id)
2035                .iter()
2036                .map(|&c| topology.get_coedge(c).unwrap().face)
2037                .collect();
2038            if faces.iter().any(|f| faces_a.contains(f))
2039                && faces.iter().any(|f| faces_b.contains(f))
2040            {
2041                shared.push([edge.start_vertex, edge.end_vertex].map(|v| {
2042                    let p = topology.get_vertex(v).unwrap().point;
2043                    [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
2044                }));
2045            }
2046        }
2047        // Telling which known point a vertex is, not a kernel comparison:
2048        // the expected points are well apart, so any coarse tolerance names
2049        // them unambiguously.
2050        let near = |p: [f64; 3], q: [f64; 3]| (0..3).all(|i| (p[i] - q[i]).abs() < 1e-4);
2051        let missing: Vec<_> = arcs
2052            .iter()
2053            .filter(|[p, q]| {
2054                !shared
2055                    .iter()
2056                    .any(|[s, e]| (near(*s, *p) && near(*e, *q)) || (near(*s, *q) && near(*e, *p)))
2057            })
2058            .collect();
2059        assert!(
2060            missing.is_empty(),
2061            "no edge shared by both solids between {missing:?}; the shared edges are {shared:?}"
2062        );
2063
2064        if let Err(errors) = geop_core_topology::validation::validate(&validation(), topology) {
2065            let all: Vec<String> = errors.iter().map(|e| format!("{e}")).collect();
2066            panic!(
2067                "{} validate error(s):\n{}",
2068                errors.len(),
2069                all.join("\n---\n")
2070            );
2071        }
2072    }
2073
2074    /// The remesh behind `cube_minus_two_inscribed_bores`' second step: each
2075    /// Steinmetz ellipse `x = ±z` of the two walls runs through midpoints
2076    /// `(±0.5, 0, ±0.5)` of the cube's edges and through `(0, ±0.5, 0)`,
2077    /// where the two ellipses cross with the walls tangent — so each of its
2078    /// eight arcs can only be traced from the midpoint end.
2079    #[test]
2080    #[ignore = "slow: 5 s — run with `cargo test -- --ignored`"]
2081    fn two_inscribed_bores_remesh_imprints_every_arc() {
2082        let mut model = M::new();
2083        let block = cube(&mut model, CORNER, UNIT);
2084        let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2085        let a = op(&mut model, block, z, BooleanOp::Difference);
2086        let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2087        let mut arcs = Vec::new();
2088        for (mx, mz) in [(0.5, 0.5), (0.5, -0.5), (-0.5, 0.5), (-0.5, -0.5)] {
2089            for cy in [0.5, -0.5] {
2090                arcs.push([[mx, 0.0, mz], [0.0, cy, 0.0]]);
2091            }
2092        }
2093        check_remesh_imprints(&mut model, a, x, &arcs);
2094    }
2095
2096    /// The remesh behind `cube_minus_three_inscribed_bores`' third step. In
2097    /// each octant the three walls meet at one point, `(±1, ±1, ±1) / sqrt 8`,
2098    /// where the arc the third wall cuts from the first meets the arc it cuts
2099    /// from the second: one runs from there to `(0, ±0.5, ±0.5)`, the other
2100    /// to `(±0.5, ±0.5, 0)`. That point is interior to the third wall, so both
2101    /// arcs leave it into the same face.
2102    #[test]
2103    #[ignore = "slow: 9 s — run with `cargo test -- --ignored`"]
2104    fn three_inscribed_bores_remesh_imprints_every_arc() {
2105        let mut model = M::new();
2106        let block = cube(&mut model, CORNER, UNIT);
2107        let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2108        let a = op(&mut model, block, z, BooleanOp::Difference);
2109        let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2110        let b = op(&mut model, a, x, BooleanOp::Difference);
2111        let y = cylinder(&mut model, [0.0, -0.875, 0.0], 0.5, 1.75, Axis::Y);
2112        let t = 0.125f64.sqrt();
2113        let mut arcs = Vec::new();
2114        for sx in [1.0, -1.0] {
2115            for sy in [1.0, -1.0] {
2116                for sz in [1.0, -1.0] {
2117                    let triple = [sx * t, sy * t, sz * t];
2118                    arcs.push([triple, [0.0, sy * 0.5, sz * 0.5]]);
2119                    arcs.push([triple, [sx * 0.5, sy * 0.5, 0.0]]);
2120                }
2121            }
2122        }
2123        check_remesh_imprints(&mut model, b, y, &arcs);
2124    }
2125
2126    /// Inscribed bores along all three axes, one after another (a "jack").
2127    /// Each bore is tangent to four faces, and each later bore crosses the
2128    /// earlier ones at Steinmetz points.
2129    #[test]
2130    #[ignore = "slow: 8 s — run with `cargo test -- --ignored`"]
2131    fn cube_minus_three_inscribed_bores() {
2132        let mut model = M::new();
2133        let block = cube(&mut model, CORNER, UNIT);
2134        let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2135        let a = op(&mut model, block, z, BooleanOp::Difference);
2136        let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2137        let b = op(&mut model, a, x, BooleanOp::Difference);
2138        let y = cylinder(&mut model, [0.0, -0.875, 0.0], 0.5, 1.75, Axis::Y);
2139        op(&mut model, b, y, BooleanOp::Difference);
2140    }
2141
2142    /// Thinner bores, so the cube keeps its faces: two crossing bores.
2143    #[test]
2144    fn cube_minus_two_crossing_bores() {
2145        let mut model = M::new();
2146        let block = cube(&mut model, CORNER, UNIT);
2147        let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.3, 1.75, Axis::Z);
2148        let a = op(&mut model, block, z, BooleanOp::Difference);
2149        let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.3, 1.75, Axis::X);
2150        op(&mut model, a, x, BooleanOp::Difference);
2151    }
2152
2153    // More tangencies, and a longer chain.
2154
2155    /// Two spheres touching externally at a single point.
2156    #[test]
2157    fn touching_spheres_union() {
2158        let mut model = M::new();
2159        let a = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
2160        let b = sphere(&mut model, [1.0, 0.0, 0.0], 0.5);
2161        op(&mut model, a, b, BooleanOp::Union);
2162    }
2163
2164    /// A sphere inside a cylinder of the same radius, touching it along the
2165    /// equator from inside.
2166    #[test]
2167    fn cylinder_minus_inscribed_sphere() {
2168        let mut model = M::new();
2169        let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
2170        let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
2171        op(&mut model, tube, ball, BooleanOp::Difference);
2172    }
2173
2174    /// Two parallel cylinders touching along a line.
2175    #[test]
2176    fn touching_parallel_cylinders_union() {
2177        let mut model = M::new();
2178        let a = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
2179        let b = cylinder(&mut model, [1.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
2180        op(&mut model, a, b, BooleanOp::Union);
2181    }
2182
2183    /// A sphere touching one of the cube's edges from outside.
2184    #[test]
2185    fn cube_union_sphere_touching_an_edge() {
2186        let mut model = M::new();
2187        let block = cube(&mut model, CORNER, UNIT);
2188        let d = 0.5 + 0.5 / 2f64.sqrt();
2189        let ball = sphere(&mut model, [d, d, 0.0], 0.5);
2190        op(&mut model, block, ball, BooleanOp::Union);
2191    }
2192
2193    /// A small part as a user might build it: a plate with a boss and a
2194    /// through hole, then a rounded cap on the boss.
2195    #[test]
2196    fn plate_with_boss_hole_and_cap() {
2197        let mut model = M::new();
2198        let plate = cube(&mut model, [-1.0, -1.0, -0.25], [2.0, 2.0, 0.5]);
2199        let boss = cylinder(&mut model, [0.0, 0.0, 0.25], 0.5, 0.5, Axis::Z);
2200        let a = op(&mut model, plate, boss, BooleanOp::Union);
2201        let cap = sphere(&mut model, [0.0, 0.0, 0.75], 0.5);
2202        let b = op(&mut model, a, cap, BooleanOp::Union);
2203        let hole = cylinder(&mut model, [0.0, 0.0, -0.5], 0.25, 1.5, Axis::Z);
2204        op(&mut model, b, hole, BooleanOp::Difference);
2205    }
2206
2207    /// The second step of `chained_differences_block_with_two_slots_and_a_sphere`,
2208    /// stopped after remesh: a point of the second slot's side face, above the
2209    /// block, must classify as outside the slotted block. It came back
2210    /// `Inside` — a ray-parity miscount that the boolean only stopped hiding
2211    /// once `classify_face` checked every interior point it sampled.
2212    #[test]
2213    fn chained_slots_point_above_the_block_is_outside() {
2214        let mut model = M::new();
2215        let a = cube(&mut model, [-0.50, -0.50, -0.50], [1.00, 1.00, 1.00]);
2216        let b = cube(&mut model, [-1.13, -0.30, -0.33], [2.25, 0.60, 0.65]);
2217        let c = op(&mut model, a, b, BooleanOp::Difference);
2218        let d = cube(&mut model, [-0.33, -0.28, -1.15], [0.65, 0.55, 2.30]);
2219        crate::remesh::remesh(&mut model, &namer(), c, d, RemeshParams::default()).unwrap();
2220
2221        let params = RemeshParams::default();
2222        let shell = model.topology().get_solid(c).unwrap().shells[0];
2223        let classification = geop_core_topology::contains::shell::shell_contains(
2224            model.topology(),
2225            shell,
2226            v3(-0.005, -0.28, 0.825),
2227            params.max_nodes,
2228            params.curve_curve_min_subdivision_size,
2229            super::SEED,
2230        )
2231        .unwrap();
2232        assert_eq!(
2233            classification,
2234            geop_core_topology::contains::shell::PointClassification::Outside
2235        );
2236    }
2237
2238    #[test]
2239    fn chained_differences_block_with_two_slots_and_a_sphere() {
2240        let f = Scalar::from_f64;
2241        let mut model = M::new();
2242        let a = cube(&mut model, [-0.50, -0.50, -0.50], [1.00, 1.00, 1.00]);
2243        let b = cube(&mut model, [-1.13, -0.30, -0.33], [2.25, 0.60, 0.65]);
2244        let params = RemeshParams::default();
2245        let c = boolean(&mut model, &namer(), a, b, BooleanOp::Difference, params)
2246            .unwrap()
2247            .expect("block minus the first slot must be non-empty");
2248
2249        let d = cube(&mut model, [-0.33, -0.28, -1.15], [0.65, 0.55, 2.30]);
2250        let e = boolean(&mut model, &namer(), c, d, BooleanOp::Difference, params)
2251            .unwrap()
2252            .expect("minus the second slot must be non-empty");
2253
2254        let sphere =
2255            geop_lib_sweep::shapes::sphere_solid(&mut model, "s", Vector3::zero(), f(0.45))
2256                .unwrap();
2257        let result = boolean(
2258            &mut model,
2259            &namer(),
2260            e,
2261            sphere,
2262            BooleanOp::Difference,
2263            params,
2264        )
2265        .unwrap()
2266        .expect("minus the sphere must be non-empty");
2267        model.check_names().unwrap();
2268
2269        let topology = model.topology();
2270        assert!(!topology.solid_faces(result).unwrap().is_empty());
2271        if let Err(errors) = validate_fast(&validation(), topology) {
2272            panic!("{} validate_fast error(s): {}", errors.len(), errors[0]);
2273        }
2274
2275        let scene = geop_core_rasterize::rasterize(topology, 8)
2276            .unwrap()
2277            .scene(|_| geop_core_rasterize::debug::Color10::Blue);
2278        std::fs::create_dir_all("outputs").unwrap();
2279        scene
2280            .save_to_file("outputs/chained_differences_block_with_two_slots_and_a_sphere.html")
2281            .unwrap();
2282    }
2283
2284    /// Regression test, captured from a browser session: two cubes
2285    /// differenced, then a sphere differenced out of that, then a thin slab
2286    /// cube differenced out of *that*. The second cube and the sphere were
2287    /// anchored to corners of the first cube, picked by kernel id
2288    /// (`VertexId(13)` and `VertexId(9)`, its corners `p3,start` and
2289    /// `p2,start`); their coordinates as the session read them are given
2290    /// here, to the last bit.
2291    ///
2292    /// Used to fail inside the final boolean's `classify_face`:
2293    /// `shell_contains` reported a point on the slab's boundary that no face
2294    /// of that shell's `face_contains` agreed contained. Root cause:
2295    /// `shell_contains`'s face coincidence pre-check only asked
2296    /// `surface_could_contain` — proximity to a face's *untrimmed* surface —
2297    /// without checking the point fell within the face's trim. Proximity is
2298    /// not membership (see `AGENTS.md`).
2299    #[test]
2300    fn chained_differences_with_anchored_shapes_and_thin_slab_cutter_succeeds() {
2301        let mut model = M::new();
2302        let block = cube(&mut model, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
2303        let [x, y, z] = [-0.5000000000000001, 0.5000000000000002, 0.5000000000000001];
2304        let second = cube(&mut model, [x - 0.5, y - 0.5, z - 0.5], [1.0, 1.0, 1.0]);
2305        let center = [0.5000000000000003, 0.5000000000000002, 0.5000000000000001];
2306        let ball = sphere(&mut model, center, 0.5);
2307        // Only success is asserted, as when this was captured: the full
2308        // `validate` that `op` runs finds an edge of the second result
2309        // crossing a face unsplit, which this regression never covered.
2310        let difference = |model: &mut M, a, b| {
2311            boolean(
2312                model,
2313                &namer(),
2314                a,
2315                b,
2316                BooleanOp::Difference,
2317                RemeshParams::default(),
2318            )
2319            .unwrap()
2320            .expect("the result is not empty")
2321        };
2322        let cut = difference(&mut model, block, second);
2323        let cut = difference(&mut model, cut, ball);
2324        let slab = cube(&mut model, [-1.13, -0.10, -0.15], [2.25, 0.20, 0.30]);
2325        difference(&mut model, cut, slab);
2326        model.check_names().unwrap();
2327    }
2328
2329    /// Regression test: a cylinder drilled through a cube, its height
2330    /// chosen so *both* caps sit exactly flush with the cube's own
2331    /// opposite faces (`cylinder z in [-0.5, 0.5]` exactly matching the
2332    /// cube's), rather than the usual "drilled hole" test scenes (e.g.
2333    /// `box_cylinder_drilled_hole_through`) where the cylinder
2334    /// deliberately extends *past* both faces.
2335    ///
2336    /// That exact-coplanar-cap case used to (1) take ~30s — over 1000x the
2337    /// sub-second time every other boolean test in this file takes — and
2338    /// (2) produce a *wrong* result, cutting the hole through only one of
2339    /// the two coplanar-cap faces and leaving the other fully solid.
2340    ///
2341    /// Root cause: `revolve_at_oriented` builds any flat cap (a
2342    /// doubly-curved pole, like a sphere's, genuinely needs a fan of
2343    /// wedges meeting at a center vertex — a flat cap doesn't, but got the
2344    /// same treatment) as 4 wedges with radial "spoke" edges from rim to
2345    /// center. `find_coincident_pair` (`remesh_edges_x_faces.rs`) couldn't
2346    /// tell those spokes apart from the genuine circular rim boundary, so
2347    /// it tried imprinting all of them — the slowdown, and (via
2348    /// interleaved imprints across the two caps corrupting
2349    /// `Model::splice_edge_into_face`'s face-splitting bookkeeping) the
2350    /// wrong result. Fixed by `seam_plane`/`faces_are_coplanar`
2351    /// there: an edge whose two neighboring faces (within its own solid)
2352    /// are already coplanar with each other is never the only carrier of a
2353    /// genuine coincidence, so it's skipped — cutting straight to the rim
2354    /// arcs.
2355    #[test]
2356    fn cube_minus_z_cylinder_with_coplanar_cap_is_fast_and_correct() {
2357        let mut model = M::new();
2358        let a = cube(&mut model, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
2359        let b = cylinder(&mut model, [0.0, 0.0, -0.5], 0.3, 1.0, Axis::Z);
2360        let params = RemeshParams::default();
2361
2362        let t0 = std::time::Instant::now();
2363        let result = boolean(&mut model, &namer(), a, b, BooleanOp::Difference, params)
2364            .unwrap()
2365            .unwrap();
2366        let elapsed = t0.elapsed();
2367        let topology = model.topology();
2368        // Ballpark-matches `difference_of_box_and_cylinder_is_valid`'s own
2369        // (non-coincident-cap) ~2s in isolation; running inside the full
2370        // suite under CPU contention from other parallel tests has been
2371        // observed up to ~12s. The threshold stays generous — the point is
2372        // catching a regression back toward the old ~32s (a *further*
2373        // 1000x-ish blowup on top of ordinary parallel-run noise), not
2374        // pinning down exact timing.
2375        assert!(
2376            elapsed.as_secs() < 20,
2377            "boolean took {elapsed:?}, expected well under 20s"
2378        );
2379
2380        // Every point on both cap planes' hole boundary must be *outside*
2381        // the result (the cylinder's full bore is open at both ends) —
2382        // catches "hole only cut on one end" directly, unlike
2383        // `validate_fast`.
2384        let shell = topology.get_solid(result).unwrap().shells[0];
2385        for z in [-0.45, 0.45] {
2386            let p = v3(0.0, 0.0, z);
2387            let outside = matches!(
2388                geop_core_topology::contains::shell::shell_contains(
2389                    topology,
2390                    shell,
2391                    p,
2392                    params.max_nodes,
2393                    params.curve_curve_min_subdivision_size,
2394                    0xC7D1
2395                )
2396                .unwrap(),
2397                geop_core_topology::contains::shell::PointClassification::Outside
2398            );
2399            assert!(
2400                outside,
2401                "point {p:?} (near a cap's bore) should be outside the drilled result"
2402            );
2403        }
2404
2405        let scene = geop_core_rasterize::rasterize(topology, 8)
2406            .unwrap()
2407            .scene(|_| geop_core_rasterize::debug::Color10::Blue);
2408        std::fs::create_dir_all("outputs").unwrap();
2409        scene
2410            .save_to_file("outputs/cube_minus_coplanar_cap_cylinder.html")
2411            .unwrap();
2412    }
2413
2414    /// A bore through a block must actually be empty in the rendered mesh:
2415    /// no triangle may cover the hole. End-to-end cover for the trimming
2416    /// that `geop_core_rasterize::clip` does per grid cell — where a
2417    /// duplicated vertex in a clipped hole outline, or a concave fragment
2418    /// classified as a whole, used to leave a flap of surface hanging
2419    /// across an opening (isolated in that module's own tests).
2420    #[test]
2421    fn bore_renders_as_a_hole() {
2422        let mut model = M::new();
2423        let block = cube(&mut model, [-1.0, -1.0, 0.0], [2.0, 2.0, 0.5]);
2424        // Radius 0.5 of the 2x2 footprint puts the bore's outline exactly
2425        // through grid cell corners at this resolution, which is what it
2426        // takes to produce the duplicated vertex.
2427        let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.5, Axis::Z);
2428        op(&mut model, block, bore, BooleanOp::Difference);
2429
2430        let rasterized = geop_core_rasterize::rasterize(model.topology(), 24).unwrap();
2431        for triangle in rasterized.faces.values().flatten() {
2432            for p in [triangle.a, triangle.b, triangle.c] {
2433                let (x, y, z) = (p[0].to_f64(), p[1].to_f64(), p[2].to_f64());
2434                let r = x.hypot(y);
2435                // Strictly inside the bore, and within the block's height:
2436                // nothing may be drawn there. The margin keeps the bore
2437                // wall's own triangles (at r = 0.6, faceted slightly inwards)
2438                // out of the test.
2439                assert!(
2440                    r > 0.45 || !(0.01..0.49).contains(&z),
2441                    "a triangle corner sits inside the bore at ({x}, {y}, {z})"
2442                );
2443            }
2444        }
2445    }
2446
2447    /// A box cut into the top of a cube, its corners known only to within
2448    /// 1e-12 — as a tool computed from a solid's own (interval) vertices
2449    /// is: a lip's groove offset from a shelled enclosure's rim. The same
2450    /// box with sharp corners was always cut cleanly.
2451    ///
2452    /// The march fed each corrector step the previous step's honest
2453    /// `(u, v)` as its first seed, unsharpened, so the width compounded
2454    /// step after step: from the corners' 1e-12 to a whole patch within a
2455    /// dozen steps, until the corrector no longer converged (see
2456    /// `predictor_corrector_step`).
2457    #[test]
2458    fn cube_minus_box_with_wide_corners() {
2459        let wide = |x: f64| Scalar::new(x - 1e-12, x + 1e-12);
2460        let corner = |p: [f64; 3]| Vector3::from_array(p.map(wide));
2461        let mut model = M::new();
2462        let block = cube(&mut model, [0.0, 0.0, 0.0], [2.0, 2.0, 1.0]);
2463        let tool = geop_lib_sweep::shapes::cube_solid(
2464            &mut model,
2465            &fresh_id(),
2466            corner([1.72, 0.1, 0.88]),
2467            corner([1.9, 1.9, 1.08]),
2468        )
2469        .unwrap();
2470        op(&mut model, block, tool, BooleanOp::Difference);
2471    }
2472}