Skip to main content

axiolid_reference/
retriangulate.rs

1//! Retriangulating a face against the intersection curve crossing it.
2//!
3//! # What this produces
4//!
5//! A face cut by the intersection curve is replaced by triangles whose
6//! edges follow that curve. Every output triangle then lies wholly inside or
7//! wholly outside the other solid, so classification becomes a per-triangle
8//! question with no further geometry -- which is what makes an exact boolean
9//! possible.
10//!
11//! # Why the work happens in 2D
12//!
13//! All points involved lie in the face's plane by construction: the face's
14//! own corners, and curve nodes that were computed as crossings OF that
15//! plane. Projecting along the plane's dominant axis is therefore exact in
16//! the sense that matters -- it drops a coordinate that carries no
17//! information, rather than approximating one that does.
18//!
19//! The dominant axis is chosen from the largest normal component so the
20//! projection never collapses: picking a near-perpendicular axis would
21//! squash the triangle to a sliver and lose the orientation the
22//! triangulation depends on.
23//!
24//! # Honest limits
25//!
26//! This handles the case the intersection curve actually produces for the
27//! operands `ScalarBoolean` supports: a face crossed by a chain of segments
28//! that enters and leaves through its boundary. A curve forming a closed
29//! loop strictly INSIDE one face is refused -- it needs a hole-aware
30//! triangulation, and inventing a bridge edge to fake it would produce a
31//! mesh whose topology no longer matches the geometry.
32
33use std::collections::BTreeMap;
34
35use axiolid_contracts::{GeomError, GeomResult, Sign};
36use axiolid_core::{Point2, Point3};
37
38use crate::intersection::{IntersectionSegment, NodeKey};
39use crate::orient2d;
40
41/// A face's corners plus the curve nodes lying on it, ready to triangulate.
42#[derive(Debug, Clone)]
43pub struct FacePatch {
44    /// Every point, in the order the triangulation indexes them.
45    ///
46    /// Corners come first so the original winding stays recoverable.
47    pub points: Vec<Point3>,
48    /// Which curve node each point came from, where it came from one.
49    ///
50    /// `None` marks an original face corner. Retaining this lets a caller
51    /// weld patches from adjacent faces by NODE IDENTITY rather than by
52    /// comparing coordinates -- the same discipline the curve itself uses.
53    pub sources: Vec<Option<NodeKey>>,
54    /// Triangles as indices into `points`, wound like the source face.
55    pub triangles: Vec<[u32; 3]>,
56}
57
58/// Which axis to drop when flattening, chosen from the largest normal term.
59fn dominant_axis(normal: Point3) -> usize {
60    let absolute = normal.abs();
61    if absolute.x >= absolute.y && absolute.x >= absolute.z {
62        0
63    } else if absolute.y >= absolute.z {
64        1
65    } else {
66        2
67    }
68}
69
70/// Drop `axis`, keeping the other two coordinates in a fixed order.
71fn project(point: Point3, axis: usize) -> Point2 {
72    match axis {
73        0 => Point2::new(point.y, point.z),
74        1 => Point2::new(point.x, point.z),
75        _ => Point2::new(point.x, point.y),
76    }
77}
78
79/// Retriangulate one face against the curve segments lying on it.
80///
81/// `corners` are the face's three vertices, wound as the source mesh winds
82/// them. `segments` are the curve segments on this face, and `positions`
83/// resolves their nodes to coordinates.
84///
85/// The result reproduces the face exactly when `segments` is empty, so a
86/// caller can run every face through this without special-casing.
87pub fn retriangulate_face(
88    corners: [Point3; 3],
89    segments: &[IntersectionSegment],
90    positions: &BTreeMap<NodeKey, Point3>,
91) -> GeomResult<FacePatch> {
92    // No orientation predicate can decide a NaN or infinite coordinate, and
93    // `sign` below relies on every predicate being certain.
94    if corners
95        .iter()
96        .chain(positions.values())
97        .any(|point| !point.is_finite())
98    {
99        return Err(GeomError::InvalidInput(
100            "face corners and node positions must be finite".into(),
101        ));
102    }
103    let normal = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
104    if normal.length_squared() == 0.0 {
105        return Err(GeomError::Degenerate(
106            "cannot retriangulate a degenerate face".into(),
107        ));
108    }
109
110    let mut points: Vec<Point3> = corners.to_vec();
111    let mut sources: Vec<Option<NodeKey>> = vec![None; 3];
112    let mut index_of: BTreeMap<NodeKey, u32> = BTreeMap::new();
113
114    // A node can lie on this face's EDGE without any segment crossing this
115    // face -- the curve runs through the neighbour instead. Splitting the
116    // edge here anyway is what keeps the two faces combinatorially matched.
117    //
118    // Skipping this leaves a T-junction: the neighbour splits the shared edge
119    // at the node while this face keeps it whole, so the edge is used once
120    // from each side under different names and the surface reads as open.
121    // The volume still comes out right, which is exactly why this needs an
122    // explicit closure check rather than a volume check to catch.
123    let mut edge_nodes: Vec<(NodeKey, Point3)> = Vec::new();
124    for (&node, &point) in positions {
125        if corners.contains(&point) {
126            continue;
127        }
128        if point_on_face_edge(point, corners) {
129            edge_nodes.push((node, point));
130        }
131    }
132    for (node, point) in edge_nodes {
133        if index_of.contains_key(&node) {
134            continue;
135        }
136        index_of.insert(node, points.len() as u32);
137        points.push(point);
138        sources.push(Some(node));
139    }
140
141    // With no cut and no edge node, the face is already its own triangulation.
142    if segments.is_empty() && points.len() == 3 {
143        return Ok(FacePatch {
144            points: corners.to_vec(),
145            sources: vec![None; 3],
146            triangles: vec![[0, 1, 2]],
147        });
148    }
149
150    // Curve nodes join the corner list. A node that coincides with a corner
151    // reuses that corner's index instead of adding a duplicate point, which
152    // would leave the triangulation with a zero-length edge.
153    for segment in segments {
154        for node in [segment.start, segment.end] {
155            if index_of.contains_key(&node) {
156                continue;
157            }
158            let point = *positions.get(&node).ok_or_else(|| {
159                GeomError::Degenerate("curve node has no recorded position".into())
160            })?;
161            if let Some(corner) = corners.iter().position(|&c| c == point) {
162                index_of.insert(node, corner as u32);
163                sources[corner] = Some(node);
164                continue;
165            }
166            index_of.insert(node, points.len() as u32);
167            points.push(point);
168            sources.push(Some(node));
169        }
170    }
171
172    let axis = dominant_axis(normal);
173    let flat: Vec<Point2> = points.iter().map(|&p| project(p, axis)).collect();
174
175    // Constraint edges, as index pairs into `points`.
176    let mut constraints: Vec<(u32, u32)> = Vec::new();
177    for segment in segments {
178        let start = index_of[&segment.start];
179        let end = index_of[&segment.end];
180        if start != end {
181            constraints.push((start.min(end), start.max(end)));
182        }
183    }
184    constraints.sort_unstable();
185    constraints.dedup();
186
187    let triangles = triangulate_with_constraints(&flat, &constraints)?;
188
189    // The 2D work happens in projected space, whose handedness depends on
190    // which axis was dropped and which way the face pointed. Rewinding
191    // against the ORIGINAL normal restores the source orientation, so the
192    // patch can be substituted for the face without flipping it.
193    let triangles = triangles
194        .into_iter()
195        .map(|tri| {
196            let [a, b, c] = tri.map(|i| points[i as usize]);
197            if (b - a).cross(c - a).dot(normal) < 0.0 {
198                [tri[0], tri[2], tri[1]]
199            } else {
200                tri
201            }
202        })
203        .collect();
204
205    Ok(FacePatch {
206        points,
207        sources,
208        triangles,
209    })
210}
211
212/// Triangulate a point set so every constraint edge appears in the output.
213///
214/// # Approach
215///
216/// A brute-force maximal triangulation: consider every candidate triangle,
217/// keep those that are non-degenerate, contain no other point, and cross no
218/// constraint. `O(n^4)`, which is the right trade for a reference -- it is
219/// short enough to audit line by line, and the input is one triangle's worth
220/// of points, not a mesh.
221///
222/// A production provider would use a proper CDT. This exists to be
223/// obviously correct, so a fast implementation has something to be checked
224/// against.
225fn triangulate_with_constraints(
226    points: &[Point2],
227    constraints: &[(u32, u32)],
228) -> GeomResult<Vec<[u32; 3]>> {
229    let count = points.len();
230    let mut triangles: Vec<[u32; 3]> = Vec::new();
231
232    for a in 0..count {
233        for b in (a + 1)..count {
234            for c in (b + 1)..count {
235                let tri = [a as u32, b as u32, c as u32];
236                let [pa, pb, pc] = [points[a], points[b], points[c]];
237
238                // A collinear triple has no area and would contribute a
239                // sliver that later orientation tests cannot classify.
240                if sign(orient2d(pa, pb, pc)) == Sign::Zero {
241                    continue;
242                }
243                // A triangle covering another point is not part of any
244                // valid triangulation of the full point set.
245                //
246                // UNPROVEN: no fixture reaches this branch, and mutating it
247                // away leaves every test passing. It is kept because the
248                // smallest-area-first order makes a covering triangle lose
249                // anyway, not because a test demonstrates the need. Delete it
250                // only alongside a case that shows it is genuinely dead.
251                if (0..count).any(|other| {
252                    other != a
253                        && other != b
254                        && other != c
255                        && point_inside(points[other], [pa, pb, pc])
256                }) {
257                    continue;
258                }
259                // A triangle edge cutting across a constraint would erase
260                // the cut the whole operation exists to make.
261                if crosses_a_constraint(tri, points, constraints) {
262                    continue;
263                }
264                triangles.push(tri);
265            }
266        }
267    }
268
269    // Candidates may still overlap each other, so a maximal non-overlapping
270    // subset has to be chosen. Order matters: a greedy pass that took the
271    // whole face first would block every finer triangle, since the face
272    // overlaps all of them, and the cut would vanish. Smallest-area-first
273    // makes the fine pieces win and the coarse cover lose.
274    //
275    // Ties break on the index triple so the result is deterministic for a
276    // given input rather than dependent on sort stability.
277    triangles.sort_by(|left, right| {
278        let area = |t: &[u32; 3]| {
279            let [a, b, c] = t.map(|i| points[i as usize]);
280            ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs()
281        };
282        area(left)
283            .partial_cmp(&area(right))
284            .expect("finite coordinates give comparable areas")
285            .then_with(|| left.cmp(right))
286    });
287
288    let mut kept: Vec<[u32; 3]> = Vec::new();
289    for tri in triangles {
290        if kept.iter().any(|existing| overlaps(*existing, tri, points)) {
291            continue;
292        }
293        kept.push(tri);
294    }
295
296    if kept.is_empty() {
297        return Err(GeomError::Degenerate(
298            "no valid triangle survives the constraints".into(),
299        ));
300    }
301
302    // Every constraint must survive as an edge of some kept triangle.
303    // Reporting this rather than returning a plausible-looking mesh is the
304    // difference between a refusal and a silently wrong cut.
305    //
306    // UNPROVEN: no fixture triggers this refusal. Mutating it away leaves the
307    // suite green, so it is a belt-and-braces check, not a tested guarantee.
308    // A case that reaches it would be a valuable addition.
309    for &(start, end) in constraints {
310        let present = kept.iter().any(|tri| {
311            [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]
312                .iter()
313                .any(|&(u, v)| (u.min(v), u.max(v)) == (start, end))
314        });
315        if !present {
316            return Err(GeomError::Unsupported {
317                backend: axiolid_contracts::BackendId::new("scalar-retriangulate"),
318                operation: axiolid_contracts::Operation::MeshBoolean,
319            });
320        }
321    }
322
323    Ok(kept)
324}
325
326/// Strictly inside the triangle: on an edge does not count.
327///
328/// Boundary points are excluded deliberately. A point ON an edge is shared
329/// with the neighbouring triangle and does not invalidate either.
330fn point_inside(point: Point2, [a, b, c]: [Point2; 3]) -> bool {
331    let signs = [
332        sign(orient2d(a, b, point)),
333        sign(orient2d(b, c, point)),
334        sign(orient2d(c, a, point)),
335    ];
336    signs.iter().all(|&s| s == Sign::Positive) || signs.iter().all(|&s| s == Sign::Negative)
337}
338
339/// Whether any edge of `tri` properly crosses any constraint.
340///
341/// Sharing an endpoint is not a crossing: constraints meet each other and
342/// the face boundary at nodes, which is exactly what they are meant to do.
343fn crosses_a_constraint(tri: [u32; 3], points: &[Point2], constraints: &[(u32, u32)]) -> bool {
344    let edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])];
345    for &(u, v) in &edges {
346        for &(s, e) in constraints {
347            if u == s || u == e || v == s || v == e {
348                continue;
349            }
350            if segments_properly_cross(
351                [points[u as usize], points[v as usize]],
352                [points[s as usize], points[e as usize]],
353            ) {
354                return true;
355            }
356        }
357    }
358    false
359}
360
361/// Two segments crossing at an interior point of both.
362fn segments_properly_cross([a, b]: [Point2; 2], [c, d]: [Point2; 2]) -> bool {
363    let d1 = sign(orient2d(a, b, c));
364    let d2 = sign(orient2d(a, b, d));
365    let d3 = sign(orient2d(c, d, a));
366    let d4 = sign(orient2d(c, d, b));
367    d1 != Sign::Zero
368        && d2 != Sign::Zero
369        && d3 != Sign::Zero
370        && d4 != Sign::Zero
371        && d1 != d2
372        && d3 != d4
373}
374
375/// Whether two triangles share interior area.
376///
377/// Tested by centroid containment both ways plus proper edge crossings.
378/// Triangles that merely share a vertex or an edge do not overlap, which is
379/// the normal case in any triangulation.
380fn overlaps(first: [u32; 3], second: [u32; 3], points: &[Point2]) -> bool {
381    let fa = first.map(|i| points[i as usize]);
382    let sa = second.map(|i| points[i as usize]);
383
384    if point_inside(centroid(fa), sa) || point_inside(centroid(sa), fa) {
385        return true;
386    }
387    for i in 0..3 {
388        for j in 0..3 {
389            let first_edge = [fa[i], fa[(i + 1) % 3]];
390            let second_edge = [sa[j], sa[(j + 1) % 3]];
391            if segments_properly_cross(first_edge, second_edge) {
392                return true;
393            }
394        }
395    }
396    false
397}
398
399/// The average of three corners, which lies strictly inside the triangle.
400fn centroid([a, b, c]: [Point2; 3]) -> Point2 {
401    Point2::new((a.x + b.x + c.x) / 3.0, (a.y + b.y + c.y) / 3.0)
402}
403
404fn sign(value: axiolid_contracts::Certified) -> Sign {
405    value
406        .sign()
407        .expect("predicates are certain for the finite coordinates checked on entry")
408}
409
410/// Whether `point` lies exactly on one of the face's three edges.
411///
412/// Exact: the point must be collinear with the edge by `orient3d`-grade
413/// reasoning and lie within its span. Used to find T-junction nodes that
414/// belong to this face's boundary even though no segment crosses the face.
415fn point_on_face_edge(point: Point3, corners: [Point3; 3]) -> bool {
416    for i in 0..3 {
417        let a = corners[i];
418        let b = corners[(i + 1) % 3];
419        let ab = b - a;
420        let ap = point - a;
421        // Collinear, and strictly between the endpoints.
422        if ab.cross(ap).length_squared() != 0.0 {
423            continue;
424        }
425        let t = ab.dot(ap);
426        if t > 0.0 && t < ab.dot(ab) {
427            return true;
428        }
429    }
430    false
431}