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;