axiolid_project/lib.rs
1#![forbid(unsafe_code)]
2//! Projection of triangle meshes onto a plane, and prism intersection.
3//!
4//! # The bridge between the 3D and 2D halves
5//!
6//! Triangle meshes live on one side of this kernel and planar booleans on the
7//! other. Both halves exist; the fold that connects them did not, so a consumer
8//! holding a mesh had to write the projection itself and pick its own winding
9//! and degeneracy conventions.
10//!
11//! # Degenerate triangles are dropped explicitly
12//!
13//! A triangle seen edge-on projects to a zero-area sliver. It contributes
14//! nothing to a union, but silently discarding it hides the difference between
15//! "this mesh is edge-on" and "this mesh is empty".
16//! The count is therefore reported in [`ProjectionEvidence`].
17//!
18//! # A projection is geometry, not a footprint
19//!
20//! [`project_mesh`] answers exactly one question: which points of the plane
21//! does this mesh cover. It does not decide *which* mesh to project, *which*
22//! plane counts as the reference, or which parts of a building should have
23//! been included. Those are the questions that turn a projection into a
24//! footprint, a shadow, a clash silhouette, or a formwork outline, and they
25//! have different answers per consumer.
26//!
27//! Concretely, two downstream users asking for "the footprint" will disagree
28//! about overhangs, balconies, and structure below grade. A kernel that picked
29//! one of those answers would be wrong for the other user and would hide the
30//! choice inside a function whose name implied there was nothing to choose.
31//! The same split applies elsewhere in this kernel: a clearance query reports
32//! a distance and refuses to call it adequate.
33//!
34//! So the mechanical half lives here -- project, union, preserve holes, report
35//! evidence -- and the naming, plane selection, and inclusion rules live with
36//! the consumer that has a reason to prefer one rule over another.
37
38use axiolid_core::{PlaneFrame, Point2, Tolerance};
39use axiolid_mesh::TriangleMeshView;
40use axiolid_overlay::{
41 overlay, union_soup, FillRule, OverlayError, OverlayInput, OverlayOperation, Polygon, Ring,
42};
43
44/// Why a projection could not be produced.
45#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum ProjectionError {
48 /// The projection plane basis was not orthonormal or not finite.
49 ///
50 /// No longer produced: `Plane` validates on construction, so an invalid
51 /// basis cannot reach a projection. Retained because the enum is public
52 /// and removing a variant is a breaking change.
53 InvalidPlane,
54 /// A mesh position was not finite.
55 NonFinitePosition,
56 /// A triangle referenced a position index the mesh does not have.
57 IndexOutOfRange,
58 /// The planar stage rejected the projected geometry.
59 Planar(OverlayError),
60}
61
62impl From<OverlayError> for ProjectionError {
63 fn from(error: OverlayError) -> Self {
64 Self::Planar(error)
65 }
66}
67
68/// What a projection actually did.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct ProjectionEvidence {
72 /// Triangles read from the source mesh.
73 pub input_triangles: usize,
74 /// Triangles whose projection had zero area and were dropped.
75 ///
76 /// Reported rather than silently skipped: a fully edge-on mesh projects to
77 /// nothing, and that is a different fact from an empty mesh.
78 pub degenerate_triangles: usize,
79 /// Polygons in the resulting union.
80 pub output_polygons: usize,
81 /// Inner boundary components across all output polygons.
82 pub output_holes: usize,
83}
84
85/// A projected footprint.
86#[derive(Debug, Clone, PartialEq)]
87pub struct Projection {
88 /// The union of the projected triangles, holes preserved.
89 pub polygons: Vec<Polygon>,
90 /// What the projection did.
91 pub evidence: ProjectionEvidence,
92}
93
94/// An orthonormal projection plane.
95///
96/// Alias for the core [`PlaneFrame`], which validates orthonormality on
97/// construction using the ANGULAR tolerance. Orthonormality is a dimensionless
98/// property; deciding it with the linear tolerance made the same skewed basis
99/// valid in millimetres and invalid in metres.
100pub type Plane = PlaneFrame;
101
102/// Project a triangle mesh onto `plane` and union the projected triangles.
103///
104/// The result is a polygon set with holes, not an outline or a hull: a mesh
105/// with a through-hole projects to a polygon that still has the hole.
106///
107/// Triangles are unioned pairwise into an accumulator rather than handed to the
108/// backend as one soup, because the planar validator rejects self-intersecting
109/// input and a raw triangle soup routinely overlaps itself.
110pub fn project_mesh<M: TriangleMeshView + ?Sized>(
111 mesh: &M,
112 plane: Plane,
113 tolerance: Tolerance,
114) -> Result<Projection, ProjectionError> {
115 let triangles = collect_triangles(mesh, plane, tolerance)?;
116 let polygons = union_all(triangles.rings, tolerance)?;
117 let evidence = ProjectionEvidence {
118 input_triangles: mesh.triangle_count(),
119 degenerate_triangles: triangles.degenerate,
120 output_polygons: polygons.len(),
121 output_holes: polygons.iter().map(|p| p.holes.len()).sum(),
122 };
123 Ok(Projection { polygons, evidence })
124}
125
126struct Collected {
127 rings: Vec<Ring>,
128 degenerate: usize,
129}
130
131fn collect_triangles<M: TriangleMeshView + ?Sized>(
132 mesh: &M,
133 plane: Plane,
134 tolerance: Tolerance,
135) -> Result<Collected, ProjectionError> {
136 let mut rings = Vec::new();
137 let mut degenerate = 0;
138 let positions = mesh.position_count();
139
140 for index in 0..mesh.triangle_count() {
141 let corners = mesh.triangle(index);
142 let mut projected = [Point2::new(0.0, 0.0); 3];
143 for (slot, corner) in corners.iter().enumerate() {
144 let corner = usize::try_from(*corner).map_err(|_| ProjectionError::IndexOutOfRange)?;
145 if corner >= positions {
146 return Err(ProjectionError::IndexOutOfRange);
147 }
148 let point = mesh.position(corner);
149 if !point.is_finite() {
150 return Err(ProjectionError::NonFinitePosition);
151 }
152 projected[slot] = plane.project(point);
153 }
154
155 // Twice the signed area. A triangle seen edge-on lands at zero here,
156 // and is counted rather than quietly skipped.
157 let cross = (projected[1] - projected[0]).perp_dot(projected[2] - projected[0]);
158 // The ring validator rejects a zero-area ring, so the threshold here
159 // matches the area threshold it applies rather than a looser one.
160 if cross.abs() <= 2.0 * tolerance.linear().powi(2) {
161 degenerate += 1;
162 continue;
163 }
164
165 // Back-facing triangles project to clockwise rings. Orientation in 3D
166 // is not a planar fact: a solid presents both facings to any plane, so
167 // both must contribute positively to the footprint.
168 let mut points = projected.to_vec();
169 if cross < 0.0 {
170 points.reverse();
171 }
172 rings.push(Ring { points });
173 }
174
175 Ok(Collected { rings, degenerate })
176}
177
178fn plane_frame() -> axiolid_core::Frame2 {
179 axiolid_core::Frame2 {
180 origin: Point2::new(0.0, 0.0),
181 x: axiolid_core::Vec2::new(1.0, 0.0),
182 y: axiolid_core::Vec2::new(0.0, 1.0),
183 }
184}
185
186/// Union already-CCW triangle rings into a normalised polygon set.
187///
188/// Delegates to `union_soup`, which validates each ring on its own but does
189/// not require the set to be mutually disjoint. Folding pairwise through
190/// `overlay` instead fails: the accumulator becomes self-touching as soon as
191/// two triangles share an edge, and `overlay` rejects a self-intersecting
192/// operand.
193fn union_all(rings: Vec<Ring>, tolerance: Tolerance) -> Result<Vec<Polygon>, ProjectionError> {
194 Ok(union_soup(&rings, tolerance)?)
195}
196
197/// Intersect a mesh footprint with a vertical prism over a 2D region.
198///
199/// The prism is infinite along the plane normal, so this is exactly the planar
200/// intersection of the mesh footprint with `region`. Naming it separately keeps
201/// the caller from having to know that identity, and leaves room for a bounded
202/// prism later without changing the call site.
203pub fn intersect_prism<M: TriangleMeshView + ?Sized>(
204 mesh: &M,
205 plane: Plane,
206 region: &[Polygon],
207 tolerance: Tolerance,
208) -> Result<Projection, ProjectionError> {
209 let footprint = project_mesh(mesh, plane, tolerance)?;
210 let subject = OverlayInput {
211 frame: plane_frame(),
212 polygons: footprint.polygons,
213 };
214 let clip = OverlayInput {
215 frame: plane_frame(),
216 polygons: region.to_vec(),
217 };
218 let clipped = overlay(
219 &subject,
220 &clip,
221 OverlayOperation::Intersection,
222 FillRule::NonZero,
223 tolerance,
224 )?;
225 let evidence = ProjectionEvidence {
226 output_polygons: clipped.polygons.len(),
227 output_holes: clipped.polygons.iter().map(|p| p.holes.len()).sum(),
228 ..footprint.evidence
229 };
230 Ok(Projection {
231 polygons: clipped.polygons,
232 evidence,
233 })
234}