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    let normal = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
93    if normal.length_squared() == 0.0 {
94        return Err(GeomError::Degenerate(
95            "cannot retriangulate a degenerate face".into(),
96        ));
97    }
98
99    let mut points: Vec<Point3> = corners.to_vec();
100    let mut sources: Vec<Option<NodeKey>> = vec![None; 3];
101    let mut index_of: BTreeMap<NodeKey, u32> = BTreeMap::new();
102
103    // A node can lie on this face's EDGE without any segment crossing this
104    // face -- the curve runs through the neighbour instead. Splitting the
105    // edge here anyway is what keeps the two faces combinatorially matched.
106    //
107    // Skipping this leaves a T-junction: the neighbour splits the shared edge
108    // at the node while this face keeps it whole, so the edge is used once
109    // from each side under different names and the surface reads as open.
110    // The volume still comes out right, which is exactly why this needs an
111    // explicit closure check rather than a volume check to catch.
112    let mut edge_nodes: Vec<(NodeKey, Point3)> = Vec::new();
113    for (&node, &point) in positions {
114        if corners.contains(&point) {
115            continue;
116        }
117        if point_on_face_edge(point, corners) {
118            edge_nodes.push((node, point));
119        }
120    }
121    for (node, point) in edge_nodes {
122        if index_of.contains_key(&node) {
123            continue;
124        }
125        index_of.insert(node, points.len() as u32);
126        points.push(point);
127        sources.push(Some(node));
128    }
129
130    // With no cut and no edge node, the face is already its own triangulation.
131    if segments.is_empty() && points.len() == 3 {
132        return Ok(FacePatch {
133            points: corners.to_vec(),
134            sources: vec![None; 3],
135            triangles: vec![[0, 1, 2]],
136        });
137    }
138
139    // Curve nodes join the corner list. A node that coincides with a corner
140    // reuses that corner's index instead of adding a duplicate point, which
141    // would leave the triangulation with a zero-length edge.
142    for segment in segments {
143        for node in [segment.start, segment.end] {
144            if index_of.contains_key(&node) {
145                continue;
146            }
147            let point = *positions.get(&node).ok_or_else(|| {
148                GeomError::Degenerate("curve node has no recorded position".into())
149            })?;
150            if let Some(corner) = corners.iter().position(|&c| c == point) {
151                index_of.insert(node, corner as u32);
152                sources[corner] = Some(node);
153                continue;
154            }
155            index_of.insert(node, points.len() as u32);
156            points.push(point);
157            sources.push(Some(node));
158        }
159    }
160
161    let axis = dominant_axis(normal);
162    let flat: Vec<Point2> = points.iter().map(|&p| project(p, axis)).collect();
163
164    // Constraint edges, as index pairs into `points`.
165    let mut constraints: Vec<(u32, u32)> = Vec::new();
166    for segment in segments {
167        let start = index_of[&segment.start];
168        let end = index_of[&segment.end];
169        if start != end {
170            constraints.push((start.min(end), start.max(end)));
171        }
172    }
173    constraints.sort_unstable();
174    constraints.dedup();
175
176    let triangles = triangulate_with_constraints(&flat, &constraints)?;
177
178    // The 2D work happens in projected space, whose handedness depends on
179    // which axis was dropped and which way the face pointed. Rewinding
180    // against the ORIGINAL normal restores the source orientation, so the
181    // patch can be substituted for the face without flipping it.
182    let triangles = triangles
183        .into_iter()
184        .map(|tri| {
185            let [a, b, c] = tri.map(|i| points[i as usize]);
186            if (b - a).cross(c - a).dot(normal) < 0.0 {
187                [tri[0], tri[2], tri[1]]
188            } else {
189                tri
190            }
191        })
192        .collect();
193
194    Ok(FacePatch {
195        points,
196        sources,
197        triangles,
198    })
199}
200
201/// Triangulate a point set so every constraint edge appears in the output.
202///
203/// # Approach
204///
205/// A brute-force maximal triangulation: consider every candidate triangle,
206/// keep those that are non-degenerate, contain no other point, and cross no
207/// constraint. `O(n^4)`, which is the right trade for a reference -- it is
208/// short enough to audit line by line, and the input is one triangle's worth
209/// of points, not a mesh.
210///
211/// A production provider would use a proper CDT. This exists to be
212/// obviously correct, so a fast implementation has something to be checked
213/// against.
214fn triangulate_with_constraints(
215    points: &[Point2],
216    constraints: &[(u32, u32)],
217) -> GeomResult<Vec<[u32; 3]>> {
218    let count = points.len();
219    let mut triangles: Vec<[u32; 3]> = Vec::new();
220
221    for a in 0..count {
222        for b in (a + 1)..count {
223            for c in (b + 1)..count {
224                let tri = [a as u32, b as u32, c as u32];
225                let [pa, pb, pc] = [points[a], points[b], points[c]];
226
227                // A collinear triple has no area and would contribute a
228                // sliver that later orientation tests cannot classify.
229                if sign(orient2d(pa, pb, pc)) == Sign::Zero {
230                    continue;
231                }
232                // A triangle covering another point is not part of any
233                // valid triangulation of the full point set.
234                //
235                // UNPROVEN: no fixture reaches this branch, and mutating it
236                // away leaves every test passing. It is kept because the
237                // smallest-area-first order makes a covering triangle lose
238                // anyway, not because a test demonstrates the need. Delete it
239                // only alongside a case that shows it is genuinely dead.
240                if (0..count).any(|other| {
241                    other != a
242                        && other != b
243                        && other != c
244                        && point_inside(points[other], [pa, pb, pc])
245                }) {
246                    continue;
247                }
248                // A triangle edge cutting across a constraint would erase
249                // the cut the whole operation exists to make.
250                if crosses_a_constraint(tri, points, constraints) {
251                    continue;
252                }
253                triangles.push(tri);
254            }
255        }
256    }
257
258    // Candidates may still overlap each other, so a maximal non-overlapping
259    // subset has to be chosen. Order matters: a greedy pass that took the
260    // whole face first would block every finer triangle, since the face
261    // overlaps all of them, and the cut would vanish. Smallest-area-first
262    // makes the fine pieces win and the coarse cover lose.
263    //
264    // Ties break on the index triple so the result is deterministic for a
265    // given input rather than dependent on sort stability.
266    triangles.sort_by(|left, right| {
267        let area = |t: &[u32; 3]| {
268            let [a, b, c] = t.map(|i| points[i as usize]);
269            ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs()
270        };
271        area(left)
272            .partial_cmp(&area(right))
273            .expect("finite coordinates give comparable areas")
274            .then_with(|| left.cmp(right))
275    });
276
277    let mut kept: Vec<[u32; 3]> = Vec::new();
278    for tri in triangles {
279        if kept.iter().any(|existing| overlaps(*existing, tri, points)) {
280            continue;
281        }
282        kept.push(tri);
283    }
284
285    if kept.is_empty() {
286        return Err(GeomError::Degenerate(
287            "no valid triangle survives the constraints".into(),
288        ));
289    }
290
291    // Every constraint must survive as an edge of some kept triangle.
292    // Reporting this rather than returning a plausible-looking mesh is the
293    // difference between a refusal and a silently wrong cut.
294    //
295    // UNPROVEN: no fixture triggers this refusal. Mutating it away leaves the
296    // suite green, so it is a belt-and-braces check, not a tested guarantee.
297    // A case that reaches it would be a valuable addition.
298    for &(start, end) in constraints {
299        let present = kept.iter().any(|tri| {
300            [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]
301                .iter()
302                .any(|&(u, v)| (u.min(v), u.max(v)) == (start, end))
303        });
304        if !present {
305            return Err(GeomError::Unsupported {
306                backend: axiolid_contracts::BackendId::new("scalar-retriangulate"),
307                operation: axiolid_contracts::Operation::MeshBoolean,
308            });
309        }
310    }
311
312    Ok(kept)
313}
314
315/// Strictly inside the triangle: on an edge does not count.
316///
317/// Boundary points are excluded deliberately. A point ON an edge is shared
318/// with the neighbouring triangle and does not invalidate either.
319fn point_inside(point: Point2, [a, b, c]: [Point2; 3]) -> bool {
320    let signs = [
321        sign(orient2d(a, b, point)),
322        sign(orient2d(b, c, point)),
323        sign(orient2d(c, a, point)),
324    ];
325    signs.iter().all(|&s| s == Sign::Positive) || signs.iter().all(|&s| s == Sign::Negative)
326}
327
328/// Whether any edge of `tri` properly crosses any constraint.
329///
330/// Sharing an endpoint is not a crossing: constraints meet each other and
331/// the face boundary at nodes, which is exactly what they are meant to do.
332fn crosses_a_constraint(tri: [u32; 3], points: &[Point2], constraints: &[(u32, u32)]) -> bool {
333    let edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])];
334    for &(u, v) in &edges {
335        for &(s, e) in constraints {
336            if u == s || u == e || v == s || v == e {
337                continue;
338            }
339            if segments_properly_cross(
340                [points[u as usize], points[v as usize]],
341                [points[s as usize], points[e as usize]],
342            ) {
343                return true;
344            }
345        }
346    }
347    false
348}
349
350/// Two segments crossing at an interior point of both.
351fn segments_properly_cross([a, b]: [Point2; 2], [c, d]: [Point2; 2]) -> bool {
352    let d1 = sign(orient2d(a, b, c));
353    let d2 = sign(orient2d(a, b, d));
354    let d3 = sign(orient2d(c, d, a));
355    let d4 = sign(orient2d(c, d, b));
356    d1 != Sign::Zero
357        && d2 != Sign::Zero
358        && d3 != Sign::Zero
359        && d4 != Sign::Zero
360        && d1 != d2
361        && d3 != d4
362}
363
364/// Whether two triangles share interior area.
365///
366/// Tested by centroid containment both ways plus proper edge crossings.
367/// Triangles that merely share a vertex or an edge do not overlap, which is
368/// the normal case in any triangulation.
369fn overlaps(first: [u32; 3], second: [u32; 3], points: &[Point2]) -> bool {
370    let fa = first.map(|i| points[i as usize]);
371    let sa = second.map(|i| points[i as usize]);
372
373    if point_inside(centroid(fa), sa) || point_inside(centroid(sa), fa) {
374        return true;
375    }
376    for i in 0..3 {
377        for j in 0..3 {
378            let first_edge = [fa[i], fa[(i + 1) % 3]];
379            let second_edge = [sa[j], sa[(j + 1) % 3]];
380            if segments_properly_cross(first_edge, second_edge) {
381                return true;
382            }
383        }
384    }
385    false
386}
387
388/// The average of three corners, which lies strictly inside the triangle.
389fn centroid([a, b, c]: [Point2; 3]) -> Point2 {
390    Point2::new((a.x + b.x + c.x) / 3.0, (a.y + b.y + c.y) / 3.0)
391}
392
393fn sign(value: axiolid_contracts::Certified) -> Sign {
394    value.sign().expect("certified predicates are total")
395}
396
397/// Whether `point` lies exactly on one of the face's three edges.
398///
399/// Exact: the point must be collinear with the edge by `orient3d`-grade
400/// reasoning and lie within its span. Used to find T-junction nodes that
401/// belong to this face's boundary even though no segment crosses the face.
402fn point_on_face_edge(point: Point3, corners: [Point3; 3]) -> bool {
403    for i in 0..3 {
404        let a = corners[i];
405        let b = corners[(i + 1) % 3];
406        let ab = b - a;
407        let ap = point - a;
408        // Collinear, and strictly between the endpoints.
409        if ab.cross(ap).length_squared() != 0.0 {
410            continue;
411        }
412        let t = ab.dot(ap);
413        if t > 0.0 && t < ab.dot(ab) {
414            return true;
415        }
416    }
417    false
418}