axiolid_reference/
coplanar.rs

1//! Overlap between two coplanar triangles.
2//!
3//! # Why this exists
4//!
5//! Two coplanar faces meet in an AREA, not a curve, so the intersection-curve
6//! module cannot describe them and used to refuse the whole operation. Real
7//! building models hit this constantly: flush walls, stacked slabs, a column
8//! sharing a face with the floor it stands on.
9//!
10//! # What it computes
11//!
12//! The overlap polygon of the two triangles, exactly. Both are convex, so the
13//! overlap is a convex polygon of at most six vertices, obtained by clipping
14//! one against each edge of the other.
15//!
16//! Two results matter to the caller and are deliberately distinguished:
17//!
18//! - **Empty** -- the faces share a plane but no area. They contribute
19//!   nothing, and refusing the operation over them would be wrong. Measured:
20//!   two walls in one plane five metres apart produce sixteen coplanar face
21//!   pairs and zero shared area.
22//! - **Non-empty** -- the polygon's boundary is where the surfaces stop
23//!   coinciding, and those edges constrain the retriangulation of both faces.
24//!
25//! # Exactness
26//!
27//! Every inside/outside decision is an `orient2d` sign. Vertex positions of
28//! the clipped polygon are computed only after the crossing they represent
29//! has been proven to exist, the same discipline the intersection curve uses.
30
31use axiolid_contracts::Sign;
32use axiolid_core::{Point2, Point3};
33
34use crate::orient2d;
35
36/// The dominant axis of `normal`, dropped when projecting to 2D.
37///
38/// Dropping the largest component keeps the projection non-degenerate: the
39/// triangle cannot collapse to a line in the remaining two coordinates.
40#[must_use]
41pub fn dominant_axis(normal: Point3) -> usize {
42    let (x, y, z) = (normal.x.abs(), normal.y.abs(), normal.z.abs());
43    if x >= y && x >= z {
44        0
45    } else if y >= z {
46        1
47    } else {
48        2
49    }
50}
51
52/// Drop `axis` from `point`, giving the 2D projection.
53#[must_use]
54pub fn project(point: Point3, axis: usize) -> Point2 {
55    match axis {
56        0 => Point2::new(point.y, point.z),
57        1 => Point2::new(point.x, point.z),
58        _ => Point2::new(point.x, point.y),
59    }
60}
61
62/// Lift a 2D point back onto the plane of `reference`, along `axis`.
63///
64/// The dropped coordinate is recovered from the plane equation rather than
65/// carried along, so the lifted point lies on the plane by construction
66/// instead of by accumulated arithmetic.
67fn lift(flat: Point2, axis: usize, reference: [Point3; 3]) -> Point3 {
68    let normal = (reference[1] - reference[0]).cross(reference[2] - reference[0]);
69    let origin = reference[0];
70    // Solve normal . (p - origin) = 0 for the dropped coordinate.
71    match axis {
72        0 => {
73            let (y, z) = (flat.x, flat.y);
74            let x = origin.x - (normal.y * (y - origin.y) + normal.z * (z - origin.z)) / normal.x;
75            Point3::new(x, y, z)
76        }
77        1 => {
78            let (x, z) = (flat.x, flat.y);
79            let y = origin.y - (normal.x * (x - origin.x) + normal.z * (z - origin.z)) / normal.y;
80            Point3::new(x, y, z)
81        }
82        _ => {
83            let (x, y) = (flat.x, flat.y);
84            let z = origin.z - (normal.x * (x - origin.x) + normal.y * (y - origin.y)) / normal.z;
85            Point3::new(x, y, z)
86        }
87    }
88}
89
90/// The overlap of two coplanar triangles, as a 3D polygon.
91///
92/// `subject` and `clip` must be coplanar; the caller establishes that with
93/// [`triangle_triangle_relation`](crate::triangle_triangle_relation).
94///
95/// Returns the overlap polygon's vertices in order, or an empty vector when
96/// the triangles share a plane but no area. An empty result is a normal
97/// answer, not a failure: coplanar faces that do not overlap are common and
98/// contribute nothing to a boolean.
99///
100/// A result with fewer than three vertices is returned empty. Such a polygon
101/// has no area -- the triangles meet along an edge or at a point -- and
102/// reporting it as an overlap would create degenerate faces downstream.
103#[must_use]
104pub fn coplanar_overlap(subject: [Point3; 3], clip: [Point3; 3]) -> Vec<Point3> {
105    let normal = (clip[1] - clip[0]).cross(clip[2] - clip[0]);
106    let axis = dominant_axis(normal);
107
108    let flat_clip = clip.map(|p| project(p, axis));
109    // Clip against consistently-wound edges, so 'inside' is one fixed sign
110    // rather than depending on how the caller happened to wind the triangle.
111    let clip_ring = match orientation(flat_clip) {
112        Sign::Negative => [flat_clip[0], flat_clip[2], flat_clip[1]],
113        _ => flat_clip,
114    };
115
116    let mut polygon: Vec<Point2> = subject.map(|p| project(p, axis)).to_vec();
117
118    for corner in 0..3 {
119        let edge_start = clip_ring[corner];
120        let edge_end = clip_ring[(corner + 1) % 3];
121        polygon = clip_to_halfplane(&polygon, edge_start, edge_end);
122        if polygon.is_empty() {
123            return Vec::new();
124        }
125    }
126
127    // Collapse points repeated by clipping through a shared vertex, then
128    // reject anything that is not a genuine area.
129    polygon.dedup();
130    if polygon.len() > 1 && polygon[0] == polygon[polygon.len() - 1] {
131        polygon.pop();
132    }
133    if polygon.len() < 3 {
134        return Vec::new();
135    }
136
137    polygon.into_iter().map(|p| lift(p, axis, clip)).collect()
138}
139
140/// Keep the part of `polygon` on the inside of the directed line a->b.
141///
142/// Inside means a non-negative `orient2d` sign, so points exactly ON the line
143/// are kept. That choice matters: a subject edge lying along a clip edge is a
144/// real part of the overlap boundary, and dropping it would open a gap.
145fn clip_to_halfplane(polygon: &[Point2], a: Point2, b: Point2) -> Vec<Point2> {
146    let mut out = Vec::with_capacity(polygon.len() + 1);
147
148    for index in 0..polygon.len() {
149        let current = polygon[index];
150        let next = polygon[(index + 1) % polygon.len()];
151        let current_side = side_of(a, b, current);
152        let next_side = side_of(a, b, next);
153
154        if current_side != Sign::Negative {
155            out.push(current);
156        }
157        // A strict sign change crosses the line, so the crossing point joins
158        // the output. Equality or a zero endpoint does not cross: the shared
159        // point was already emitted above.
160        if (current_side == Sign::Positive && next_side == Sign::Negative)
161            || (current_side == Sign::Negative && next_side == Sign::Positive)
162        {
163            out.push(line_crossing(current, next, a, b));
164        }
165    }
166    out
167}
168
169/// Exact side of the directed line a->b that `point` lies on.
170fn side_of(a: Point2, b: Point2, point: Point2) -> Sign {
171    orient2d(a, b, point)
172        .sign()
173        .expect("certified predicates are total")
174}
175
176/// Winding of a triangle, as an exact sign.
177fn orientation([a, b, c]: [Point2; 3]) -> Sign {
178    orient2d(a, b, c)
179        .sign()
180        .expect("certified predicates are total")
181}
182
183/// Where segment `start`->`end` crosses the line a->b.
184///
185/// The caller has already proven the crossing exists with exact signs; this
186/// computes only its position, so rounding can move the point slightly but
187/// cannot invent or remove it.
188fn line_crossing(start: Point2, end: Point2, a: Point2, b: Point2) -> Point2 {
189    let edge = b - a;
190    let start_height = edge.x * (start.y - a.y) - edge.y * (start.x - a.x);
191    let end_height = edge.x * (end.y - a.y) - edge.y * (end.x - a.x);
192    let span = start_height - end_height;
193    if span == 0.0 {
194        // Proven to cross, so this is unreachable; returning an endpoint
195        // keeps the polygon finite rather than emitting a NaN.
196        return start;
197    }
198    let t = start_height / span;
199    Point2::new(
200        start.x + (end.x - start.x) * t,
201        start.y + (end.y - start.y) * t,
202    )
203}