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}