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///
104/// # Panics
105///
106/// When a coordinate is NaN or infinite. The signature has no error channel
107/// and an empty result would claim "no shared area", so a non-finite input is
108/// a violated precondition; [`intersection_segments`](crate::intersection_segments)
109/// refuses such a pair with a typed error before calling this.
110#[must_use]
111pub fn coplanar_overlap(subject: [Point3; 3], clip: [Point3; 3]) -> Vec<Point3> {
112 assert!(
113 subject.iter().chain(&clip).all(|p| p.is_finite()),
114 "coplanar_overlap requires finite coordinates"
115 );
116 let normal = (clip[1] - clip[0]).cross(clip[2] - clip[0]);
117 let axis = dominant_axis(normal);
118
119 let flat_clip = clip.map(|p| project(p, axis));
120 // Clip against consistently-wound edges, so 'inside' is one fixed sign
121 // rather than depending on how the caller happened to wind the triangle.
122 let clip_ring = match orientation(flat_clip) {
123 Sign::Negative => [flat_clip[0], flat_clip[2], flat_clip[1]],
124 _ => flat_clip,
125 };
126
127 let mut polygon: Vec<Point2> = subject.map(|p| project(p, axis)).to_vec();
128
129 for corner in 0..3 {
130 let edge_start = clip_ring[corner];
131 let edge_end = clip_ring[(corner + 1) % 3];
132 polygon = clip_to_halfplane(&polygon, edge_start, edge_end);
133 if polygon.is_empty() {
134 return Vec::new();
135 }
136 }
137
138 // Collapse points repeated by clipping through a shared vertex, then
139 // reject anything that is not a genuine area.
140 polygon.dedup();
141 if polygon.len() > 1 && polygon[0] == polygon[polygon.len() - 1] {
142 polygon.pop();
143 }
144 if polygon.len() < 3 {
145 return Vec::new();
146 }
147
148 polygon.into_iter().map(|p| lift(p, axis, clip)).collect()
149}
150
151/// Keep the part of `polygon` on the inside of the directed line a->b.
152///
153/// Inside means a non-negative `orient2d` sign, so points exactly ON the line
154/// are kept. That choice matters: a subject edge lying along a clip edge is a
155/// real part of the overlap boundary, and dropping it would open a gap.
156fn clip_to_halfplane(polygon: &[Point2], a: Point2, b: Point2) -> Vec<Point2> {
157 let mut out = Vec::with_capacity(polygon.len() + 1);
158
159 for index in 0..polygon.len() {
160 let current = polygon[index];
161 let next = polygon[(index + 1) % polygon.len()];
162 let current_side = side_of(a, b, current);
163 let next_side = side_of(a, b, next);
164
165 if current_side != Sign::Negative {
166 out.push(current);
167 }
168 // A strict sign change crosses the line, so the crossing point joins
169 // the output. Equality or a zero endpoint does not cross: the shared
170 // point was already emitted above.
171 if (current_side == Sign::Positive && next_side == Sign::Negative)
172 || (current_side == Sign::Negative && next_side == Sign::Positive)
173 {
174 out.push(line_crossing(current, next, a, b));
175 }
176 }
177 out
178}
179
180/// Exact side of the directed line a->b that `point` lies on.
181fn side_of(a: Point2, b: Point2, point: Point2) -> Sign {
182 orient2d(a, b, point)
183 .sign()
184 .expect("orient2d is certain for the finite coordinates asserted on entry")
185}
186
187/// Winding of a triangle, as an exact sign.
188fn orientation([a, b, c]: [Point2; 3]) -> Sign {
189 orient2d(a, b, c)
190 .sign()
191 .expect("orient2d is certain for the finite coordinates asserted on entry")
192}
193
194/// Where segment `start`->`end` crosses the line a->b.
195///
196/// The caller has already proven the crossing exists with exact signs; this
197/// computes only its position, so rounding can move the point slightly but
198/// cannot invent or remove it.
199fn line_crossing(start: Point2, end: Point2, a: Point2, b: Point2) -> Point2 {
200 let edge = b - a;
201 let start_height = edge.x * (start.y - a.y) - edge.y * (start.x - a.x);
202 let end_height = edge.x * (end.y - a.y) - edge.y * (end.x - a.x);
203 let span = start_height - end_height;
204 if span == 0.0 {
205 // Proven to cross, so this is unreachable; returning an endpoint
206 // keeps the polygon finite rather than emitting a NaN.
207 return start;
208 }
209 let t = start_height / span;
210 Point2::new(
211 start.x + (end.x - start.x) * t,
212 start.y + (end.y - start.y) * t,
213 )
214}