Skip to main content

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}