Skip to main content

fast_mesh_ops/
boolean.rs

1//! Boolean operations (union, subtract, intersect) between two closed meshes.
2//!
3//! [`boolean_plan`] splits every triangle of mesh A that mesh B crosses (and
4//! vice versa) so that each piece lies strictly on one side of the other
5//! mesh, and classifies the pieces; [`apply_boolean`] writes the plan into
6//! A's transaction. The result stays on A with A's placement, like a cut
7//! that keeps the tool: for world-space booleans the caller maps B into A's
8//! frame first (see `fast-mesh-scene`).
9//!
10//! Inputs must be closed, manifold, consistently wound, and free of
11//! degenerate, duplicate and self-intersecting triangles; anything else is
12//! [`OpsError::InvalidInput`]. Touches at a point or along an edge are fine,
13//! and so are coplanar area overlaps (flush faces): a piece whose centroid
14//! lies on the tool surface is decided by the surface just outside it.
15//! Disjoint inputs need no splitting (union concatenates, intersect is
16//! empty, subtract keeps A); nested inputs classify whole.
17//!
18//! Each side is split independently against the other's surface, so the two
19//! rims along the intersection curve generally carry different vertices.
20//! The apply step stitches them without any winding assumption: every rim
21//! edge is split at the other rim's nearby vertices (reusing their ids), and
22//! a final [`weld`] merges what is left within tolerance. The output is
23//! closed when the inputs are; point and edge touches leave pinch vertices.
24//!
25//! New vertices take channel defaults; only positions and triangles cross
26//! over. Tolerances scale with the inputs (`1e-6` of the coordinate
27//! magnitude); meshes with features near that scale may weld together.
28//!
29//! [`weld`]: crate::weld
30
31use std::collections::{BTreeMap, BTreeSet};
32
33use fast_mesh_bvh::{BuildOptions, Bvh};
34use fast_mesh_query::{
35    ClosestOptions, Cull, PairSegment, Ray, RayOptions, Rigid, closest_point,
36    intersecting_segments, raycast_all,
37};
38use fast_mesh_store::{Mesh, StoreError, Transaction, TriangleId, UV_CHANNEL, VertexId};
39
40use crate::analyze::analyze;
41use crate::{OpsError, Result, push, reserve};
42
43/// Which boolean to compute.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BooleanOp {
46    /// Whatever is in either mesh.
47    Union,
48    /// Whatever is in A but not in B.
49    Subtract,
50    /// Whatever is in both meshes.
51    Intersect,
52}
53
54/// What [`apply_boolean`] did.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct BooleanReport {
57    /// Alive triangles of the result.
58    pub kept_triangles: usize,
59    /// Triangles added (split pieces and the tool's kept pieces), minus the
60    /// ones the cleanup merged or removed again.
61    pub triangles_added: usize,
62    /// Mesh A's UVs were dropped: the result mixes split pieces and the tool's
63    /// surface, which have no UVs in A's space. Undo restores them.
64    pub uvs_dropped: bool,
65}
66
67/// The plan of [`boolean_plan`]: what to drop, rewrite and add on mesh A.
68/// Built read-only from both meshes; [`apply_boolean`] writes it.
69#[derive(Debug, Clone, Default)]
70pub struct BooleanPlan {
71    drop_a: Vec<TriangleId>,
72    rewrite: Vec<(TriangleId, Vec<[PRef; 3]>)>,
73    fresh: Vec<[f32; 3]>,
74    tool: Vec<[PRef; 3]>,
75    tol: f32,
76}
77
78/// A vertex of a planned triangle: a live vertex of A or a fresh position.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80enum PRef {
81    Live(VertexId),
82    Fresh(u32),
83}
84
85fn oom() -> OpsError {
86    OpsError::Store(StoreError::OutOfMemory)
87}
88
89type F3 = [f32; 3];
90type D3 = [f64; 3];
91
92fn sub(a: D3, b: D3) -> D3 {
93    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
94}
95
96fn cross(a: D3, b: D3) -> D3 {
97    [
98        a[1] * b[2] - a[2] * b[1],
99        a[2] * b[0] - a[0] * b[2],
100        a[0] * b[1] - a[1] * b[0],
101    ]
102}
103
104fn dot(a: D3, b: D3) -> f64 {
105    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
106}
107
108fn area2(a: D3, b: D3, c: D3) -> f64 {
109    let n = cross(sub(b, a), sub(c, a));
110    dot(n, n)
111}
112
113fn max_edge2(t: &[D3; 3]) -> f64 {
114    let mut m = 0.0f64;
115    for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
116        let d = sub(b, a);
117        m = m.max(dot(d, d));
118    }
119    m
120}
121
122/// A piece smaller than this times its longest edge squared is dropped. The
123/// same rule as [`remove_degenerate`](crate::remove_degenerate)'s default.
124const AREA_EPSILON: f64 = 1e-12;
125
126/// Plans the boolean `op` of A with B (B placed in A's frame by `xf`): the
127/// result replaces A. Reads only; [`apply_boolean`] writes the plan.
128///
129/// `bvh_a` and `bvh_b` must be current for `a` and `b` (the plan queries
130/// them for segments, parity and closest points).
131///
132/// # Errors
133/// - [`OpsError::InvalidInput`] when an input is not closed, manifold,
134///   consistently wound or free of degenerate, duplicate and
135///   self-intersecting triangles, when a mapped tool position leaves `f32`
136///   range, or when an intersection is too complex (colinearly overlapping
137///   segments that meet in no endpoint).
138/// - [`OpsError::Query`] or [`OpsError::Store`] from the queries and the
139///   temporary builds. Nothing is written: the plan owns every allocation.
140pub fn boolean_plan(
141    a: &Mesh,
142    bvh_a: &Bvh,
143    b: &Mesh,
144    bvh_b: &Bvh,
145    xf: &Rigid,
146    op: BooleanOp,
147) -> Result<BooleanPlan> {
148    check_input(a, bvh_a)?;
149    check_input(b, bvh_b)?;
150    if std::ptr::eq(a, b) {
151        return Err(OpsError::InvalidInput("cannot combine a mesh with itself"));
152    }
153    // The tool in A's frame, slot for slot (ids only matter for queries):
154    // positions mapped through `xf`, triangles untouched.
155    let tool = map_tool(b, xf)?;
156    let tool_bvh = Bvh::build(&tool, &BuildOptions::default()).map_err(|e| match e {
157        fast_mesh_bvh::BvhError::OutOfMemory => OpsError::Store(StoreError::OutOfMemory),
158        // A fresh build from the mapped mesh cannot mismatch; anything else
159        // is a bug, reported without a dedicated variant.
160        _ => OpsError::InvalidInput("boolean tool BVH failed to build"),
161    })?;
162    let tol = tolerance(a, &tool);
163    let mut plan = BooleanPlan {
164        tol,
165        ..BooleanPlan::default()
166    };
167    let mut fresh_of: BTreeMap<[u32; 3], u32> = BTreeMap::new();
168    let a_tool = ToolRef {
169        mesh: &tool,
170        bvh: &tool_bvh,
171    };
172    let a_live = ToolRef {
173        mesh: a,
174        bvh: bvh_a,
175    };
176    let pieces_a = split_side(a, bvh_a, &Side::A, &a_tool, &mut plan, &mut fresh_of)?;
177    let pieces_b = split_side(
178        &tool,
179        &tool_bvh,
180        &Side::B,
181        &a_live,
182        &mut plan,
183        &mut fresh_of,
184    )?;
185    classify_side(
186        a,
187        &pieces_a,
188        &Side::A,
189        &a_tool,
190        op,
191        true,
192        &mut plan,
193        &mut fresh_of,
194    )?;
195    classify_side(
196        &tool,
197        &pieces_b,
198        &Side::B,
199        &a_live,
200        op,
201        false,
202        &mut plan,
203        &mut fresh_of,
204    )?;
205    Ok(plan)
206}
207
208/// Applies `plan` to A's transaction: drops, rewrites and adds triangles,
209/// stitches the two rims, welds and cleans up. A failure rolls the
210/// transaction back, so the call stays atomic through
211/// [`Scene::edit`](https://docs.rs/fast-mesh-scene)-style callers.
212pub fn apply_boolean(tx: &mut Transaction, plan: &BooleanPlan) -> Result<BooleanReport> {
213    let uvs_dropped = tx.mesh().uvs().is_some();
214    if uvs_dropped {
215        tx.drop_channel(UV_CHANNEL)?;
216    }
217    if !plan.drop_a.is_empty() {
218        tx.remove_triangles(&plan.drop_a)?;
219    }
220    let fresh = if plan.fresh.is_empty() {
221        Vec::new()
222    } else {
223        tx.add_vertices(&plan.fresh)?
224    };
225    let at = |r: PRef| match r {
226        PRef::Live(v) => v,
227        PRef::Fresh(i) => fresh[i as usize],
228    };
229    let mut added = 0usize;
230    for (t, pieces) in &plan.rewrite {
231        // `pieces` is never empty (a rewritten triangle keeps a piece).
232        let tris: Vec<[VertexId; 3]> = pieces.iter().map(|p| p.map(at)).collect();
233        tx.set_triangles(&[*t], &[tris[0]])?;
234        if tris.len() > 1 {
235            tx.add_triangles(&tris[1..])?;
236            added += tris.len() - 1;
237        }
238    }
239    let mut tool_ids = Vec::new();
240    if !plan.tool.is_empty() {
241        let tris: Vec<[VertexId; 3]> = plan.tool.iter().map(|p| p.map(at)).collect();
242        tool_ids = tx.add_triangles(&tris)?;
243        added += tris.len();
244    }
245    added += stitch(tx, &tool_ids, plan.tol)?;
246    let welded = crate::weld(tx, plan.tol)?;
247    let cleaned = crate::remove_degenerate(tx, &crate::DegenerateOptions::default())?;
248    added = added.saturating_sub(welded.removed_triangles + cleaned.removed_triangles);
249    Ok(BooleanReport {
250        kept_triangles: tx.mesh().triangle_count(),
251        triangles_added: added,
252        uvs_dropped,
253    })
254}
255
256/// The tolerance derived from the coordinate magnitude both meshes live at
257/// (origin-relative `f32`): it merges seam duplicates from different
258/// rounding paths while keeping test-scale features apart.
259fn tolerance(a: &Mesh, tool: &Mesh) -> f32 {
260    let mut m = 1.0f32;
261    for mesh in [a, tool] {
262        for p in mesh.positions() {
263            for c in p {
264                m = m.max(c.abs());
265            }
266        }
267    }
268    (1e-6 * m).max(f32::MIN_POSITIVE)
269}
270
271/// Rejects inputs no boolean can consume.
272fn check_input(mesh: &Mesh, bvh: &Bvh) -> Result<()> {
273    let options = crate::AnalyzeOptions {
274        weld_tolerance: 0.0,
275        self_intersection: Some(bvh),
276        max_listed: 1,
277    };
278    let r = analyze(mesh, &options)?;
279    if !r.loops.is_empty() {
280        return Err(OpsError::InvalidInput("boolean needs closed meshes"));
281    }
282    if r.non_manifold_edges > 0 || !r.pinch_vertices.is_empty() {
283        return Err(OpsError::InvalidInput("boolean needs manifold meshes"));
284    }
285    if r.degenerate > 0 {
286        return Err(OpsError::InvalidInput(
287            "boolean input has degenerate triangles",
288        ));
289    }
290    if r.duplicate > 0 {
291        return Err(OpsError::InvalidInput(
292            "boolean input has duplicate triangles",
293        ));
294    }
295    if r.orientation_conflicts > 0 {
296        return Err(OpsError::InvalidInput(
297            "boolean input is inconsistently wound",
298        ));
299    }
300    if r.self_intersects == Some(true) {
301        return Err(OpsError::InvalidInput("boolean input self-intersects"));
302    }
303    Ok(())
304}
305
306/// Mesh B mapped into A's frame, slot for slot.
307fn map_tool(b: &Mesh, xf: &Rigid) -> Result<Mesh> {
308    let mut positions: Vec<f32> = Vec::new();
309    reserve(&mut positions, b.vertex_slots() * 3)?;
310    for p in b.positions() {
311        let q = xf.apply(*p);
312        let f = [q[0] as f32, q[1] as f32, q[2] as f32];
313        if !f.iter().all(|c| c.is_finite()) {
314            return Err(OpsError::InvalidInput("boolean tool leaves f32 range"));
315        }
316        positions.extend_from_slice(&f);
317    }
318    let mut indices: Vec<u32> = Vec::new();
319    reserve(&mut indices, b.triangle_slots() * 3)?;
320    for t in b.triangles() {
321        indices.extend_from_slice(t);
322    }
323    let mut mapped = Mesh::from_buffers(&positions, &indices)?;
324    // `from_buffers` wakes every slot; put the dead ones back to sleep so
325    // queries see the same triangles as `b`.
326    let dead_tris: Vec<TriangleId> = (0..b.triangle_slots() as TriangleId)
327        .filter(|&t| b.triangle(t).is_none())
328        .collect();
329    let dead_verts: Vec<VertexId> = (0..b.vertex_slots() as VertexId)
330        .filter(|&v| !b.is_vertex_alive(v))
331        .collect();
332    if !dead_tris.is_empty() || !dead_verts.is_empty() {
333        let mut tx = mapped.begin_edit();
334        if !dead_tris.is_empty() {
335            tx.remove_triangles(&dead_tris)?;
336        }
337        if !dead_verts.is_empty() {
338            tx.remove_vertices(&dead_verts)?;
339        }
340        let _ = tx.commit()?;
341    }
342    Ok(mapped)
343}
344
345/// One side of the split.
346#[derive(Clone, Copy, PartialEq, Eq)]
347enum Side {
348    /// Mesh A itself: corners reuse live vertex ids.
349    A,
350    /// The mapped tool: every vertex is fresh.
351    B,
352}
353
354/// The other mesh, already in A's frame, with its BVH.
355#[derive(Clone, Copy)]
356struct ToolRef<'m> {
357    mesh: &'m Mesh,
358    bvh: &'m Bvh,
359}
360
361/// Corner positions of triangle `t` in `f64` (from stored `f32`, so queries
362/// and splitting agree).
363fn tri_pos(mesh: &Mesh, t: TriangleId) -> [D3; 3] {
364    mesh.triangle(t)
365        .expect("split of a live triangle")
366        .map(|v| mesh.position(v).expect("live corner").map(f64::from))
367}
368
369/// A fresh vertex for `p` (exact-`f32` deduplicated across the whole plan).
370fn fresh(p: D3, plan: &mut BooleanPlan, fresh_of: &mut BTreeMap<[u32; 3], u32>) -> Result<PRef> {
371    let f = [p[0] as f32, p[1] as f32, p[2] as f32];
372    if !f.iter().all(|c| c.is_finite()) {
373        return Err(OpsError::InvalidInput("boolean point leaves f32 range"));
374    }
375    let bits = f.map(f32::to_bits);
376    if let Some(&i) = fresh_of.get(&bits) {
377        return Ok(PRef::Fresh(i));
378    }
379    let i = u32::try_from(plan.fresh.len()).map_err(|_| oom())?;
380    reserve(&mut plan.fresh, 1)?;
381    plan.fresh.push(f);
382    fresh_of.insert(bits, i);
383    Ok(PRef::Fresh(i))
384}
385
386/// Distance from `p` to the segment `a..b`, with the projection parameter.
387fn point_seg(p: D3, a: D3, b: D3) -> (f64, f64) {
388    let ab = sub(b, a);
389    let len2 = dot(ab, ab);
390    if len2 == 0.0 {
391        let d = sub(p, a);
392        return (dot(d, d).sqrt(), 0.0);
393    }
394    let t = dot(sub(p, a), ab) / len2;
395    let c = std::array::from_fn(|k| a[k] + t.clamp(0.0, 1.0) * ab[k]);
396    let d = sub(p, c);
397    (dot(d, d).sqrt(), t)
398}
399
400/// The id edges are keyed by.
401fn key(a: VertexId, b: VertexId) -> (VertexId, VertexId) {
402    (a.min(b), b.max(a))
403}
404
405/// A work point: its position and how it materialises.
406#[derive(Debug, Clone, Copy)]
407struct WPt {
408    pos: D3,
409    v: PRef,
410}
411
412/// A convex piece of a triangle being split: its boundary ring, interior
413/// points and segments, all as flat point indices.
414#[derive(Debug, Clone)]
415struct Piece {
416    ring: Vec<usize>,
417    inner: Vec<usize>,
418    segs: Vec<(usize, usize)>,
419}
420
421/// A triangle's corners, crossings and segments, ready to split.
422struct TriWork {
423    corners: [D3; 3],
424    corner_v: [PRef; 3],
425    /// Boundary points per edge (corner `i` to corner `(i+1)%3`), sorted.
426    edge: [Vec<WPt>; 3],
427    /// Interior points.
428    inner: Vec<WPt>,
429    /// Segments as flat point indices (corners, then edge points per edge
430    /// in order, then interior points).
431    segs: Vec<(usize, usize)>,
432}
433
434/// Split pieces of one triangle, and whether any split follows a shared
435/// edge (so the rewrite must materialise even when every piece is kept).
436struct SplitTris {
437    pieces: Vec<[PRef; 3]>,
438    edge_split: bool,
439}
440
441/// Splits every triangle of `mesh` that the tool crosses — and every
442/// triangle with a crossing on a shared edge, so splits never leave
443/// T-junctions within the side — returning the pieces per touched triangle.
444/// Untouched triangles are absent; the caller classifies them whole.
445fn split_side(
446    mesh: &Mesh,
447    bvh: &Bvh,
448    side: &Side,
449    tool: &ToolRef<'_>,
450    plan: &mut BooleanPlan,
451    fresh_of: &mut BTreeMap<[u32; 3], u32>,
452) -> Result<BTreeMap<TriangleId, SplitTris>> {
453    let mut out: BTreeMap<TriangleId, SplitTris> = BTreeMap::new();
454    let mut segs: Vec<PairSegment> = Vec::new();
455    intersecting_segments(mesh, bvh, tool.mesh, tool.bvh, &Rigid::IDENTITY, &mut segs)?;
456    if segs.is_empty() {
457        return Ok(out);
458    }
459    // Group the segments by triangle of this side, deduplicated. The mesh
460    // being split is always the first mesh of the pair query, so `s.a`
461    // (with endpoints already in its frame) is its triangle in both cases.
462    let mut by_tri: BTreeMap<TriangleId, Vec<(F3, F3)>> = BTreeMap::new();
463    for s in &segs {
464        let (t, from, to) = (s.a, s.from, s.to);
465        if mesh.triangle(t).is_none() {
466            continue;
467        }
468        let e = by_tri.entry(t).or_default();
469        if !e
470            .iter()
471            .any(|&(a, b)| (a == from && b == to) || (a == to && b == from))
472        {
473            push(e, (from, to))?;
474        }
475    }
476    // Crossing parameters per mesh edge, shared by both adjacent
477    // triangles, so splits never leave T-junctions within the side. Each
478    // entry is `(t from the smaller id, fresh index)`.
479    let mut edge_pts: BTreeMap<(VertexId, VertexId), Vec<(f64, u32)>> = BTreeMap::new();
480    // Pass 1: locate every segment's endpoints, filling the shared edge
481    // map. Pass 2 then assembles and splits every triangle with a segment
482    // or a shared edge crossing (edge-only triangles still need their ring
483    // split, or the splits would leave T-junctions within the side). Rings
484    // assemble from the complete map only, so no triangle misses a
485    // neighbour's crossing.
486    let mut ends: BTreeMap<TriangleId, Vec<(PtLoc, PtLoc)>> = BTreeMap::new();
487    for (t, ss) in &by_tri {
488        let e = locate_ends(mesh, side, *t, ss, &mut edge_pts, plan, fresh_of)?;
489        ends.insert(*t, e);
490    }
491    let mut todo: BTreeSet<TriangleId> = by_tri.keys().copied().collect();
492    for t in 0..mesh.triangle_slots() as TriangleId {
493        if mesh.triangle(t).is_none() {
494            continue;
495        }
496        let tri = mesh.triangle(t).expect("live");
497        let touched = [0, 1, 2].iter().any(|&i| {
498            edge_pts
499                .get(&key(tri[i], tri[(i + 1) % 3]))
500                .is_some_and(|l| !l.is_empty())
501        });
502        // Triangles touched only through a shared edge have no segments of
503        // their own.
504        if touched {
505            todo.insert(t);
506        }
507    }
508    for t in todo {
509        let e = ends.remove(&t).unwrap_or_default();
510        let work = assemble(mesh, side, t, &e, &edge_pts, plan, fresh_of)?;
511        let edge_split = work.edge.iter().any(|e| !e.is_empty());
512        let tris = split_triangle(work, plan, fresh_of)?;
513        if !tris.is_empty() {
514            out.insert(
515                t,
516                SplitTris {
517                    pieces: tris,
518                    edge_split,
519                },
520            );
521        }
522    }
523    Ok(out)
524}
525
526/// A located segment endpoint: a corner, an edge point or an interior one.
527#[derive(Debug, Clone, Copy)]
528enum PtLoc {
529    Corner(usize),
530    Edge {
531        edge: usize,
532        t: f64,
533        pos: D3,
534        v: PRef,
535    },
536    Inner {
537        pos: D3,
538        v: PRef,
539    },
540}
541
542/// Locates every segment endpoint of triangle `t` (corner, edge or
543/// interior), filing edge crossings in the side's shared edge map. Runs for
544/// every segment triangle before any ring assembles, so no triangle misses
545/// a neighbour's crossing.
546#[allow(clippy::too_many_arguments)]
547fn locate_ends(
548    mesh: &Mesh,
549    _side: &Side,
550    t: TriangleId,
551    ss: &[(F3, F3)],
552    edge_pts: &mut BTreeMap<(VertexId, VertexId), Vec<(f64, u32)>>,
553    plan: &mut BooleanPlan,
554    fresh_of: &mut BTreeMap<[u32; 3], u32>,
555) -> Result<Vec<(PtLoc, PtLoc)>> {
556    let corners = tri_pos(mesh, t);
557    let c32: [F3; 3] = corners.map(|p| p.map(|c| c as f32));
558    let elen: [f64; 3] = std::array::from_fn(|i| {
559        let d = sub(corners[(i + 1) % 3], corners[i]);
560        dot(d, d).sqrt()
561    });
562    let etol = |i: usize| 1e-6 * elen[i].max(1e-12);
563    let mut ends: Vec<(PtLoc, PtLoc)> = Vec::new();
564    for (from, to) in ss {
565        let mut pair: Vec<PtLoc> = Vec::new();
566        for e in [from, to] {
567            if let Some(c) = c32.iter().position(|c| *c == *e) {
568                pair.push(PtLoc::Corner(c));
569                continue;
570            }
571            let p = e.map(f64::from);
572            // Snap to a corner first (rounding may miss the exact match).
573            let mut corner = None;
574            for (c, cp) in corners.iter().enumerate() {
575                let d = sub(p, *cp);
576                if dot(d, d).sqrt() <= etol(c).max(etol((c + 2) % 3)) {
577                    corner = Some(c);
578                    break;
579                }
580            }
581            if let Some(c) = corner {
582                pair.push(PtLoc::Corner(c));
583                continue;
584            }
585            // Nearest edge within tolerance, else interior.
586            let mut best: Option<(usize, f64)> = None;
587            for (i, (a, b)) in [
588                (corners[0], corners[1]),
589                (corners[1], corners[2]),
590                (corners[2], corners[0]),
591            ]
592            .into_iter()
593            .enumerate()
594            {
595                let (d, tpar) = point_seg(p, a, b);
596                if d <= etol(i) && best.is_none_or(|(_, bt)| tpar < bt) {
597                    best = Some((i, tpar));
598                }
599            }
600            if let Some((i, tpar)) = best {
601                let tri = mesh.triangle(t).expect("live");
602                let ekey = key(tri[i], tri[(i + 1) % 3]);
603                let tkey = if tri[i] < tri[(i + 1) % 3] {
604                    tpar
605                } else {
606                    1.0 - tpar
607                };
608                let list = edge_pts.entry(ekey).or_default();
609                let mut found = None;
610                for &(pt, v) in list.iter() {
611                    if (pt - tkey).abs() <= 1e-9 {
612                        found = Some(v);
613                        break;
614                    }
615                }
616                let v = match found {
617                    Some(v) => PRef::Fresh(v),
618                    None => {
619                        let v = fresh(p, plan, fresh_of)?;
620                        let PRef::Fresh(idx) = v else {
621                            unreachable!("fresh is fresh")
622                        };
623                        push(list, (tkey, idx))?;
624                        // Keep parameters sorted for determinism.
625                        list.sort_by(|x, y| x.0.total_cmp(&y.0));
626                        v
627                    }
628                };
629                pair.push(PtLoc::Edge {
630                    edge: i,
631                    t: tpar,
632                    pos: p,
633                    v,
634                });
635                continue;
636            }
637            let v = fresh(p, plan, fresh_of)?;
638            pair.push(PtLoc::Inner { pos: p, v });
639        }
640        push(&mut ends, (pair[0], pair[1]))?;
641    }
642    Ok(ends)
643}
644
645/// Assembles triangle `t`'s work from its located ends and the complete
646/// shared edge map: corners, edge points in ring order, interior points.
647#[allow(clippy::too_many_arguments)]
648fn assemble(
649    mesh: &Mesh,
650    side: &Side,
651    t: TriangleId,
652    ends: &[(PtLoc, PtLoc)],
653    edge_pts: &BTreeMap<(VertexId, VertexId), Vec<(f64, u32)>>,
654    plan: &mut BooleanPlan,
655    fresh_of: &mut BTreeMap<[u32; 3], u32>,
656) -> Result<TriWork> {
657    let corners = tri_pos(mesh, t);
658    let corner_v: [PRef; 3] = match side {
659        Side::A => mesh.triangle(t).expect("live").map(PRef::Live),
660        Side::B => {
661            let mut v = [PRef::Fresh(0); 3];
662            for (k, p) in corners.iter().enumerate() {
663                v[k] = fresh(*p, plan, fresh_of)?;
664            }
665            v
666        }
667    };
668    // Flat points: corners, then edge points in ring order, then interior.
669    let mut pts: Vec<WPt> = Vec::new();
670    for (k, p) in corners.iter().enumerate() {
671        push(
672            &mut pts,
673            WPt {
674                pos: *p,
675                v: corner_v[k],
676            },
677        )?;
678    }
679    let tri = mesh.triangle(t).expect("live");
680    let mut edge_idx: [Vec<usize>; 3] = [Vec::new(), Vec::new(), Vec::new()];
681    let edge_frames = [
682        (0, corners[0], corners[1], tri[0], tri[1]),
683        (1, corners[1], corners[2], tri[1], tri[2]),
684        (2, corners[2], corners[0], tri[2], tri[0]),
685    ];
686    for ((i, c0, c1, t0, t1), edge) in edge_frames.into_iter().zip(edge_idx.iter_mut()) {
687        // This triangle's edge points on edge `i`: its own plus the ones
688        // the neighbour filed (they split this edge too).
689        let mut ordered: Vec<(f64, WPt)> = Vec::new();
690        for loc in ends.iter().flat_map(|(a, b)| [*a, *b]) {
691            if let PtLoc::Edge { edge, t, pos, v } = loc
692                && edge == i
693                && !ordered.iter().any(|&(_, w): &(f64, WPt)| w.v == v)
694            {
695                ordered.push((t, WPt { pos, v }));
696            }
697        }
698        let ekey = key(t0, t1);
699        if let Some(list) = edge_pts.get(&ekey) {
700            let axis_len = dot(sub(c1, c0), sub(c1, c0)).sqrt().max(1e-12);
701            for &(_, idx) in list {
702                let v = PRef::Fresh(idx);
703                if ordered.iter().any(|&(_, w): &(f64, WPt)| w.v == v) {
704                    continue;
705                }
706                let pos = plan.fresh[idx as usize].map(f64::from);
707                let (d, tpar) = point_seg(pos, c0, c1);
708                if d <= 1e-6 * axis_len {
709                    ordered.push((tpar, WPt { pos, v }));
710                }
711            }
712        }
713        ordered.sort_by(|x, y| x.0.total_cmp(&y.0));
714        for (_, w) in ordered {
715            edge.push(pts.len());
716            push(&mut pts, w)?;
717        }
718    }
719    // Interior points (deduplicated by vertex).
720    let mut inner_idx: Vec<usize> = Vec::new();
721    for loc in ends.iter().flat_map(|(a, b)| [*a, *b]) {
722        if let PtLoc::Inner { pos, v } = loc
723            && !pts.iter().any(|w| w.v == v)
724        {
725            inner_idx.push(pts.len());
726            push(&mut pts, WPt { pos, v })?;
727        }
728    }
729    // Segments as flat indices.
730    let mut segs: Vec<(usize, usize)> = Vec::new();
731    for (a, b) in ends {
732        let (ia, ib) = (loc_index(*a, &pts), loc_index(*b, &pts));
733        if ia != ib {
734            push(&mut segs, (ia.min(ib), ia.max(ib)))?;
735        }
736    }
737    segs.sort_unstable();
738    segs.dedup();
739    // The ring is implicit: corners + edge points in edge order.
740    let mut edge_out: [Vec<WPt>; 3] = [Vec::new(), Vec::new(), Vec::new()];
741    for (src, dst) in edge_idx.iter().zip(edge_out.iter_mut()) {
742        for &at in src {
743            push(dst, pts[at])?;
744        }
745    }
746    let mut inner_out: Vec<WPt> = Vec::new();
747    for &at in &inner_idx {
748        push(&mut inner_out, pts[at])?;
749    }
750    Ok(TriWork {
751        corners,
752        corner_v,
753        edge: edge_out,
754        inner: inner_out,
755        segs,
756    })
757}
758
759/// Flat index of a located endpoint (always present: every endpoint was
760/// added to `pts` above).
761fn loc_index(loc: PtLoc, pts: &[WPt]) -> usize {
762    match loc {
763        PtLoc::Corner(c) => c,
764        PtLoc::Edge { v, .. } | PtLoc::Inner { v, .. } => {
765            pts.iter().position(|w| w.v == v).expect("located point")
766        }
767    }
768}
769
770/// Splits the work triangle into convex pieces and emits non-degenerate
771/// triangles with the parent's winding.
772fn split_triangle(
773    work: TriWork,
774    plan: &mut BooleanPlan,
775    fresh_of: &mut BTreeMap<[u32; 3], u32>,
776) -> Result<Vec<[PRef; 3]>> {
777    // Flat points, exactly as `locate` laid them out: corners, then edge
778    // points per edge in order, then interior points. Segments are already
779    // flat indices into this layout.
780    let mut pts: Vec<WPt> = Vec::new();
781    for (k, p) in work.corners.iter().enumerate() {
782        push(
783            &mut pts,
784            WPt {
785                pos: *p,
786                v: work.corner_v[k],
787            },
788        )?;
789    }
790    let mut edge_idx: [Vec<usize>; 3] = [Vec::new(), Vec::new(), Vec::new()];
791    for (src, dst) in work.edge.iter().zip(edge_idx.iter_mut()) {
792        for w in src {
793            dst.push(pts.len());
794            push(&mut pts, *w)?;
795        }
796    }
797    let mut inner_idx: Vec<usize> = Vec::new();
798    for w in &work.inner {
799        inner_idx.push(pts.len());
800        push(&mut pts, *w)?;
801    }
802    let mut segs: Vec<(usize, usize)> = Vec::new();
803    for &(a, b) in &work.segs {
804        if a != b {
805            push(&mut segs, (a.min(b), a.max(b)))?;
806        }
807    }
808    segs.sort_unstable();
809    segs.dedup();
810    // The 2D frame: the triangle with its dominant axis dropped.
811    let n = cross(
812        sub(work.corners[1], work.corners[0]),
813        sub(work.corners[2], work.corners[0]),
814    );
815    let axis = [n[0].abs(), n[1].abs(), n[2].abs()]
816        .iter()
817        .enumerate()
818        .max_by(|x, y| x.1.total_cmp(y.1))
819        .map(|(i, _)| i)
820        .unwrap_or(2);
821    let flat2 = |p: D3| -> [f64; 2] { [p[(axis + 1) % 3], p[(axis + 2) % 3]] };
822    // Split segments at interior crossings and colinear endpoints.
823    split_crossings(&mut pts, &mut segs, &mut inner_idx, flat2, plan, fresh_of)?;
824    // Ring: corners with edge points in order.
825    let mut ring = Vec::new();
826    for (i, edge) in edge_idx.iter().enumerate() {
827        push(&mut ring, i)?;
828        ring.extend_from_slice(edge);
829    }
830    // Interior points created by `split_crossings` are appended to `pts`
831    // and `inner_idx` already; collect any others (none: all interior
832    // points are segment endpoints by construction).
833    let mut pieces = vec![Piece {
834        ring,
835        inner: inner_idx,
836        segs,
837    }];
838    // Split until every segment is a piece edge: full segments split convex
839    // pieces in two, interior endpoints fan their piece first. Progress is
840    // lexicographic (interior points, then segments): a fan consumes an
841    // interior point, a full split consumes a segment.
842    loop {
843        let before: (usize, usize) = pieces.iter().fold((0, 0), |(ni, ns), p| {
844            (ni + p.inner.len(), ns + p.segs.len())
845        });
846        // Drop boundary-hugging segments first: both endpoints on the ring
847        // with a colinear walk between them. They follow a ring edge (a
848        // coplanar edge overlap), so the ring already preserves them;
849        // splitting along them would only make slivers.
850        let mut hugged: Option<Vec<Piece>> = None;
851        for (pi, piece) in pieces.iter().enumerate() {
852            if let Some(seg) = piece
853                .segs
854                .iter()
855                .find(|&&(a, b)| hugs_ring(a, b, piece, &pts, flat2))
856            {
857                let mut out = Vec::new();
858                for (qi, q) in pieces.iter().enumerate() {
859                    if qi == pi {
860                        let mut p = q.clone();
861                        p.segs.retain(|&s| s != *seg);
862                        push(&mut out, p)?;
863                    } else {
864                        push(&mut out, q.clone())?;
865                    }
866                }
867                hugged = Some(out);
868                break;
869            }
870        }
871        if let Some(out) = hugged {
872            pieces = out;
873            continue;
874        }
875        let mut next: Option<Vec<Piece>> = None;
876        for (pi, piece) in pieces.iter().enumerate() {
877            if let Some((p1, p2)) = full_split(piece, &pts, flat2)? {
878                let mut out = Vec::new();
879                for (qi, q) in pieces.iter().enumerate() {
880                    if qi == pi {
881                        push(&mut out, p1.clone())?;
882                        push(&mut out, p2.clone())?;
883                    } else {
884                        push(&mut out, q.clone())?;
885                    }
886                }
887                next = Some(out);
888                break;
889            }
890            if let Some(&q) = piece
891                .inner
892                .iter()
893                .find(|q| piece.segs.iter().any(|&(a, b)| a == **q || b == **q))
894            {
895                let fanned = fan_split(piece, q, &mut pts, flat2, plan, fresh_of)?;
896                let mut out = Vec::new();
897                for (qi, qpiece) in pieces.iter().enumerate() {
898                    if qi == pi {
899                        for f in &fanned {
900                            push(&mut out, f.clone())?;
901                        }
902                    } else {
903                        push(&mut out, qpiece.clone())?;
904                    }
905                }
906                next = Some(out);
907                break;
908            }
909        }
910        match next {
911            Some(out) => pieces = out,
912            None => break,
913        }
914        let after: (usize, usize) = pieces.iter().fold((0, 0), |(ni, ns), p| {
915            (ni + p.inner.len(), ns + p.segs.len())
916        });
917        if after.0 > before.0 || (after.0 == before.0 && after.1 >= before.1) {
918            // No progress: a segment spans pieces (rounding at a shared
919            // boundary). Fail rather than loop or leave a hole.
920            return Err(OpsError::InvalidInput("boolean intersection too complex"));
921        }
922    }
923    // Triangulate each convex piece without losing ring vertices: collapse
924    // colinear runs, ear-clip the corners, then split the collapsed edges
925    // back open. Every ring vertex survives in a non-degenerate triangle.
926    let mut tris: Vec<[PRef; 3]> = Vec::new();
927    for piece in &pieces {
928        if piece.ring.len() < 3 {
929            continue;
930        }
931        // Collapse colinear runs, remembering the dropped points per edge.
932        // A vertex is a corner unless it sits straight between its ring
933        // neighbours (orient2 within 1e-9 relative); runs between
934        // consecutive corners are straight by transitivity.
935        let n = piece.ring.len();
936        // Triples of consecutive ring vertices, wrapping around.
937        let ext: Vec<usize> = piece
938            .ring
939            .iter()
940            .chain(piece.ring.iter().take(2))
941            .copied()
942            .collect();
943        let mut is_corner = vec![true; n];
944        for (k, w) in ext.windows(3).enumerate() {
945            let (a, b, c) = (
946                flat2(pts[w[0]].pos),
947                flat2(pts[w[1]].pos),
948                flat2(pts[w[2]].pos),
949            );
950            if colinear3(a, b, c) {
951                // Window k tests vertex k+1.
952                is_corner[(k + 1) % n] = false;
953            }
954        }
955        let mut corners: Vec<usize> = Vec::new();
956        for (k, &x) in piece.ring.iter().enumerate() {
957            if is_corner[k] {
958                push(&mut corners, x)?;
959            }
960        }
961        if corners.len() < 3 {
962            continue;
963        }
964        let mut runs: BTreeMap<(usize, usize), Vec<usize>> = BTreeMap::new();
965        for (ci, &c) in corners.iter().enumerate() {
966            let cn = corners[(ci + 1) % corners.len()];
967            let mut mid = Vec::new();
968            let mut k = piece.ring.iter().position(|&x| x == c).unwrap();
969            loop {
970                k = (k + 1) % n;
971                if piece.ring[k] == cn {
972                    break;
973                }
974                push(&mut mid, piece.ring[k])?;
975            }
976            if !mid.is_empty() {
977                runs.insert((c, cn), mid);
978            }
979        }
980        // Ear-clip the convex corners (every consecutive triple is a valid
981        // ear): clip from the front until one triangle is left.
982        let mut out: Vec<[usize; 3]> = Vec::new();
983        let mut ring = corners.clone();
984        while ring.len() > 3 {
985            let tri = [ring[ring.len() - 1], ring[0], ring[1]];
986            push(&mut out, tri)?;
987            ring.remove(0);
988        }
989        push(&mut out, [ring[0], ring[1], ring[2]])?;
990        for ((a, b), qs) in &runs {
991            let at = out
992                .iter()
993                .position(|tri| tri.contains(a) && tri.contains(b))
994                .expect("collapsed edge of a live piece");
995            let tri = out.remove(at);
996            split_chain(tri, *a, *b, qs, &mut out)?;
997        }
998        // Emit with PRefs, dropping zero-area pieces.
999        for tri in out {
1000            let t = [pts[tri[0]].v, pts[tri[1]].v, pts[tri[2]].v];
1001            if t[0] == t[1] || t[1] == t[2] || t[0] == t[2] {
1002                continue;
1003            }
1004            let p = [pts[tri[0]].pos, pts[tri[1]].pos, pts[tri[2]].pos];
1005            if area2(p[0], p[1], p[2]) > AREA_EPSILON * max_edge2(&p).max(1e-300) {
1006                push(&mut tris, t)?;
1007            }
1008        }
1009    }
1010    Ok(tris)
1011}
1012
1013/// 2D orientation (twice the signed area).
1014fn orient2(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
1015    (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
1016}
1017
1018/// Whether `b` sits straight between `a` and `c`: orientation within 1e-6
1019/// relative to the edge lengths. Consistent with the stitch and weld
1020/// tolerance (features below `1e-6` of the magnitude merge), so collapsed
1021/// points always come back through the run splits below.
1022fn colinear3(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool {
1023    let o = orient2(a, b, c).abs();
1024    let m = (b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2);
1025    let m = m.max((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2));
1026    o * o <= 1e-12 * m * m.max(1e-300)
1027}
1028
1029/// Where the segments `p..q` and `r..s` cross, if strictly interior to
1030/// both. Colinear cases report `Cross::Colinear`.
1031enum Cross {
1032    Point(f64, f64),
1033    Colinear,
1034    None,
1035}
1036
1037fn seg_cross(p: [f64; 2], q: [f64; 2], r: [f64; 2], s: [f64; 2]) -> Cross {
1038    let d = orient2(p, q, r);
1039    let e = orient2(p, q, s);
1040    let f = orient2(r, s, p);
1041    let g = orient2(r, s, q);
1042    if d == 0.0 && e == 0.0 && f == 0.0 && g == 0.0 {
1043        return Cross::Colinear;
1044    }
1045    if d * e >= 0.0 || f * g >= 0.0 {
1046        return Cross::None;
1047    }
1048    let denom = (q[0] - p[0]) * (s[1] - r[1]) - (q[1] - p[1]) * (s[0] - r[0]);
1049    if denom == 0.0 {
1050        return Cross::None;
1051    }
1052    let t = ((r[0] - p[0]) * (s[1] - r[1]) - (r[1] - p[1]) * (s[0] - r[0])) / denom;
1053    let u = ((r[0] - p[0]) * (q[1] - p[1]) - (r[1] - p[1]) * (q[0] - p[0])) / denom;
1054    if t > 1e-9 && t < 1.0 - 1e-9 && u > 1e-9 && u < 1.0 - 1e-9 {
1055        Cross::Point(t, u)
1056    } else {
1057        Cross::None
1058    }
1059}
1060
1061/// Point `p` strictly inside segment `a..b` (2D): exact orientation with
1062/// a relative distance tolerance.
1063fn on_seg_tol(p: [f64; 2], a: [f64; 2], b: [f64; 2], tol: f64) -> bool {
1064    let ab = [b[0] - a[0], b[1] - a[1]];
1065    let len2 = ab[0] * ab[0] + ab[1] * ab[1];
1066    if len2 == 0.0 {
1067        return false;
1068    }
1069    let t = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / len2;
1070    if t <= 1e-9 || t >= 1.0 - 1e-9 {
1071        return false;
1072    }
1073    let c = [a[0] + t * ab[0], a[1] + t * ab[1]];
1074    let d = (p[0] - c[0]) * (p[0] - c[0]) + (p[1] - c[1]) * (p[1] - c[1]);
1075    d <= tol * tol * len2
1076}
1077
1078/// Point `p` strictly inside segment `a..b` (2D, tight tolerance for
1079/// exactly-computed crossings).
1080fn on_seg(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> bool {
1081    on_seg_tol(p, a, b, 1e-9)
1082}
1083
1084/// Splits every segment at interior crossings and colinear endpoints,
1085/// adding the crossing points (as fresh vertices) to `inner`. Colinear
1086/// partial overlaps fail.
1087#[allow(clippy::too_many_arguments)]
1088fn split_crossings(
1089    pts: &mut Vec<WPt>,
1090    segs: &mut Vec<(usize, usize)>,
1091    inner: &mut Vec<usize>,
1092    flat2: impl Copy + Fn(D3) -> [f64; 2],
1093    plan: &mut BooleanPlan,
1094    fresh_of: &mut BTreeMap<[u32; 3], u32>,
1095) -> Result<()> {
1096    // Each pass resolves one crossing; cap the passes (segments only grow
1097    // by splitting, so a bound far above the input size means rounding is
1098    // fighting the predicates).
1099    let mut passes = segs.len() * 4 + 64;
1100    loop {
1101        if passes == 0 {
1102            return Err(OpsError::InvalidInput("boolean intersection too complex"));
1103        }
1104        passes -= 1;
1105        // Loose pass first: an endpoint within tolerance of a colinear
1106        // segment splits it there. Endpoints from different pairs agree
1107        // only up to rounding, so the exact predicates below would miss the
1108        // touch. Transverse near-misses are left alone (splitting them
1109        // would distort the curve more than rounding does).
1110        let mut snapped: Option<(usize, usize)> = None;
1111        for (i, &(a, b)) in segs.iter().enumerate() {
1112            for (j, &(c, d)) in segs.iter().enumerate().take(i) {
1113                if a == c || a == d || b == c || b == d {
1114                    continue;
1115                }
1116                let (pa, pb) = (flat2(pts[a].pos), flat2(pts[b].pos));
1117                let dab = [(pb[0] - pa[0]), (pb[1] - pa[1])];
1118                let len2 = dab[0] * dab[0] + dab[1] * dab[1];
1119                for (e, (o, oo), si) in [
1120                    (a, (c, d), j),
1121                    (b, (c, d), j),
1122                    (c, (a, b), i),
1123                    (d, (a, b), i),
1124                ] {
1125                    let pe = flat2(pts[e].pos);
1126                    // Colinear within tolerance: the endpoint is on the
1127                    // segment's line.
1128                    let o2 = orient2(pa, pb, pe);
1129                    if o2 * o2 > 1e-12 * len2 * len2 {
1130                        continue;
1131                    }
1132                    if on_seg_tol(pe, flat2(pts[o].pos), flat2(pts[oo].pos), 1e-6) {
1133                        snapped = Some((si, e));
1134                        break;
1135                    }
1136                }
1137                if snapped.is_some() {
1138                    break;
1139                }
1140            }
1141            if snapped.is_some() {
1142                break;
1143            }
1144        }
1145        if let Some((si, e)) = snapped {
1146            let (x, y) = segs[si];
1147            segs[si] = (x.min(e), x.max(e));
1148            push(segs, (e.min(y), e.max(y)))?;
1149            segs.sort_unstable();
1150            segs.dedup();
1151            continue;
1152        }
1153        let mut crossing: Option<(usize, usize, f64)> = None;
1154        let mut colinear: Option<(usize, usize)> = None;
1155        for (i, &(a, b)) in segs.iter().enumerate() {
1156            for (j, &(c, d)) in segs.iter().enumerate().take(i) {
1157                if a == c || a == d || b == c || b == d {
1158                    continue;
1159                }
1160                let (pa, pb, pc, pd) = (
1161                    flat2(pts[a].pos),
1162                    flat2(pts[b].pos),
1163                    flat2(pts[c].pos),
1164                    flat2(pts[d].pos),
1165                );
1166                match seg_cross(pa, pb, pc, pd) {
1167                    Cross::Point(t, _) => {
1168                        crossing = Some((i, j, t));
1169                        break;
1170                    }
1171                    Cross::Colinear => {
1172                        for (e, si) in [(a, j), (b, j), (c, i), (d, i)] {
1173                            let (o, oo) = if si == j { (c, d) } else { (a, b) };
1174                            if on_seg(flat2(pts[e].pos), flat2(pts[o].pos), flat2(pts[oo].pos)) {
1175                                colinear = Some((si, e));
1176                                break;
1177                            }
1178                        }
1179                        if colinear.is_some() {
1180                            break;
1181                        }
1182                        return Err(OpsError::InvalidInput("boolean intersection too complex"));
1183                    }
1184                    Cross::None => {}
1185                }
1186            }
1187            if crossing.is_some() || colinear.is_some() {
1188                break;
1189            }
1190        }
1191        if let Some((si, e)) = colinear {
1192            // An endpoint strictly inside another segment: split there (no
1193            // new point). The endpoint is interior, so both halves are
1194            // sorted pairs.
1195            let (x, y) = segs[si];
1196            segs[si] = (x.min(e), x.max(e));
1197            push(segs, (e.min(y), e.max(y)))?;
1198        } else if let Some((i, j, t)) = crossing {
1199            let (a, b) = segs[i];
1200            let (c, d) = segs[j];
1201            let p = std::array::from_fn(|k| pts[a].pos[k] + t * (pts[b].pos[k] - pts[a].pos[k]));
1202            let v = fresh(p, plan, fresh_of)?;
1203            let at = pts.len();
1204            push(pts, WPt { pos: p, v })?;
1205            push(inner, at)?;
1206            segs[i] = (a.min(at), a.max(at));
1207            push(segs, (at.min(b), at.max(b)))?;
1208            segs[j] = (c.min(at), c.max(at));
1209            push(segs, (at.min(d), at.max(d)))?;
1210        } else {
1211            break;
1212        }
1213        segs.sort_unstable();
1214        segs.dedup();
1215    }
1216    Ok(())
1217}
1218
1219/// Whether segment `(a, b)` hugs the piece's ring: both endpoints on it
1220/// with a colinear walk between them (a coplanar edge overlap, already
1221/// preserved by the ring itself).
1222fn hugs_ring(
1223    a: usize,
1224    b: usize,
1225    piece: &Piece,
1226    pts: &[WPt],
1227    flat2: impl Copy + Fn(D3) -> [f64; 2],
1228) -> bool {
1229    let pa = piece.ring.iter().position(|&x| x == a);
1230    let pb = piece.ring.iter().position(|&x| x == b);
1231    let (Some(ia), Some(ib)) = (pa, pb) else {
1232        return false;
1233    };
1234    if ia == ib {
1235        return true;
1236    }
1237    let (pa, pb) = (flat2(pts[a].pos), flat2(pts[b].pos));
1238    let dab = [(pb[0] - pa[0]), (pb[1] - pa[1])];
1239    let len2 = dab[0] * dab[0] + dab[1] * dab[1];
1240    let tol = 1e-7 * len2.max(1e-300);
1241    let (lo, hi) = if ia < ib { (ia, ib) } else { (ib, ia) };
1242    let colinear = |range: &[usize]| {
1243        range.iter().all(|&x| {
1244            let p = flat2(pts[x].pos);
1245            orient2(pa, pb, p).abs() <= tol
1246        })
1247    };
1248    colinear(&piece.ring[lo + 1..hi])
1249        || colinear(&piece.ring[hi + 1..]) && colinear(&piece.ring[..lo])
1250}
1251
1252/// Splits `piece` along a full segment (both endpoints on its ring) into
1253/// two convex pieces, or `None` when it has none.
1254fn full_split(
1255    piece: &Piece,
1256    pts: &[WPt],
1257    flat2: impl Copy + Fn(D3) -> [f64; 2],
1258) -> Result<Option<(Piece, Piece)>> {
1259    for &(a, b) in &piece.segs {
1260        let pa = piece.ring.iter().position(|&x| x == a);
1261        let pb = piece.ring.iter().position(|&x| x == b);
1262        let (Some(ia), Some(ib)) = (pa, pb) else {
1263            continue;
1264        };
1265        if ia == ib {
1266            continue;
1267        }
1268        // Walk the ring both ways.
1269        let (lo, hi) = if ia < ib { (ia, ib) } else { (ib, ia) };
1270        let r1: Vec<usize> = piece.ring[lo..=hi].to_vec();
1271        let mut r2: Vec<usize> = piece.ring[hi..].to_vec();
1272        r2.extend_from_slice(&piece.ring[..=lo]);
1273        if r1.len() < 3 || r2.len() < 3 {
1274            continue;
1275        }
1276        let mut p1 = Piece {
1277            ring: r1,
1278            inner: Vec::new(),
1279            segs: Vec::new(),
1280        };
1281        let mut p2 = Piece {
1282            ring: r2,
1283            inner: Vec::new(),
1284            segs: Vec::new(),
1285        };
1286        // Distribute the other segments (boundary goes to the first piece)
1287        // and interior points.
1288        for &(c, d) in &piece.segs {
1289            if (c == a && d == b) || (c == b && d == a) {
1290                continue;
1291            }
1292            let in1 = |x: usize| point_in_piece(flat2(pts[x].pos), &p1, pts, flat2);
1293            if in1(c) && in1(d) {
1294                push(&mut p1.segs, (c, d))?;
1295            } else {
1296                push(&mut p2.segs, (c, d))?;
1297            }
1298        }
1299        for &q in &piece.inner {
1300            if point_in_piece(flat2(pts[q].pos), &p1, pts, flat2) {
1301                push(&mut p1.inner, q)?;
1302            } else {
1303                push(&mut p2.inner, q)?;
1304            }
1305        }
1306        return Ok(Some((p1, p2)));
1307    }
1308    Ok(None)
1309}
1310
1311/// Whether `p` is in the (convex) piece, boundary included.
1312fn point_in_piece(
1313    p: [f64; 2],
1314    piece: &Piece,
1315    pts: &[WPt],
1316    flat2: impl Copy + Fn(D3) -> [f64; 2],
1317) -> bool {
1318    let n = piece.ring.len();
1319    if n < 3 {
1320        return false;
1321    }
1322    // Convex fan test from the first vertex, with tolerance.
1323    let p0 = flat2(pts[piece.ring[0]].pos);
1324    for k in 1..n - 1 {
1325        let a = flat2(pts[piece.ring[k]].pos);
1326        let b = flat2(pts[piece.ring[k + 1]].pos);
1327        let o = orient2(p0, a, b);
1328        let inside = if o >= 0.0 {
1329            orient2(p0, a, p) >= -1e-9 && orient2(a, b, p) >= -1e-9 && orient2(b, p0, p) >= -1e-9
1330        } else {
1331            orient2(p0, a, p) <= 1e-9 && orient2(a, b, p) <= 1e-9 && orient2(b, p0, p) <= 1e-9
1332        };
1333        if inside {
1334            return true;
1335        }
1336    }
1337    false
1338}
1339
1340/// Fans the convex `piece` from interior point `q` (a segment endpoint),
1341/// splitting crossing segments at the spokes. Returns the fan triangles as
1342/// pieces with redistributed segments and interior points.
1343#[allow(clippy::too_many_arguments)]
1344fn fan_split(
1345    piece: &Piece,
1346    q: usize,
1347    pts: &mut Vec<WPt>,
1348    flat2: impl Copy + Fn(D3) -> [f64; 2],
1349    plan: &mut BooleanPlan,
1350    fresh_of: &mut BTreeMap<[u32; 3], u32>,
1351) -> Result<Vec<Piece>> {
1352    let n = piece.ring.len();
1353    // New points where segments cross the spokes `q..ring[i]`.
1354    let mut spoke_pts: Vec<Vec<usize>> = vec![Vec::new(); n];
1355    // Collect first, then split: a segment crossing several spokes is cut
1356    // at each of them.
1357    let mut cuts: Vec<(usize, usize, D3)> = Vec::new();
1358    for &(a, b) in &piece.segs {
1359        if a == q || b == q {
1360            continue;
1361        }
1362        for (i, &r) in piece.ring.iter().enumerate() {
1363            let pa = flat2(pts[a].pos);
1364            let pb = flat2(pts[b].pos);
1365            let pq = flat2(pts[q].pos);
1366            let pr = flat2(pts[r].pos);
1367            if let Cross::Point(t, u) = seg_cross(pa, pb, pq, pr)
1368                && t > 1e-9
1369                && t < 1.0 - 1e-9
1370                && u > 1e-9
1371                && u < 1.0 - 1e-9
1372            {
1373                let p =
1374                    std::array::from_fn(|k| pts[a].pos[k] + t * (pts[b].pos[k] - pts[a].pos[k]));
1375                push(&mut cuts, (i, a, p))?;
1376            }
1377        }
1378    }
1379    // Group cuts per spoke, ordered from q outward.
1380    let mut per_spoke: Vec<Vec<(f64, D3)>> = vec![Vec::new(); n];
1381    for (i, _, p) in &cuts {
1382        // Parameter along the spoke for ordering.
1383        let (_, u) = point_seg2(flat2(*p), flat2(pts[q].pos), flat2(pts[piece.ring[*i]].pos));
1384        per_spoke[*i].push((u, *p));
1385    }
1386    for sp in per_spoke.iter_mut() {
1387        sp.sort_by(|x, y| x.0.total_cmp(&y.0));
1388    }
1389    // Split the segments at the spoke points.
1390    let mut seg_list = piece.segs.clone();
1391    for (i, sp) in per_spoke.iter().enumerate() {
1392        for &(_, p) in sp {
1393            let v = fresh(p, plan, fresh_of)?;
1394            let at = pts.len();
1395            push(pts, WPt { pos: p, v })?;
1396            // Split every segment passing through `p` (it lies on a spoke,
1397            // shared by two future fan triangles).
1398            let mut k = 0;
1399            while k < seg_list.len() {
1400                let (x, y) = seg_list[k];
1401                if x == at || y == at {
1402                    k += 1;
1403                    continue;
1404                }
1405                if on_seg(flat2(p), flat2(pts[x].pos), flat2(pts[y].pos)) {
1406                    seg_list[k] = (x.min(at), x.max(at));
1407                    push(&mut seg_list, (at.min(y), at.max(y)))?;
1408                }
1409                k += 1;
1410            }
1411            spoke_pts[i].push(at);
1412        }
1413    }
1414    seg_list.sort_unstable();
1415    seg_list.dedup();
1416    // Fan triangles, with spokes split at their crossings.
1417    let mut out: Vec<Piece> = Vec::new();
1418    // Spoke chains from q outward (spoke point indices, then the ring end).
1419    let mut chains: Vec<Vec<usize>> = Vec::new();
1420    for (spoke, &r) in spoke_pts.iter().zip(piece.ring.iter()) {
1421        let mut chain = vec![q];
1422        chain.extend_from_slice(spoke);
1423        chain.push(r);
1424        chains.push(chain);
1425    }
1426    for i in 0..n {
1427        let (c0, c1) = (&chains[i], &chains[(i + 1) % n]);
1428        let rows = c0.len().max(c1.len());
1429        for r in 0..rows - 1 {
1430            let a0 = c0[r.min(c0.len() - 1)];
1431            let a1 = c0[(r + 1).min(c0.len() - 1)];
1432            let b0 = c1[r.min(c1.len() - 1)];
1433            let b1 = c1[(r + 1).min(c1.len() - 1)];
1434            for tri in [[a0, a1, b1], [a0, b1, b0]] {
1435                if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] {
1436                    continue;
1437                }
1438                let p = [pts[tri[0]].pos, pts[tri[1]].pos, pts[tri[2]].pos];
1439                if area2(p[0], p[1], p[2]) <= AREA_EPSILON * max_edge2(&p).max(1e-300) {
1440                    continue;
1441                }
1442                push(
1443                    &mut out,
1444                    Piece {
1445                        ring: vec![tri[0], tri[1], tri[2]],
1446                        inner: Vec::new(),
1447                        segs: Vec::new(),
1448                    },
1449                )?;
1450            }
1451        }
1452    }
1453    // Distribute the split segments by midpoint; every sub-seg was cut at
1454    // each spoke it crosses, so each lies in one fan. Complete any endpoint
1455    // that is not a fan vertex yet (strictly inside goes to inner, on a
1456    // boundary edge splices into the ring).
1457    for (a, b) in &seg_list {
1458        let mid = std::array::from_fn(|k| 0.5 * (pts[*a].pos[k] + pts[*b].pos[k]));
1459        let mut at = None;
1460        for (fi, fan) in out.iter().enumerate() {
1461            if fan_contains_pos(fan, flat2(mid), pts, flat2) {
1462                at = Some(fi);
1463                break;
1464            }
1465        }
1466        let Some(fi) = at else {
1467            return Err(OpsError::InvalidInput("boolean intersection too complex"));
1468        };
1469        for &x in [a, b] {
1470            complete_endpoint(&mut out[fi], x, pts, flat2)?;
1471        }
1472        push(&mut out[fi].segs, (*a, *b))?;
1473    }
1474    for &p in &piece.inner {
1475        if p == q {
1476            continue;
1477        }
1478        let mut placed = false;
1479        for fan in out.iter_mut() {
1480            if fan_contains(fan, p, pts, flat2) {
1481                if !fan.ring.contains(&p) && !fan.inner.contains(&p) {
1482                    push(&mut fan.inner, p)?;
1483                }
1484                placed = true;
1485                break;
1486            }
1487        }
1488        if !placed {
1489            return Err(OpsError::InvalidInput("boolean intersection too complex"));
1490        }
1491    }
1492    Ok(out)
1493}
1494
1495/// Splits triangle `tri` along its edge `u..v` at `qs` (in order from `u`
1496/// to `v`, strictly interior), appending the chain to `out`. The pieces
1497/// follow the parent's winding and are never degenerate.
1498fn split_chain<T: Copy + PartialEq>(
1499    tri: [T; 3],
1500    u: T,
1501    v: T,
1502    qs: &[T],
1503    out: &mut Vec<[T; 3]>,
1504) -> Result<()> {
1505    let iu = tri.iter().position(|&x| x == u).expect("edge of triangle");
1506    // Normalize to the forward direction (cyclic order).
1507    let (a, b, o, qs): (T, T, T, Vec<T>) = if tri[(iu + 1) % 3] == v {
1508        (u, v, tri[(iu + 2) % 3], qs.to_vec())
1509    } else {
1510        debug_assert!(tri[(iu + 1) % 3] != u && tri[(iu + 2) % 3] == v);
1511        let o = tri[(iu + 1) % 3];
1512        let mut r = qs.to_vec();
1513        r.reverse();
1514        (v, u, o, r)
1515    };
1516    let mut prev = a;
1517    for q in qs.iter().chain(std::iter::once(&b)) {
1518        push(out, [prev, *q, o])?;
1519        prev = *q;
1520    }
1521    Ok(())
1522}
1523
1524/// Whether the piece contains the 2D point `p` (boundary included).
1525fn fan_contains_pos(
1526    fan: &Piece,
1527    p: [f64; 2],
1528    pts: &[WPt],
1529    flat2: impl Copy + Fn(D3) -> [f64; 2],
1530) -> bool {
1531    if fan.ring.len() != 3 {
1532        return point_in_piece(p, fan, pts, flat2);
1533    }
1534    let t = [
1535        flat2(pts[fan.ring[0]].pos),
1536        flat2(pts[fan.ring[1]].pos),
1537        flat2(pts[fan.ring[2]].pos),
1538    ];
1539    let o = orient2(t[0], t[1], t[2]);
1540    let (s0, s1, s2) = (
1541        orient2(t[0], t[1], p),
1542        orient2(t[1], t[2], p),
1543        orient2(t[2], t[0], p),
1544    );
1545    if o >= 0.0 {
1546        s0 >= -1e-9 && s1 >= -1e-9 && s2 >= -1e-9
1547    } else {
1548        s0 <= 1e-9 && s1 <= 1e-9 && s2 <= 1e-9
1549    }
1550}
1551
1552/// Completes an endpoint `x` of a segment assigned to `fan`: already a ring
1553/// vertex (done), strictly inside (goes to inner) or on a boundary edge
1554/// (splices into the ring in order).
1555fn complete_endpoint(
1556    fan: &mut Piece,
1557    x: usize,
1558    pts: &[WPt],
1559    flat2: impl Copy + Fn(D3) -> [f64; 2],
1560) -> Result<()> {
1561    if fan.ring.contains(&x) || fan.inner.contains(&x) {
1562        return Ok(());
1563    }
1564    let p = flat2(pts[x].pos);
1565    // Strictly inside: not within tolerance of any ring edge.
1566    let mut on_edge = None;
1567    for i in 0..fan.ring.len() {
1568        let (a, b) = (
1569            flat2(pts[fan.ring[i]].pos),
1570            flat2(pts[fan.ring[(i + 1) % fan.ring.len()]].pos),
1571        );
1572        let (d, t) = point_seg2(p, a, b);
1573        let len = (b[0] - a[0]).hypot(b[1] - a[1]).max(1e-300);
1574        if d <= 1e-9 * len && t > 1e-9 && t < 1.0 - 1e-9 {
1575            on_edge = Some(i);
1576            break;
1577        }
1578    }
1579    if let Some(i) = on_edge {
1580        // Splice into the ring after `i`, ordered along the edge.
1581        let mut at = i + 1;
1582        while at < fan.ring.len() + i {
1583            let prev = fan.ring[at % fan.ring.len()];
1584            let (d, _) = point_seg2(p, flat2(pts[fan.ring[i]].pos), flat2(pts[prev].pos));
1585            // Stop once past `x` along the edge.
1586            let prev_p = flat2(pts[prev].pos);
1587            let edge_p = flat2(pts[fan.ring[i]].pos);
1588            let along = (p[0] - edge_p[0]) * (prev_p[0] - edge_p[0])
1589                + (p[1] - edge_p[1]) * (prev_p[1] - edge_p[1]);
1590            let _ = d;
1591            if along <= 0.0 {
1592                break;
1593            }
1594            at += 1;
1595            if at - i - 1 > fan.ring.len() {
1596                break;
1597            }
1598        }
1599        fan.ring.insert(at % (fan.ring.len() + 1), x);
1600        return Ok(());
1601    }
1602    push(&mut fan.inner, x)
1603}
1604fn fan_contains(fan: &Piece, p: usize, pts: &[WPt], flat2: impl Copy + Fn(D3) -> [f64; 2]) -> bool {
1605    if fan.ring.len() != 3 {
1606        return point_in_piece(flat2(pts[p].pos), fan, pts, flat2);
1607    }
1608    let q = flat2(pts[p].pos);
1609    let t = [
1610        flat2(pts[fan.ring[0]].pos),
1611        flat2(pts[fan.ring[1]].pos),
1612        flat2(pts[fan.ring[2]].pos),
1613    ];
1614    let o = orient2(t[0], t[1], t[2]);
1615    let (s0, s1, s2) = (
1616        orient2(t[0], t[1], q),
1617        orient2(t[1], t[2], q),
1618        orient2(t[2], t[0], q),
1619    );
1620    if o >= 0.0 {
1621        s0 >= -1e-9 && s1 >= -1e-9 && s2 >= -1e-9
1622    } else {
1623        s0 <= 1e-9 && s1 <= 1e-9 && s2 <= 1e-9
1624    }
1625}
1626
1627/// 2D distance from `p` to segment `a..b`, with the parameter.
1628fn point_seg2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> (f64, f64) {
1629    let ab = [b[0] - a[0], b[1] - a[1]];
1630    let len2 = ab[0] * ab[0] + ab[1] * ab[1];
1631    if len2 == 0.0 {
1632        return (((p[0] - a[0]).powi(2) + (p[1] - a[1]).powi(2)).sqrt(), 0.0);
1633    }
1634    let t = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / len2;
1635    let c = [
1636        a[0] + t.clamp(0.0, 1.0) * ab[0],
1637        a[1] + t.clamp(0.0, 1.0) * ab[1],
1638    ];
1639    (((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt(), t)
1640}
1641
1642/// Classifies every triangle of `mesh` against the tool and records what to
1643/// keep: A drops and rewrites, tool adds.
1644#[allow(clippy::too_many_arguments)]
1645fn classify_side(
1646    mesh: &Mesh,
1647    pieces: &BTreeMap<TriangleId, SplitTris>,
1648    side: &Side,
1649    tool: &ToolRef<'_>,
1650    op: BooleanOp,
1651    is_a: bool,
1652    plan: &mut BooleanPlan,
1653    fresh_of: &mut BTreeMap<[u32; 3], u32>,
1654) -> Result<()> {
1655    for t in 0..mesh.triangle_slots() as TriangleId {
1656        let Some(tri) = mesh.triangle(t) else {
1657            continue;
1658        };
1659        let whole: [PRef; 3] = match side {
1660            Side::A => tri.map(PRef::Live),
1661            Side::B => {
1662                let mut v = [PRef::Fresh(0); 3];
1663                for (k, p) in tri_pos(mesh, t).iter().enumerate() {
1664                    v[k] = fresh(*p, plan, fresh_of)?;
1665                }
1666                v
1667            }
1668        };
1669        let owned;
1670        let (candidates, edge_split): (&[[PRef; 3]], bool) = match pieces.get(&t) {
1671            Some(split) => (&split.pieces, split.edge_split),
1672            None => {
1673                owned = vec![whole];
1674                (&owned, false)
1675            }
1676        };
1677        // Classify into a fresh vector first.
1678        let mut kept: Vec<[PRef; 3]> = Vec::new();
1679        for c in candidates {
1680            let (centroid, normal) = piece_geom(mesh, c, plan)?;
1681            if keep_piece(tool, centroid, normal, op, is_a, plan.tol)? {
1682                push(&mut kept, *c)?;
1683            }
1684        }
1685        match side {
1686            Side::A => {
1687                if kept.is_empty() {
1688                    push(&mut plan.drop_a, t)?;
1689                } else if pieces.contains_key(&t) && (kept.len() < candidates.len() || edge_split) {
1690                    // Materialise the splits: a dropped piece exposes them,
1691                    // and shared edge splits keep the neighbour consistent.
1692                    push(&mut plan.rewrite, (t, kept))?;
1693                }
1694                // Else the whole triangle stays: untouched, or split with
1695                // every piece kept and no shared edge split.
1696            }
1697            Side::B => {
1698                for mut k in kept {
1699                    if op == BooleanOp::Subtract {
1700                        // The cavity wall faces into the removed volume.
1701                        k.swap(1, 2);
1702                    }
1703                    push(&mut plan.tool, k)?;
1704                }
1705            }
1706        }
1707    }
1708    Ok(())
1709}
1710
1711/// Centroid and (unnormalized) normal of a planned piece.
1712fn piece_geom(mesh: &Mesh, tri: &[PRef; 3], plan: &BooleanPlan) -> Result<(D3, D3)> {
1713    let mut p = [[0.0; 3]; 3];
1714    for (k, r) in tri.iter().enumerate() {
1715        p[k] = match r {
1716            PRef::Live(v) => mesh
1717                .position(*v)
1718                .ok_or(OpsError::InvalidInput("boolean triangle lost a vertex"))?
1719                .map(f64::from),
1720            PRef::Fresh(i) => plan
1721                .fresh
1722                .get(*i as usize)
1723                .ok_or(OpsError::InvalidInput("boolean plan is not ready"))?
1724                .map(f64::from),
1725        };
1726    }
1727    let c = std::array::from_fn(|k| (p[0][k] + p[1][k] + p[2][k]) / 3.0);
1728    Ok((c, cross(sub(p[1], p[0]), sub(p[2], p[0]))))
1729}
1730
1731/// Whether a piece with centroid `c` and normal `n` survives `op`.
1732fn keep_piece(
1733    tool: &ToolRef<'_>,
1734    c: D3,
1735    n: D3,
1736    op: BooleanOp,
1737    is_a: bool,
1738    tol: f32,
1739) -> Result<bool> {
1740    let tol = tol as f64;
1741    let (inside, on_surface) = parity(tool, c, tol)?;
1742    if !on_surface {
1743        return Ok(match (op, is_a) {
1744            (BooleanOp::Union, _) => !inside,
1745            (BooleanOp::Subtract, true) => !inside,
1746            (BooleanOp::Subtract, false) => inside,
1747            (BooleanOp::Intersect, _) => inside,
1748        });
1749    }
1750    // Coplanar: the centroid lies on the tool surface. Flush overlaps
1751    // (same winding) probe behind the piece, into the solid; opposing
1752    // overlaps (face-to-face touches) probe in front of it.
1753    let len = dot(n, n).sqrt();
1754    if len == 0.0 {
1755        return Err(OpsError::InvalidInput("boolean piece lost its normal"));
1756    }
1757    let u = n.map(|x| x / len);
1758    let eps = 64.0 * tol;
1759    let nt = tool_normal(tool, c)?;
1760    let flush = dot(u, nt) > 0.0;
1761    let p_out = std::array::from_fn(|k| c[k] + eps * u[k]);
1762    let (out_inside, _) = parity(tool, p_out, tol)?;
1763    let p_in = std::array::from_fn(|k| c[k] - eps * u[k]);
1764    let (in_inside, _) = parity(tool, p_in, tol)?;
1765    Ok(match (op, is_a) {
1766        // B's coplanar copies always go: A's copy covers the patch (or the
1767        // patch is interior and both go). A survives when the tool does not
1768        // cover its front: a flush overlap is still surface (void in
1769        // front), while a face-to-face contact is interior.
1770        (BooleanOp::Union, true) => !out_inside,
1771        (BooleanOp::Union, false) => false,
1772        (BooleanOp::Subtract, true) => {
1773            if flush {
1774                !in_inside
1775            } else {
1776                true
1777            }
1778        }
1779        (BooleanOp::Subtract, false) => false,
1780        (BooleanOp::Intersect, true) => true,
1781        (BooleanOp::Intersect, false) => false,
1782    })
1783}
1784
1785/// Inside/outside of `p` against the tool, by majority vote over three
1786/// axes, plus whether any axis starts on the tool surface.
1787fn parity(tool: &ToolRef<'_>, p: D3, tol: f64) -> Result<(bool, bool)> {
1788    let origin = [p[0] as f32, p[1] as f32, p[2] as f32];
1789    if !origin.iter().all(|c| c.is_finite()) {
1790        return Err(OpsError::InvalidInput("boolean point leaves f32 range"));
1791    }
1792    let mut inside_votes = 0;
1793    let mut on_surface = false;
1794    let mut hits: Vec<fast_mesh_query::RayHit> = Vec::new();
1795    for dir in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] {
1796        hits.clear();
1797        let ray = Ray { origin, dir };
1798        let opts = RayOptions {
1799            min_t: 0.0,
1800            max_t: f32::INFINITY,
1801            cull: Cull::None,
1802        };
1803        raycast_all(tool.mesh, tool.bvh, &ray, &opts, &mut hits)?;
1804        // Merge hits at the same surface (one face is two triangles).
1805        hits.sort_by(|x, y| x.t.total_cmp(&y.t));
1806        let mut groups = 0;
1807        let mut first = f32::INFINITY;
1808        let mut prev = f32::NEG_INFINITY;
1809        for h in &hits {
1810            if (h.t - prev) as f64 > 1e-6 * (1.0 + h.t as f64) {
1811                groups += 1;
1812                if first.is_infinite() {
1813                    first = h.t;
1814                }
1815            }
1816            prev = h.t;
1817        }
1818        if groups % 2 == 1 {
1819            inside_votes += 1;
1820        }
1821        if (first as f64) <= tol {
1822            on_surface = true;
1823        }
1824    }
1825    Ok((inside_votes >= 2, on_surface))
1826}
1827
1828/// The tool's outward normal at the surface nearest `p`.
1829fn tool_normal(tool: &ToolRef<'_>, p: D3) -> Result<D3> {
1830    let q = [p[0] as f32, p[1] as f32, p[2] as f32];
1831    let hit = closest_point(
1832        tool.mesh,
1833        tool.bvh,
1834        q,
1835        &ClosestOptions {
1836            max_distance: f32::INFINITY,
1837        },
1838    )?
1839    .ok_or(OpsError::InvalidInput("boolean tool has no triangles"))?;
1840    let tri = tool
1841        .mesh
1842        .triangle(hit.triangle)
1843        .ok_or(OpsError::InvalidInput("boolean tool lost a triangle"))?;
1844    let pos = tri.map(|v| {
1845        tool.mesh
1846            .position(v)
1847            .expect("tool triangle is live")
1848            .map(f64::from)
1849    });
1850    let n = cross(sub(pos[1], pos[0]), sub(pos[2], pos[0]));
1851    if dot(n, n) == 0.0 {
1852        return Err(OpsError::InvalidInput("boolean tool lost its normal"));
1853    }
1854    Ok(n)
1855}
1856
1857/// Stitches the rims: every boundary edge is split at the other side's rim
1858/// vertices within `tol` (reusing their ids), closing the curve on shared
1859/// vertices. Returns the triangles added by the splits.
1860fn stitch(tx: &mut Transaction, tool_ids: &[TriangleId], tol: f32) -> Result<usize> {
1861    let mesh = tx.mesh();
1862    let loops = crate::boundary_loops(mesh)?;
1863    if loops.is_empty() {
1864        return Ok(0);
1865    }
1866    let tool_set: BTreeMap<TriangleId, ()> = tool_ids.iter().map(|&t| (t, ())).collect();
1867    // Boundary edges with their triangle and side (tool or A).
1868    let mut edges: Vec<((VertexId, VertexId), TriangleId, bool)> = Vec::new();
1869    for l in &loops {
1870        let n = l.vertices.len();
1871        if n < 2 {
1872            continue;
1873        }
1874        let end = if l.closed { n } else { n - 1 };
1875        for i in 0..end {
1876            let (u, v) = (l.vertices[i], l.vertices[(i + 1) % n]);
1877            // The boundary edge's single triangle.
1878            let mut user = None;
1879            for t in 0..mesh.triangle_slots() as TriangleId {
1880                if let Some(tri) = mesh.triangle(t)
1881                    && tri.contains(&u)
1882                    && tri.contains(&v)
1883                {
1884                    user = Some(t);
1885                    break;
1886                }
1887            }
1888            let Some(t) = user else { continue };
1889            push(&mut edges, ((u, v), t, tool_set.contains_key(&t)))?;
1890        }
1891    }
1892    // Rim vertices per side.
1893    let mut rim_a: Vec<VertexId> = Vec::new();
1894    let mut rim_b: Vec<VertexId> = Vec::new();
1895    for ((u, v), _, is_tool) in &edges {
1896        for w in [*u, *v] {
1897            let rim = if *is_tool { &mut rim_b } else { &mut rim_a };
1898            if !rim.contains(&w) {
1899                push(rim, w)?;
1900            }
1901        }
1902    }
1903    // Positions of every rim vertex, read once so the borrow ends before
1904    // the writes below.
1905    let mut positions: BTreeMap<VertexId, D3> = BTreeMap::new();
1906    for w in rim_a.iter().chain(rim_b.iter()) {
1907        if let Some(p) = mesh.position(*w) {
1908            positions.insert(*w, p.map(f64::from));
1909        }
1910    }
1911    let pos = |v: VertexId| positions[&v];
1912    // Splits per triangle.
1913    let mut splits: BTreeMap<TriangleId, Vec<(VertexId, VertexId, VertexId)>> = BTreeMap::new();
1914    for ((u, v), t, is_tool) in &edges {
1915        let others = if *is_tool { &rim_a } else { &rim_b };
1916        let (pu, pv) = (pos(*u), pos(*v));
1917        for &w in others {
1918            // Vertices at the edge's ends merge in the weld below.
1919            let (du, dv) = (sub(pos(w), pu), sub(pos(w), pv));
1920            if dot(du, du).sqrt() <= tol as f64 || dot(dv, dv).sqrt() <= tol as f64 {
1921                continue;
1922            }
1923            let (d, _) = point_seg(pos(w), pu, pv);
1924            if d <= tol as f64 {
1925                splits.entry(*t).or_default().push((*u, *v, w));
1926            }
1927        }
1928    }
1929    if splits.is_empty() {
1930        return Ok(0);
1931    }
1932    let mut added = 0usize;
1933    for (t, ss) in splits {
1934        let tri = tx.mesh().triangle(t).expect("split of a live triangle");
1935        let mut tris = vec![tri];
1936        // Group splits per edge, ordered along it.
1937        let mut by_edge: BTreeMap<(VertexId, VertexId), Vec<VertexId>> = BTreeMap::new();
1938        for (u, v, w) in ss {
1939            by_edge.entry((u, v)).or_default().push(w);
1940        }
1941        for ((u, v), mut ws) in by_edge {
1942            let (pu, pv) = (pos(u), pos(v));
1943            ws.sort_by(|&x, &y| {
1944                let tx_ = dot(sub(pos(x), pu), sub(pv, pu));
1945                let ty_ = dot(sub(pos(y), pu), sub(pv, pu));
1946                tx_.total_cmp(&ty_)
1947            });
1948            ws.dedup();
1949            // Split the one triangle holding the edge.
1950            let at = tris
1951                .iter()
1952                .position(|tri| tri.contains(&u) && tri.contains(&v))
1953                .expect("split edge of a live triangle");
1954            let tri = tris.remove(at);
1955            split_chain(tri, u, v, &ws, &mut tris)?;
1956        }
1957        if tris.len() <= 1 {
1958            continue;
1959        }
1960        tx.set_triangles(&[t], &[tris[0]])?;
1961        tx.add_triangles(&tris[1..])?;
1962        added += tris.len() - 1;
1963    }
1964    Ok(added)
1965}
1966
1967#[cfg(test)]
1968mod tests {
1969    use super::*;
1970    use crate::test_util::{box_mesh, edit, grid, uv_sphere, with_tetra};
1971    use fast_mesh_query::Rigid;
1972
1973    /// A closed unit cube, outward wound.
1974    fn cube() -> Mesh {
1975        box_mesh([0.0, 0.0, 0.0], [1.0, 1.0, 1.0])
1976    }
1977
1978    /// Six times the signed volume: positive for outward-wound meshes.
1979    fn volume6(mesh: &Mesh) -> f64 {
1980        let mut v = 0.0;
1981        for t in 0..mesh.triangle_slots() as TriangleId {
1982            let Some(tri) = mesh.triangle(t) else {
1983                continue;
1984            };
1985            let [a, b, c] = tri.map(|x| mesh.position(x).unwrap().map(f64::from));
1986            v += a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0])
1987                + a[2] * (b[0] * c[1] - b[1] * c[0]);
1988        }
1989        v
1990    }
1991
1992    fn closed(mesh: &Mesh) -> bool {
1993        crate::boundary_loops(mesh).unwrap().is_empty()
1994    }
1995
1996    fn run(a: &mut Mesh, b: &Mesh, op: BooleanOp) -> BooleanReport {
1997        let bvh_a = Bvh::build(a, &BuildOptions::default()).unwrap();
1998        let bvh_b = Bvh::build(b, &BuildOptions::default()).unwrap();
1999        let (report, _) = edit(a, |tx| {
2000            let plan = boolean_plan(tx.mesh(), &bvh_a, b, &bvh_b, &Rigid::IDENTITY, op)?;
2001            apply_boolean(tx, &plan)
2002        });
2003        report
2004    }
2005
2006    #[test]
2007    fn union_overlapping_cubes() {
2008        // B shifted half a unit in x: overlap 0.5 x 1 x 1.
2009        let mut a = cube();
2010        let b = box_mesh([0.5, 0.0, 0.0], [1.5, 1.0, 1.0]);
2011        let report = run(&mut a, &b, BooleanOp::Union);
2012        assert!(closed(&a), "union must be closed");
2013        assert!(
2014            (volume6(&a) - 6.0 * 1.5).abs() < 1e-3,
2015            "vol {}",
2016            volume6(&a)
2017        );
2018        assert_eq!(report.kept_triangles, a.triangle_count());
2019    }
2020
2021    #[test]
2022    fn subtract_overlapping_cubes() {
2023        let mut a = cube();
2024        let b = box_mesh([0.5, 0.0, 0.0], [1.5, 1.0, 1.0]);
2025        run(&mut a, &b, BooleanOp::Subtract);
2026        assert!(closed(&a), "subtract must be closed");
2027        assert!(
2028            (volume6(&a) - 6.0 * 0.5).abs() < 1e-3,
2029            "vol {}",
2030            volume6(&a)
2031        );
2032    }
2033
2034    #[test]
2035    fn intersect_overlapping_cubes() {
2036        let mut a = cube();
2037        let b = box_mesh([0.5, 0.0, 0.0], [1.5, 1.0, 1.0]);
2038        run(&mut a, &b, BooleanOp::Intersect);
2039        assert!(closed(&a), "intersect must be closed");
2040        assert!(
2041            (volume6(&a) - 6.0 * 0.5).abs() < 1e-3,
2042            "vol {}",
2043            volume6(&a)
2044        );
2045    }
2046
2047    #[test]
2048    fn asymmetric_overlap_needs_interior_points() {
2049        // B offset in every axis: B's edges pierce A's faces away from
2050        // their diagonals, so triangles gain interior segment endpoints.
2051        // Overlap 0.5 x 0.7 x 0.3.
2052        let mut a = cube();
2053        let b = box_mesh([0.5, 0.3, 0.7], [1.5, 1.3, 1.7]);
2054        run(&mut a, &b, BooleanOp::Subtract);
2055        assert!(closed(&a), "subtract must be closed");
2056        assert!(
2057            (volume6(&a) - 6.0 * 0.895).abs() < 1e-3,
2058            "vol {}",
2059            volume6(&a)
2060        );
2061        let mut u = cube();
2062        run(&mut u, &b, BooleanOp::Union);
2063        assert!(closed(&u), "union must be closed");
2064        assert!(
2065            (volume6(&u) - 6.0 * 1.895).abs() < 1e-3,
2066            "vol {}",
2067            volume6(&u)
2068        );
2069    }
2070
2071    #[test]
2072    fn stacked_cubes_share_a_face() {
2073        // Coplanar area overlap: the shared face drops out of the union.
2074        let mut a = cube();
2075        let b = box_mesh([0.0, 0.0, 1.0], [1.0, 1.0, 2.0]);
2076        run(&mut a, &b, BooleanOp::Union);
2077        assert!(closed(&a), "union must be closed");
2078        assert!((volume6(&a) - 12.0).abs() < 1e-3, "vol {}", volume6(&a));
2079        assert_eq!(a.triangle_count(), 20);
2080    }
2081
2082    #[test]
2083    fn nested_spheres_classify_whole() {
2084        let outer = uv_sphere(8, 12, 1.0);
2085        let inner = uv_sphere(6, 10, 0.5);
2086        let (vo, vi) = (volume6(&outer), volume6(&inner));
2087        let mut u = uv_sphere(8, 12, 1.0);
2088        run(&mut u, &inner, BooleanOp::Union);
2089        assert!(closed(&u));
2090        assert!((volume6(&u) - vo).abs() < 1e-3 * vo.abs());
2091        let mut i = uv_sphere(8, 12, 1.0);
2092        run(&mut i, &inner, BooleanOp::Intersect);
2093        assert!(closed(&i));
2094        assert!((volume6(&i) - vi).abs() < 1e-3 * vi.abs());
2095        let mut s = uv_sphere(8, 12, 1.0);
2096        run(&mut s, &inner, BooleanOp::Subtract);
2097        assert!(closed(&s));
2098        assert!((volume6(&s) - (vo - vi)).abs() < 1e-3 * vo.abs());
2099    }
2100
2101    #[test]
2102    fn overlapping_spheres() {
2103        // Two unit spheres a unit apart: every triangle near the intersection
2104        // is split, on both sides.
2105        let sphere_at = |x: f32| {
2106            let (mut positions, triangles) = crate::test_util::parts(&uv_sphere(8, 12, 1.0));
2107            for p in positions.as_chunks_mut::<3>().0 {
2108                p[0] += x;
2109            }
2110            let indices: Vec<u32> = triangles.iter().flatten().copied().collect();
2111            Mesh::from_buffers(&positions, &indices).unwrap()
2112        };
2113        // Analytic lens volume for r = 1, d = 1: 5π/12 ≈ 1.3090 (×6 below).
2114        let lens = 6.0 * 5.0 * std::f64::consts::PI / 12.0;
2115        let single = volume6(&sphere_at(0.0));
2116        let mut u = sphere_at(0.0);
2117        run(&mut u, &sphere_at(1.0), BooleanOp::Union);
2118        assert!(closed(&u), "union must be closed");
2119        assert!(
2120            (volume6(&u) - (2.0 * single - lens)).abs() < 0.05 * single.abs(),
2121            "union vol {}",
2122            volume6(&u)
2123        );
2124        assert!((volume6(&u) - (2.0 * single - lens)).abs() < 0.05 * single.abs());
2125        let mut i = sphere_at(0.0);
2126        run(&mut i, &sphere_at(1.0), BooleanOp::Intersect);
2127        assert!(closed(&i), "intersect must be closed");
2128        assert!((volume6(&i) - lens).abs() < 0.05 * single.abs());
2129        let mut s = sphere_at(0.0);
2130        run(&mut s, &sphere_at(1.0), BooleanOp::Subtract);
2131        assert!(closed(&s), "subtract must be closed");
2132        assert!((volume6(&s) - (single - lens)).abs() < 0.05 * single.abs());
2133    }
2134
2135    #[test]
2136    fn disjoint_cubes() {
2137        let b = box_mesh([5.0, 0.0, 0.0], [6.0, 1.0, 1.0]);
2138        let mut u = cube();
2139        run(&mut u, &b, BooleanOp::Union);
2140        assert!(closed(&u));
2141        assert_eq!(u.triangle_count(), 24);
2142        assert!((volume6(&u) - 12.0).abs() < 1e-9);
2143        let mut i = cube();
2144        let report = run(&mut i, &b, BooleanOp::Intersect);
2145        assert_eq!(i.triangle_count(), 0);
2146        assert_eq!(report.kept_triangles, 0);
2147        let mut s = cube();
2148        run(&mut s, &b, BooleanOp::Subtract);
2149        assert_eq!(s.triangle_count(), 12);
2150        assert!((volume6(&s) - 6.0).abs() < 1e-9);
2151    }
2152
2153    #[test]
2154    fn mesh_with_itself_is_rejected() {
2155        let mut a = cube();
2156        let bvh_a = Bvh::build(&a, &BuildOptions::default()).unwrap();
2157        // The scene short-circuits self-booleans; the op itself refuses: a
2158        // mesh against itself is all interface, which no rule resolves.
2159        assert_eq!(
2160            boolean_plan(&a, &bvh_a, &a, &bvh_a, &Rigid::IDENTITY, BooleanOp::Union).unwrap_err(),
2161            OpsError::InvalidInput("cannot combine a mesh with itself")
2162        );
2163        let _ = &mut a;
2164    }
2165
2166    #[test]
2167    fn rejects_bad_inputs() {
2168        // Open.
2169        let open = grid(2);
2170        let a = cube();
2171        let bvh_a = Bvh::build(&a, &BuildOptions::default()).unwrap();
2172        let bvh_o = Bvh::build(&open, &BuildOptions::default()).unwrap();
2173        assert_eq!(
2174            boolean_plan(
2175                &a,
2176                &bvh_a,
2177                &open,
2178                &bvh_o,
2179                &Rigid::IDENTITY,
2180                BooleanOp::Union
2181            )
2182            .unwrap_err(),
2183            OpsError::InvalidInput("boolean needs closed meshes")
2184        );
2185        // Non-manifold edge: a tetrahedron on a cube edge gives edge
2186        // (0, 1) four triangles, without opening the mesh.
2187        let spike = with_tetra(&a, 0, 1, [[2.0, 0.0, 0.0], [2.0, 1.0, 0.0]]);
2188        let bvh_s = Bvh::build(&spike, &BuildOptions::default()).unwrap();
2189        assert_eq!(
2190            boolean_plan(
2191                &spike,
2192                &bvh_s,
2193                &a,
2194                &bvh_a,
2195                &Rigid::IDENTITY,
2196                BooleanOp::Union
2197            )
2198            .unwrap_err(),
2199            OpsError::InvalidInput("boolean needs manifold meshes")
2200        );
2201        // A degenerate triangle with its own vertices trips neither the
2202        // boundary nor the manifold check, but the degenerate one.
2203        let mut needle = cube();
2204        let n = edit(&mut needle, |tx| {
2205            tx.add_vertices(&[[9.0, 9.0, 9.0], [9.0, 9.0, 9.0], [9.0, 9.0, 8.0]])?;
2206            Ok(())
2207        });
2208        let _ = n;
2209        let base = needle.vertex_slots() as u32 - 3;
2210        edit(&mut needle, |tx| {
2211            tx.add_triangles(&[[base, base, base + 1]])?;
2212            Ok(())
2213        });
2214        let bvh_n = Bvh::build(&needle, &BuildOptions::default()).unwrap();
2215        assert_eq!(
2216            boolean_plan(
2217                &needle,
2218                &bvh_n,
2219                &a,
2220                &bvh_a,
2221                &Rigid::IDENTITY,
2222                BooleanOp::Union
2223            )
2224            .unwrap_err(),
2225            OpsError::InvalidInput("boolean input has degenerate triangles")
2226        );
2227        // Two identical triangles with their own vertices: closed and
2228        // manifold, but duplicates.
2229        let dup = Mesh::from_buffers(
2230            &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
2231            &[0, 1, 2, 0, 1, 2],
2232        )
2233        .unwrap();
2234        let bvh_d = Bvh::build(&dup, &BuildOptions::default()).unwrap();
2235        assert_eq!(
2236            boolean_plan(&dup, &bvh_d, &a, &bvh_a, &Rigid::IDENTITY, BooleanOp::Union).unwrap_err(),
2237            OpsError::InvalidInput("boolean input has duplicate triangles")
2238        );
2239    }
2240}