axiolid_reference/intersection.rs
1//! Intersection segments between two triangle meshes, and the polylines they
2//! assemble into.
3//!
4//! # The capability this adds
5//!
6//! [`ScalarBoolean`] is exact and total for disjoint,
7//! nested, and identical operands, and refuses everything else. The single
8//! missing capability is resolving surfaces that properly cross, which needs
9//! the intersection curve first. This module computes it.
10//!
11//! # Why nodes are symbolic, not coordinates
12//!
13//! An intersection point is named by the *source topology that produced it*
14//! -- a vertex index, or the pair of faces and the edge that crossed -- never
15//! by its computed position. Two faces sharing an edge then produce byte-
16//! identical node names, so stitching segments into a polyline is exact
17//! integer matching with no tolerance anywhere.
18//!
19//! Matching on coordinates instead would need an epsilon, and an epsilon in
20//! the stitching step is precisely how a boolean develops cracks: two
21//! segments that should share an endpoint fail to join, and the curve opens.
22//! `ScalarSection` already uses this technique for plane cuts; this reuses it
23//! for the mesh-mesh case.
24//!
25//! # Honest limits
26//!
27//! Coplanar face pairs are refused, not approximated. Their intersection is
28//! an area rather than a curve, and resolving it needs a 2D overlap policy
29//! the caller must choose. Refusing keeps this module's output meaning
30//! exactly one thing.
31
32use std::collections::{BTreeMap, BTreeSet};
33
34use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation, Sign};
35use axiolid_core::Point3;
36use axiolid_mesh::TriMesh;
37
38use crate::boolean::ScalarBoolean;
39use crate::{orient3d, triangle_triangle_relation, TriangleTriangleRelation};
40
41/// Which operand a piece of source topology belongs to.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub enum Operand {
44 /// The first mesh passed to [`intersection_segments`].
45 Subject,
46 /// The second mesh passed to [`intersection_segments`].
47 Tool,
48}
49
50/// An undirected mesh edge, named by its two vertex indices in sorted order.
51///
52/// Sorting is what makes the name canonical: the two faces sharing an edge
53/// visit it in opposite directions, and both must produce the same key.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub struct EdgeKey {
56 operand: Operand,
57 low: u32,
58 high: u32,
59}
60
61impl EdgeKey {
62 pub fn new(operand: Operand, first: u32, second: u32) -> Self {
63 Self {
64 operand,
65 low: first.min(second),
66 high: first.max(second),
67 }
68 }
69}
70
71/// An intersection point, named by the source topology that produced it.
72///
73/// Never by coordinates: see the module docs for why that distinction
74/// decides whether the assembled curve can crack.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
76pub enum NodeKey {
77 /// An original mesh vertex lying exactly on the other surface.
78 Vertex {
79 /// Which operand owns the vertex.
80 operand: Operand,
81 /// Its index in that operand's position array.
82 index: u32,
83 },
84 /// An edge of one operand crossing the other operand's surface.
85 ///
86 /// Identified by the edge ALONE, deliberately. The crossed triangle is
87 /// not part of the name: a closed surface is triangulated arbitrarily,
88 /// so one puncture point can sit on a shared triangle edge and be
89 /// reported once per incident triangle. Including the face index would
90 /// give that single point two names, and the curve would fragment into
91 /// disconnected two-node pieces instead of closing into a loop.
92 ///
93 /// An edge crossing a plane it is NOT parallel to punctures it exactly
94 /// once, so the pierced plane completes the name. The plane is identified
95 /// by its own geometry rather than by a triangle index: a flat side of a
96 /// solid is triangulated arbitrarily, and naming the triangle would give
97 /// one puncture two names whenever it lands on a shared triangle edge.
98 ///
99 /// This matters for a through-cut. An edge that enters one side of a slab
100 /// and leaves the other punctures the surface TWICE; identifying the node
101 /// by the edge alone would collapse both punctures into a single name and
102 /// corrupt the curve into degree-3 nodes.
103 EdgeSurface {
104 /// The crossing edge.
105 edge: EdgeKey,
106 /// Where along that edge the puncture lies, as exact coordinate bits.
107 at: PointKey,
108 },
109}
110
111/// A point named by its exact coordinate bits.
112///
113/// Used only to disambiguate several punctures along ONE edge. It is not a
114/// coordinate-tolerance match: two punctures are the same node only when
115/// their coordinates are bit-identical, which they are when the same
116/// arithmetic produced them from the same inputs.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
118pub struct PointKey {
119 bits: [u64; 3],
120}
121
122impl PointKey {
123 pub fn new(point: Point3) -> Self {
124 Self {
125 // `+ 0.0` folds `-0.0` into `0.0` so equal points share bits.
126 bits: [point.x + 0.0, point.y + 0.0, point.z + 0.0].map(f64::to_bits),
127 }
128 }
129}
130
131/// One segment of the intersection curve, joining two nodes.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
133pub struct IntersectionSegment {
134 /// One endpoint.
135 pub start: NodeKey,
136 /// The other endpoint.
137 pub end: NodeKey,
138}
139
140impl IntersectionSegment {
141 /// Build a segment between two distinct nodes.
142 ///
143 /// Rejects a segment that collapses to one node: that is a degenerate
144 /// contact, not a piece of curve.
145 pub fn between(start: NodeKey, end: NodeKey) -> GeomResult<Self> {
146 if start == end {
147 return Err(GeomError::Degenerate(
148 "an intersection segment collapsed to one source-topology node".into(),
149 ));
150 }
151 // Canonical order, so a segment found twice compares equal.
152 Ok(Self {
153 start: start.min(end),
154 end: start.max(end),
155 })
156 }
157}
158
159/// Exact orientation sign of `point` against the plane of `triangle`.
160fn plane_sign(triangle: [Point3; 3], point: Point3) -> Sign {
161 orient3d(triangle[0], triangle[1], triangle[2], point)
162 .sign()
163 .expect("certified predicates are total")
164}
165
166/// Nodes where one face's edges meet the other face's plane.
167///
168/// Returns at most two: a triangle is convex, so its boundary crosses a
169/// plane in at most two places. A vertex exactly on the plane contributes a
170/// `Vertex` node; an edge straddling it contributes an `EdgeFace` node.
171///
172/// This finds where the edges meet the *plane*, which is a superset of where
173/// they meet the *triangle*. The caller filters to the triangle.
174fn crossing_nodes(
175 face_vertices: [u32; 3],
176 face_operand: Operand,
177 face_points: [Point3; 3],
178 other: [Point3; 3],
179) -> Vec<(NodeKey, Point3)> {
180 let signs = face_points.map(|point| plane_sign(other, point));
181 let mut nodes = Vec::new();
182
183 for corner in 0..3 {
184 let next = (corner + 1) % 3;
185
186 // A vertex ON the plane is itself an intersection point, and is
187 // named by its own index so both operands agree on it.
188 if signs[corner] == Sign::Zero {
189 nodes.push((
190 NodeKey::Vertex {
191 operand: face_operand,
192 index: face_vertices[corner],
193 },
194 face_points[corner],
195 ));
196 continue;
197 }
198
199 // A straddling edge crosses once, strictly between its endpoints.
200 // `Zero` at `next` is handled when that corner is visited, so only
201 // a genuine sign flip counts here -- otherwise the point is emitted
202 // twice under two different names.
203 if signs[next] != Sign::Zero && signs[corner] != signs[next] {
204 // Which puncture along this edge: the parameter of the crossing,
205 // as exact bits. An edge that pierces the other surface several
206 // times (a through-cut) gets a distinct name per puncture, while
207 // the SAME puncture reported from several incident triangles of a
208 // flat face gets one name, because the parameter is identical.
209 let crossing = plane_crossing(face_points[corner], face_points[next], other);
210 let key = NodeKey::EdgeSurface {
211 edge: EdgeKey::new(face_operand, face_vertices[corner], face_vertices[next]),
212 at: PointKey::new(crossing),
213 };
214 nodes.push((key, crossing));
215 }
216 }
217 nodes
218}
219
220/// Where segment `start`->`end` meets the plane of `triangle`.
221///
222/// The caller has already PROVEN a crossing exists using exact predicates;
223/// this only computes the position. That separation is deliberate: the
224/// decision is exact, and the coordinate is the best binary64 approximation
225/// of a point whose existence is certain. Rounding moves the point slightly,
226/// never invents or removes it, because the node's identity comes from its
227/// symbolic name rather than this value.
228fn plane_crossing(start: Point3, end: Point3, triangle: [Point3; 3]) -> Point3 {
229 let normal = (triangle[1] - triangle[0]).cross(triangle[2] - triangle[0]);
230 let start_height = (start - triangle[0]).dot(normal);
231 let end_height = (end - triangle[0]).dot(normal);
232 let span = start_height - end_height;
233 if span == 0.0 {
234 // Unreachable for a proven crossing: opposite exact signs cannot
235 // produce equal heights. Returning the midpoint keeps the function
236 // total rather than panicking inside a geometry kernel.
237 return start.midpoint(end);
238 }
239 start + (end - start) * (start_height / span)
240}
241
242/// Whether `point` lies within `triangle`, given it is already on its plane.
243///
244/// Uses the same exact sign test as the rest of the module: the point is
245/// inside when it is on the same side of all three edges, with `Zero`
246/// accepted so boundary contact counts as inside.
247fn point_in_triangle(point: Point3, triangle: [Point3; 3]) -> bool {
248 let normal = (triangle[1] - triangle[0]).cross(triangle[2] - triangle[0]);
249 let mut positive = false;
250 let mut negative = false;
251 for corner in 0..3 {
252 let next = (corner + 1) % 3;
253 // Build a tetrahedron from the edge, the point, and the face normal;
254 // its orientation says which side of the edge the point is on.
255 let apex = triangle[corner] + normal;
256 match plane_sign([triangle[corner], triangle[next], apex], point) {
257 Sign::Positive => positive = true,
258 Sign::Negative => negative = true,
259 // `Zero` means the point is exactly on this edge's plane, which
260 // is boundary contact and counts as inside. `Sign` is
261 // non-exhaustive, so an unknown future variant is treated the
262 // same rather than silently changing the answer.
263 _ => {}
264 }
265 }
266 !(positive && negative)
267}
268
269/// The intersection curve between two meshes, as segments plus positions.
270#[derive(Debug, Clone, Default)]
271pub struct IntersectionCurve {
272 /// Segments, deduplicated and in canonical order.
273 pub segments: Vec<IntersectionSegment>,
274 /// Position of every node named by a segment.
275 pub positions: BTreeMap<NodeKey, Point3>,
276 /// Segments lying on each subject face, keyed by face index.
277 ///
278 /// Retriangulation needs to know which constraints belong to the face it
279 /// is cutting; without this the caller would have to re-derive the
280 /// association geometrically and could disagree with what was computed.
281 pub subject_face_segments: BTreeMap<u32, Vec<IntersectionSegment>>,
282 /// Segments lying on each tool face, keyed by face index.
283 pub tool_face_segments: BTreeMap<u32, Vec<IntersectionSegment>>,
284}
285
286/// Compute the intersection curve between two triangle meshes.
287///
288/// `O(n*m)`: every face pair is tested. This is the reference implementation,
289/// so it is written to be obviously right rather than fast -- a BVH here
290/// would be a second thing to get wrong. A production provider adds one.
291///
292/// # Errors
293///
294/// Refuses coplanar face pairs, whose intersection is an area rather than a
295/// curve and needs a 2D overlap policy the caller must choose.
296pub fn intersection_segments(subject: &TriMesh, tool: &TriMesh) -> GeomResult<IntersectionCurve> {
297 let mut segments = BTreeSet::new();
298 let mut positions = BTreeMap::new();
299 let mut subject_face_segments: BTreeMap<u32, Vec<IntersectionSegment>> = BTreeMap::new();
300 let mut tool_face_segments: BTreeMap<u32, Vec<IntersectionSegment>> = BTreeMap::new();
301
302 for subject_face in 0..subject.triangle_count() {
303 let (subject_indices, subject_points) = face(subject, subject_face)?;
304 for tool_face in 0..tool.triangle_count() {
305 let (tool_indices, tool_points) = face(tool, tool_face)?;
306
307 match triangle_triangle_relation(subject_points, tool_points) {
308 // No shared point: nothing to record.
309 TriangleTriangleRelation::Disjoint => continue,
310 // An area, not a curve. Refuse rather than pick a policy.
311 TriangleTriangleRelation::Coplanar => {
312 // Sharing a plane is not sharing area. Two walls in one
313 // plane metres apart produce many coplanar pairs and no
314 // overlap at all; refusing over those would reject
315 // ordinary models. Only a real shared AREA is beyond what
316 // a curve can describe.
317 if crate::coplanar::coplanar_overlap(subject_points, tool_points).is_empty() {
318 continue;
319 }
320 return Err(GeomError::Unsupported {
321 backend: BackendId::new("scalar-intersection"),
322 operation: Operation::MeshBoolean,
323 });
324 }
325 // A degenerate source face has no well-defined plane.
326 TriangleTriangleRelation::DegenerateTriangle => {
327 return Err(GeomError::Degenerate(
328 "a source face is degenerate; heal the mesh before intersecting".into(),
329 ))
330 }
331 // Both contribute segments: `Touching` includes edge-on-face
332 // contact, which is a real part of the curve.
333 TriangleTriangleRelation::Proper | TriangleTriangleRelation::Touching => {}
334 }
335
336 let subject_normal = (subject_points[1] - subject_points[0])
337 .cross(subject_points[2] - subject_points[0]);
338 let tool_normal =
339 (tool_points[1] - tool_points[0]).cross(tool_points[2] - tool_points[0]);
340
341 // Nodes contributed by each face crossing the other's plane,
342 // filtered to those actually inside the other triangle.
343 let mut nodes = Vec::new();
344 for (key, point) in crossing_nodes(
345 subject_indices,
346 Operand::Subject,
347 subject_points,
348 tool_points,
349 ) {
350 if point_in_triangle(point, tool_points) {
351 nodes.push((key, point));
352 }
353 }
354 for (key, point) in
355 crossing_nodes(tool_indices, Operand::Tool, tool_points, subject_points)
356 {
357 if point_in_triangle(point, subject_points) {
358 nodes.push((key, point));
359 }
360 }
361
362 // One physical point can arrive under two names here: the subject
363 // edge crossing the tool surface and the tool edge crossing the
364 // subject surface coincide when the operands share coordinates.
365 // Collapsing them BEFORE choosing the interval matters: left
366 // duplicated, the interval rule picks the two identical names,
367 // the segment collapses, and a real piece of the curve vanishes.
368 nodes.sort_by(|left, right| {
369 point_bits(left.1)
370 .cmp(&point_bits(right.1))
371 .then_with(|| left.0.cmp(&right.0))
372 });
373 nodes.dedup_by(|left, right| point_bits(left.1) == point_bits(right.1));
374
375 // The two triangles' planes meet in a line; each triangle clips
376 // that line to an interval, and the curve here is the OVERLAP of
377 // those two intervals.
378 //
379 // Up to four nodes arrive: each operand's edges can puncture the
380 // other's triangle. Four is the normal transverse case, not an
381 // error -- discarding it was what cracked the curve into
382 // disconnected two-node pieces.
383 //
384 // Ordering the nodes ALONG the intersection line and taking the
385 // middle two yields exactly the shared interval: the outer two
386 // are each outside the other triangle.
387 nodes.sort_by(|left, right| left.0.cmp(&right.0));
388 nodes.dedup_by(|left, right| left.0 == right.0);
389 if nodes.len() < 2 {
390 // A single point of contact contributes no segment.
391 for (key, point) in nodes {
392 positions.insert(key, point);
393 }
394 continue;
395 }
396
397 // Direction of the plane-plane intersection line.
398 let axis = subject_normal.cross(tool_normal);
399 if axis.length_squared() == 0.0 {
400 // Parallel planes that are not coplanar cannot cross; the
401 // coplanar case was refused above.
402 for (key, point) in nodes {
403 positions.insert(key, point);
404 }
405 continue;
406 }
407 nodes.sort_by(|left, right| {
408 let left_t = left.1.dot(axis);
409 let right_t = right.1.dot(axis);
410 left_t
411 .partial_cmp(&right_t)
412 .expect("finite coordinates give an orderable projection")
413 });
414 let interval = if nodes.len() == 2 {
415 [nodes[0], nodes[1]]
416 } else {
417 [nodes[nodes.len() / 2 - 1], nodes[nodes.len() / 2]]
418 };
419 for (key, point) in nodes.iter().copied() {
420 positions.insert(key, point);
421 }
422 if interval[0].0 == interval[1].0 {
423 continue;
424 }
425
426 let segment = IntersectionSegment::between(interval[0].0, interval[1].0)?;
427 segments.insert(segment);
428 // The segment lies in BOTH faces' planes -- it is exactly where
429 // they meet -- so it constrains the retriangulation of each.
430 let subject_key = u32::try_from(subject_face).map_err(|_| face_count_error())?;
431 let tool_key = u32::try_from(tool_face).map_err(|_| face_count_error())?;
432 subject_face_segments
433 .entry(subject_key)
434 .or_default()
435 .push(segment);
436 tool_face_segments
437 .entry(tool_key)
438 .or_default()
439 .push(segment);
440 }
441 }
442
443 for list in subject_face_segments
444 .values_mut()
445 .chain(tool_face_segments.values_mut())
446 {
447 list.sort_unstable();
448 list.dedup();
449 }
450
451 Ok(IntersectionCurve {
452 segments: segments.into_iter().collect(),
453 positions,
454 subject_face_segments,
455 tool_face_segments,
456 })
457}
458
459/// A connected run of the intersection curve.
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct Polyline {
462 /// Nodes in traversal order.
463 pub nodes: Vec<NodeKey>,
464 /// Whether the run returns to its first node.
465 ///
466 /// A closed loop is the normal result for two closed solids. An open
467 /// run means the curve reached a boundary, which a closed operand
468 /// should not have.
469 pub closed: bool,
470}
471
472/// Stitch segments into maximal connected polylines.
473///
474/// Pure integer graph traversal: nodes are symbolic names, so joining two
475/// segments is an equality test rather than a distance comparison. No
476/// tolerance is involved at any point.
477///
478/// # Errors
479///
480/// Refuses a node of degree three or more. On a clean pair of closed
481/// surfaces the intersection curve is a 1-manifold, so every node has one
482/// or two neighbours; a branch means the input is self-intersecting or
483/// non-manifold, and continuing would silently pick one arbitrary path.
484pub fn assemble_polylines(segments: &[IntersectionSegment]) -> GeomResult<Vec<Polyline>> {
485 let mut adjacency: BTreeMap<NodeKey, Vec<NodeKey>> = BTreeMap::new();
486 for segment in segments {
487 adjacency
488 .entry(segment.start)
489 .or_default()
490 .push(segment.end);
491 adjacency
492 .entry(segment.end)
493 .or_default()
494 .push(segment.start);
495 }
496 for (node, neighbours) in &mut adjacency {
497 neighbours.sort_unstable();
498 neighbours.dedup();
499 if neighbours.len() > 2 {
500 return Err(GeomError::NotManifold(format!(
501 "intersection curve branches at {node:?} with degree {}",
502 neighbours.len()
503 )));
504 }
505 // The intersection of two CLOSED surfaces is a set of closed rings, so
506 // every node must have exactly two neighbours. A degree-1 node means
507 // the curve was cut short -- in practice because an edge of one
508 // operand crosses an edge of the other at the same point, and each
509 // operand named that puncture from its own side. The two names do not
510 // join, so the ring opens.
511 //
512 // Refuse rather than return the broken run. Merging coincident names
513 // by coordinate was tried and rejected: it welded genuinely distinct
514 // nodes in the corner-overlap case, replacing a visible failure with a
515 // quiet wrong answer.
516 if neighbours.len() < 2 {
517 return Err(GeomError::Unsupported {
518 backend: ScalarBoolean::ID,
519 operation: Operation::MeshBoolean,
520 });
521 }
522 }
523
524 let mut visited = BTreeSet::new();
525 let mut polylines = Vec::new();
526
527 // Open runs first: starting from a degree-1 node walks the whole run in
528 // one pass. Starting mid-run would produce two half-runs instead.
529 let endpoints: Vec<NodeKey> = adjacency
530 .iter()
531 .filter(|(_, neighbours)| neighbours.len() == 1)
532 .map(|(node, _)| *node)
533 .collect();
534 for start in endpoints {
535 if visited.contains(&start) {
536 continue;
537 }
538 polylines.push(walk(start, &adjacency, &mut visited, false));
539 }
540
541 // Whatever remains is a cycle: every node has degree two.
542 let cycle_starts: Vec<NodeKey> = adjacency.keys().copied().collect();
543 for start in cycle_starts {
544 if visited.contains(&start) {
545 continue;
546 }
547 polylines.push(walk(start, &adjacency, &mut visited, true));
548 }
549
550 Ok(polylines)
551}
552
553/// Walk one connected run from `start`, marking nodes visited.
554fn walk(
555 start: NodeKey,
556 adjacency: &BTreeMap<NodeKey, Vec<NodeKey>>,
557 visited: &mut BTreeSet<NodeKey>,
558 closed: bool,
559) -> Polyline {
560 let mut nodes = vec![start];
561 visited.insert(start);
562 let mut current = start;
563 let mut previous = None;
564
565 loop {
566 let Some(neighbours) = adjacency.get(¤t) else {
567 break;
568 };
569 // Step to the neighbour we did not arrive from. On a cycle both are
570 // unvisited at the first step, so the choice of direction is
571 // arbitrary but consistent -- `adjacency` is sorted.
572 let next = neighbours
573 .iter()
574 .copied()
575 .find(|candidate| Some(*candidate) != previous && !visited.contains(candidate));
576 let Some(next) = next else {
577 break;
578 };
579 nodes.push(next);
580 visited.insert(next);
581 previous = Some(current);
582 current = next;
583 }
584
585 Polyline { nodes, closed }
586}
587
588/// Vertex indices and positions of one face.
589fn face(mesh: &TriMesh, index: usize) -> GeomResult<([u32; 3], [Point3; 3])> {
590 let base = index * 3;
591 let indices: [u32; 3] = mesh
592 .indices
593 .get(base..base + 3)
594 .ok_or_else(|| GeomError::Degenerate("face index out of range".into()))?
595 .try_into()
596 .map_err(|_| GeomError::Degenerate("face index slice is not three wide".into()))?;
597 let mut points = [Point3::ZERO; 3];
598 for (slot, vertex) in indices.iter().enumerate() {
599 points[slot] = *mesh
600 .positions
601 .get(*vertex as usize)
602 .ok_or_else(|| GeomError::Degenerate("face references a missing vertex".into()))?;
603 }
604 Ok((indices, points))
605}
606
607/// A mesh with more faces than a `u32` index can name.
608fn face_count_error() -> GeomError {
609 GeomError::Degenerate("mesh has more faces than a u32 index can name".into())
610}
611
612/// Exact coordinate bits, with `-0.0` folded into `0.0` so equal points match.
613///
614/// UNPROVEN: no fixture produces a `-0.0` coordinate, so removing the fold
615/// leaves the suite green. It is kept because `-0.0` and `0.0` compare equal
616/// as numbers but differ in bits, which would split one point into two names
617/// exactly like the bug this function exists to fix.
618fn point_bits(point: Point3) -> [u64; 3] {
619 [point.x + 0.0, point.y + 0.0, point.z + 0.0].map(f64::to_bits)
620}