ifc_lite_geometry/projection_outline.rs
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5//! Winding-independent 2D footprint outline of a mesh, for construction
6//! projection on 2D floor plans (issue #979).
7//!
8//! Normal-based silhouette extraction (the TypeScript `EdgeExtractor`
9//! fallback) needs consistent triangle winding to tell front faces from back
10//! faces — but ifc-lite meshes are rendered double-sided precisely because
11//! their winding is *not* reliable, so a globally-flipped roof or stair can
12//! lose its silhouette entirely.
13//!
14//! This module is robust to winding because it works on triangle **areas**,
15//! not normals: every triangle is projected onto the section plane, each
16//! projected triangle is forced counter-clockwise, and the whole set is
17//! unioned with `i_overlay` (the same 2D boolean engine the CSG/void paths
18//! use). The boundary of that union is the true projected footprint outline
19//! regardless of the source winding.
20//!
21//! The 2D projection matches `projectTo2D` in `@ifc-lite/drawing-2d` exactly
22//! (`getProjectionAxes` + the flipped-U mirror), so the returned contours land
23//! in the same drawing space as the section-cut polygons.
24
25use i_overlay::core::fill_rule::FillRule;
26use i_overlay::core::overlay_rule::OverlayRule;
27use i_overlay::float::single::SingleFloatOverlay;
28
29/// Section axis perpendicular to the cut plane (geometric, WebGL Y-up).
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum ProjectionAxis {
32 X,
33 Y,
34 Z,
35}
36
37impl ProjectionAxis {
38 /// Decode the 0/1/2 = x/y/z convention used across the WASM boundary.
39 pub fn from_u8(v: u8) -> Option<Self> {
40 match v {
41 0 => Some(ProjectionAxis::X),
42 1 => Some(ProjectionAxis::Y),
43 2 => Some(ProjectionAxis::Z),
44 _ => None,
45 }
46 }
47}
48
49/// One element's projected footprint outline.
50#[derive(Clone, Debug, Default)]
51pub struct MeshOutline {
52 /// Boundary rings (outer + holes) in drawing 2D space. Each ring is a
53 /// closed loop given WITHOUT a duplicated closing vertex; consumers should
54 /// connect the last point back to the first.
55 pub contours: Vec<Vec<[f32; 2]>>,
56 /// Element extent along the cut axis (world units, NOT flip-adjusted), so
57 /// the caller can classify the outline into the visible/overhead band.
58 pub axis_min: f32,
59 pub axis_max: f32,
60}
61
62/// Project a world point to drawing 2D, matching `projectTo2D`:
63/// x → (u=z, v=y), y → (u=x, v=z), z → (u=x, v=y); u mirrored if flipped.
64#[inline]
65fn project(p: [f64; 3], axis: ProjectionAxis, flipped: bool) -> [f64; 2] {
66 let (u, v) = match axis {
67 ProjectionAxis::X => (p[2], p[1]),
68 ProjectionAxis::Y => (p[0], p[2]),
69 ProjectionAxis::Z => (p[0], p[1]),
70 };
71 [if flipped { -u } else { u }, v]
72}
73
74#[inline]
75fn axis_coord(p: [f64; 3], axis: ProjectionAxis) -> f64 {
76 match axis {
77 ProjectionAxis::X => p[0],
78 ProjectionAxis::Y => p[1],
79 ProjectionAxis::Z => p[2],
80 }
81}
82
83/// Area below which a projected triangle is treated as degenerate (edge-on to
84/// the view) and skipped. In drawing metres² — generous enough to drop f32
85/// slivers, small enough to keep real footprints.
86const DEGENERATE_AREA: f64 = 1.0e-8;
87
88/// Maximum number of triangles to feed into the i_overlay union. A mesh with
89/// more valid projected triangles is refused with [`NoOutline::OverBudget`]
90/// as soon as the projection loop crosses the cap, before the union runs, to
91/// bound computation time on pathological geometry.
92pub const MAX_OVERLAY_TRIANGLES: usize = 50_000;
93
94/// Why [`mesh_outline_2d`] produced no outline. The two are different
95/// answers for the caller: an `Empty` element has no footprint to draw; an
96/// `OverBudget` one has a footprint that was not computed.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum NoOutline {
99 /// The mesh has no triangles, or every projected triangle is degenerate
100 /// (edge-on to the view) and nothing survives the union.
101 Empty,
102 /// More than [`MAX_OVERLAY_TRIANGLES`] valid projected triangles; the
103 /// union was not attempted. `triangles` is the mesh's own triangle count.
104 OverBudget { triangles: usize },
105}
106
107/// Compute the winding-independent 2D footprint outline of a triangle mesh.
108///
109/// `positions` is flat XYZ (len = 3·vertexCount); `indices` is flat triangle
110/// indices. Returns [`NoOutline`] when there is no outline to return, and
111/// which of the two reasons applies.
112pub fn mesh_outline_2d(
113 positions: &[f32],
114 indices: &[u32],
115 axis: ProjectionAxis,
116 flipped: bool,
117) -> Result<MeshOutline, NoOutline> {
118 if indices.len() < 3 {
119 return Err(NoOutline::Empty);
120 }
121 let vertex_count = positions.len() / 3;
122
123 let mut subject: Vec<Vec<[f64; 2]>> = Vec::new();
124 let mut clip: Vec<Vec<[f64; 2]>> = Vec::new();
125 let mut axis_min = f64::INFINITY;
126 let mut axis_max = f64::NEG_INFINITY;
127
128 for tri in indices.chunks_exact(3) {
129 let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
130 if i0 >= vertex_count || i1 >= vertex_count || i2 >= vertex_count {
131 continue;
132 }
133 let p0 = [
134 positions[i0 * 3] as f64,
135 positions[i0 * 3 + 1] as f64,
136 positions[i0 * 3 + 2] as f64,
137 ];
138 let p1 = [
139 positions[i1 * 3] as f64,
140 positions[i1 * 3 + 1] as f64,
141 positions[i1 * 3 + 2] as f64,
142 ];
143 let p2 = [
144 positions[i2 * 3] as f64,
145 positions[i2 * 3 + 1] as f64,
146 positions[i2 * 3 + 2] as f64,
147 ];
148
149 for p in [p0, p1, p2] {
150 let a = axis_coord(p, axis);
151 axis_min = axis_min.min(a);
152 axis_max = axis_max.max(a);
153 }
154
155 let a0 = project(p0, axis, flipped);
156 let a1 = project(p1, axis, flipped);
157 let a2 = project(p2, axis, flipped);
158
159 // Signed area in (u, v); skip degenerate, force CCW so i_overlay's
160 // NonZero fill unions (mixed winding would cancel triangles instead).
161 let area = (a1[0] - a0[0]) * (a2[1] - a0[1]) - (a2[0] - a0[0]) * (a1[1] - a0[1]);
162 if area.abs() < DEGENERATE_AREA {
163 continue;
164 }
165 // Skip near-collinear triangles (high aspect ratio) that stress i_overlay.
166 let max_edge_sq = ((a1[0]-a0[0]).powi(2) + (a1[1]-a0[1]).powi(2))
167 .max((a2[0]-a1[0]).powi(2) + (a2[1]-a1[1]).powi(2))
168 .max((a0[0]-a2[0]).powi(2) + (a0[1]-a2[1]).powi(2));
169 if max_edge_sq > 0.0 && area.abs() / max_edge_sq.sqrt() < 1.0e-6 {
170 continue;
171 }
172 let path: Vec<[f64; 2]> = if area >= 0.0 {
173 vec![a0, a1, a2]
174 } else {
175 vec![a0, a2, a1]
176 };
177
178 if subject.is_empty() {
179 subject.push(path);
180 } else {
181 clip.push(path);
182 }
183 // Refuse as soon as the cap is crossed: the union is the unbounded
184 // part, and projecting the rest of the mesh first is wasted work.
185 if subject.len() + clip.len() > MAX_OVERLAY_TRIANGLES {
186 return Err(NoOutline::OverBudget { triangles: indices.len() / 3 });
187 }
188 }
189
190 if subject.is_empty() {
191 return Err(NoOutline::Empty);
192 }
193
194 // Single triangle -> its own outline (skip the union round-trip).
195 let shapes: Vec<Vec<Vec<[f64; 2]>>> = if clip.is_empty() {
196 vec![subject.clone()]
197 } else {
198 subject.overlay(&clip, OverlayRule::Union, FillRule::NonZero)
199 };
200
201 let mut contours: Vec<Vec<[f32; 2]>> = Vec::new();
202 for shape in shapes {
203 for ring in shape {
204 if ring.len() >= 3 {
205 contours.push(ring.iter().map(|pt| [pt[0] as f32, pt[1] as f32]).collect());
206 }
207 }
208 }
209
210 if contours.is_empty() {
211 return Err(NoOutline::Empty);
212 }
213
214 Ok(MeshOutline {
215 contours,
216 axis_min: axis_min as f32,
217 axis_max: axis_max as f32,
218 })
219}
220
221#[cfg(test)]
222#[path = "projection_outline_tests.rs"]
223mod tests;