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(&current) 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}