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}