axiolid_reference/
assemble.rs

1//! Assembling an exact boolean result from retriangulated operands.
2//!
3//! # The three steps
4//!
5//! 1. [`intersection_segments`] finds WHERE the
6//!    two surfaces cross.
7//! 2. [`retriangulate_face`] rebuilds each cut face
8//!    so the curve exists as mesh edges.
9//! 3. This module decides which of the resulting pieces to keep.
10//!
11//! # Why step 2 makes step 3 easy
12//!
13//! After retriangulation no triangle straddles the other solid's surface:
14//! each one lies wholly inside or wholly outside. So a single containment
15//! test per triangle settles it, and the test can be taken at the centroid --
16//! a point guaranteed to be in the triangle's interior, away from the
17//! boundary where classification is ambiguous.
18//!
19//! Without step 2 this would be false: a triangle crossing the surface has no
20//! single answer, and sampling it anywhere would be a guess.
21//!
22//! # Winding
23//!
24//! `Difference` keeps the subject's outside and the tool's inside, but the
25//! tool's kept faces must be REVERSED: they become the cavity wall, and a
26//! cavity's outward normal points into the removed volume. Getting this
27//! wrong produces a mesh that looks right and has the wrong sign everywhere.
28
29use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
30use axiolid_core::{BooleanOperator, Point3};
31use axiolid_mesh::TriMesh;
32
33use crate::boolean::contains_point_exact;
34use crate::intersection::{intersection_segments, IntersectionSegment, NodeKey};
35use crate::retriangulate::retriangulate_face;
36use std::collections::BTreeMap;
37
38/// Which side of the other solid a piece lies on.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum Side {
41    /// Strictly inside the other operand.
42    Inside,
43    /// Strictly outside it.
44    Outside,
45}
46
47/// Rebuild one operand's faces against the curve, and classify each piece.
48///
49/// Returns the retriangulated triangles paired with the side they fall on,
50/// so the caller can keep whichever the operation asks for.
51fn split_and_classify(
52    mesh: &TriMesh,
53    other: &TriMesh,
54    face_segments: &BTreeMap<u32, Vec<IntersectionSegment>>,
55    positions: &BTreeMap<NodeKey, Point3>,
56) -> GeomResult<Vec<([Point3; 3], Side)>> {
57    let mut pieces = Vec::new();
58
59    for face_index in 0..mesh.triangle_count() {
60        let base = face_index * 3;
61        let corners: [Point3; 3] = [
62            mesh.positions[mesh.indices[base] as usize],
63            mesh.positions[mesh.indices[base + 1] as usize],
64            mesh.positions[mesh.indices[base + 2] as usize],
65        ];
66
67        let key = u32::try_from(face_index).unwrap_or(u32::MAX);
68        let empty: Vec<IntersectionSegment> = Vec::new();
69        let segments = face_segments.get(&key).unwrap_or(&empty);
70        let patch = retriangulate_face(corners, segments, positions)?;
71
72        for triangle in &patch.triangles {
73            let [a, b, c] = triangle.map(|i| patch.points[i as usize]);
74            // The centroid is interior to its OWN piece, but that says
75            // nothing about where it falls relative to the other operand: it
76            // can land exactly on the other's face, edge, or corner. Measured,
77            // not assumed -- a corner-overlap subtraction puts a centroid on
78            // exactly (2,4,2), the tool's corner edge.
79            let centroid = Point3::new(
80                (a.x + b.x + c.x) / 3.0,
81                (a.y + b.y + c.y) / 3.0,
82                (a.z + b.z + c.z) / 3.0,
83            );
84            // A centroid on the other operand's surface is unclassifiable, so
85            // retry from points nudged toward each corner. These stay strictly
86            // inside the piece -- so they classify the SAME piece -- while
87            // moving off whatever feature the centroid landed on. Refusing
88            // beats guessing: treating an on-surface centroid as "outside"
89            // keeps a piece whose neighbours were dropped, leaving a hole that
90            // still reports a plausible volume.
91            let probes = [
92                centroid,
93                lerp(centroid, a, 0.25),
94                lerp(centroid, b, 0.25),
95                lerp(centroid, c, 0.25),
96            ];
97            let inside = probes
98                .into_iter()
99                .find_map(|probe| contains_point_exact(other, probe));
100            let side = match inside {
101                Some(true) => Side::Inside,
102                Some(false) => Side::Outside,
103                None => {
104                    return Err(GeomError::Unsupported {
105                        backend: BackendId::new("scalar-assemble"),
106                        operation: Operation::MeshBoolean,
107                    })
108                }
109            };
110            pieces.push(([a, b, c], side));
111        }
112    }
113    Ok(pieces)
114}
115
116/// Compute a boolean of two interpenetrating solids, exactly.
117///
118/// This is the case [`ScalarBoolean`](crate::ScalarBoolean) refuses: surfaces
119/// that properly cross. Every decision is an exact predicate -- the curve from
120/// `orient3d` signs, the retriangulation from `orient2d` signs, the
121/// classification from ray parity -- so the result is not a tolerance
122/// approximation of the answer, it is the answer.
123///
124/// # Errors
125///
126/// Propagates the curve's refusals: coplanar face overlap, degenerate faces,
127/// and a curve that cannot be stitched. Refusing is deliberate; a boolean
128/// that guesses in those cases is worse than one that declines.
129pub fn exact_boolean(
130    subject: &TriMesh,
131    tool: &TriMesh,
132    operation: BooleanOperator,
133) -> GeomResult<TriMesh> {
134    let curve = intersection_segments(subject, tool)?;
135
136    let subject_pieces = split_and_classify(
137        subject,
138        tool,
139        &curve.subject_face_segments,
140        &curve.positions,
141    )?;
142    let tool_pieces =
143        split_and_classify(tool, subject, &curve.tool_face_segments, &curve.positions)?;
144
145    // Which side of each operand the operation keeps, and whether the tool's
146    // kept faces have to be flipped.
147    //
148    // Difference keeps the subject's outside and the tool's inside, and the
149    // tool's faces become the cavity wall: their outward normal must point
150    // INTO the removed volume, so they are reversed. Union and Intersection
151    // keep consistently-oriented faces from both, so they are not.
152    let (keep_subject, keep_tool, flip_tool) = match operation {
153        BooleanOperator::Union => (Side::Outside, Side::Outside, false),
154        BooleanOperator::Intersection => (Side::Inside, Side::Inside, false),
155        BooleanOperator::Difference => (Side::Outside, Side::Inside, true),
156        _ => {
157            return Err(axiolid_contracts::GeomError::Unsupported {
158                backend: axiolid_contracts::BackendId::new("scalar-exact-boolean"),
159                operation: Operation::MeshBoolean,
160            })
161        }
162    };
163
164    let mut positions: Vec<Point3> = Vec::new();
165    let mut indices: Vec<u32> = Vec::new();
166
167    // Vertices are welded on exact coordinate bits, the same discipline the
168    // curve uses for node identity. Two pieces that meet along the cut were
169    // computed from the same arithmetic, so their shared corners are
170    // bit-identical and join into one vertex -- leaving the result closed
171    // rather than a shell of unconnected triangles.
172    let mut welded: BTreeMap<[u64; 3], u32> = BTreeMap::new();
173    let mut push = |point: Point3, positions: &mut Vec<Point3>| -> u32 {
174        let bits = [point.x + 0.0, point.y + 0.0, point.z + 0.0].map(f64::to_bits);
175        *welded.entry(bits).or_insert_with(|| {
176            positions.push(point);
177            (positions.len() - 1) as u32
178        })
179    };
180
181    for (triangle, side) in &subject_pieces {
182        if *side != keep_subject {
183            continue;
184        }
185        for corner in triangle {
186            let index = push(*corner, &mut positions);
187            indices.push(index);
188        }
189    }
190    for (triangle, side) in &tool_pieces {
191        if *side != keep_tool {
192            continue;
193        }
194        // Reversing swaps two corners, which flips the winding and so the
195        // outward normal.
196        let ordered = if flip_tool {
197            [triangle[0], triangle[2], triangle[1]]
198        } else {
199            *triangle
200        };
201        for corner in &ordered {
202            let index = push(*corner, &mut positions);
203            indices.push(index);
204        }
205    }
206
207    Ok(TriMesh::new(positions, indices))
208}
209
210/// A point a fraction of the way from `from` toward `to`.
211///
212/// Used to move a probe off a degenerate feature while keeping it strictly
213/// inside the same piece, so it still classifies that piece.
214fn lerp(from: Point3, to: Point3, t: f64) -> Point3 {
215    Point3::new(
216        from.x + (to.x - from.x) * t,
217        from.y + (to.y - from.y) * t,
218        from.z + (to.z - from.z) * t,
219    )
220}