Skip to main content

falcon_library/model3d/
geom.rs

1//! Geometry shared by the viewer and the thumbnail renderer: welding
2//! duplicate vertices, face and smooth normals, feature edges, the
3//! measurements Eagle's viewer lists, and the lighting both draw with.
4
5use std::collections::HashMap;
6
7use super::math::{self, V3};
8use super::{Mesh, Model};
9
10/// For each vertex of `positions`, the index of the first vertex at exactly
11/// the same place. Formats such as STL repeat a corner for every triangle
12/// that uses it; welding lets them share normals and edges.
13pub fn weld(positions: &[V3]) -> Vec<u32> {
14    let mut first: HashMap<[u32; 3], u32> = HashMap::with_capacity(positions.len());
15    positions
16        .iter()
17        .enumerate()
18        .map(|(i, p)| {
19            // -0.0 and 0.0 are the same place.
20            let key = p.map(|c| if c == 0.0 { 0 } else { c.to_bits() });
21            *first.entry(key).or_insert(i as u32)
22        })
23        .collect()
24}
25
26/// The unit normal of the triangle (a, b, c), counter-clockwise front;
27/// zero for a degenerate triangle.
28pub fn face_normal(a: V3, b: V3, c: V3) -> V3 {
29    math::normalize(math::cross(math::sub(b, a), math::sub(c, a)))
30}
31
32/// Twice the area of the triangle (a, b, c).
33fn double_area(a: V3, b: V3, c: V3) -> f32 {
34    math::length(math::cross(math::sub(b, a), math::sub(c, a)))
35}
36
37/// One normal per vertex for smooth shading: the file's own normals when it
38/// has them, otherwise the area-weighted average of the faces meeting at
39/// the vertex's place (welded, so a corner repeated per triangle still
40/// shades smoothly).
41pub fn smooth_normals(mesh: &Mesh) -> Vec<V3> {
42    if let Some(n) = &mesh.normals {
43        return n.iter().map(|&v| math::normalize(v)).collect();
44    }
45    let welded = weld(&mesh.positions);
46    let mut acc = vec![[0.0f32; 3]; mesh.positions.len()];
47    for t in mesh.indices.as_chunks::<3>().0.iter() {
48        let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
49        // The unnormalised cross product weighs each face by its area.
50        let n = math::cross(math::sub(b, a), math::sub(c, a));
51        for &i in t {
52            let w = welded[i as usize] as usize;
53            acc[w] = math::add(acc[w], n);
54        }
55    }
56    welded.iter().map(|&w| math::normalize(acc[w as usize])).collect()
57}
58
59/// The edges to draw for Eagle's "Show Edges": the segments where the
60/// faces on either side meet at more than `threshold_deg` degrees, plus
61/// the open edges with a face on one side only. Edges inside a flat area
62/// (the diagonals of a triangulated quad) are left out.
63pub fn feature_edges(mesh: &Mesh, threshold_deg: f32) -> Vec<[V3; 2]> {
64    let welded = weld(&mesh.positions);
65    let cos_limit = threshold_deg.to_radians().cos();
66    // Undirected edge (smaller welded index first) → the normal of the
67    // first face seen and whether the edge is to be drawn.
68    let mut edges: HashMap<(u32, u32), (V3, u8, bool)> = HashMap::new();
69    let mut order: Vec<(u32, u32)> = Vec::new();
70    for t in mesh.indices.as_chunks::<3>().0.iter() {
71        let w = t.map(|i| welded[i as usize]);
72        let [a, b, c] = w.map(|i| mesh.positions[i as usize]);
73        let n = face_normal(a, b, c);
74        if n == [0.0; 3] {
75            continue;
76        }
77        for (p, q) in [(w[0], w[1]), (w[1], w[2]), (w[2], w[0])] {
78            if p == q {
79                continue;
80            }
81            let key = (p.min(q), p.max(q));
82            match edges.get_mut(&key) {
83                None => {
84                    edges.insert(key, (n, 1, false));
85                    order.push(key);
86                }
87                Some(e) => {
88                    e.1 = e.1.saturating_add(1);
89                    if math::dot(e.0, n) <= cos_limit {
90                        e.2 = true;
91                    }
92                }
93            }
94        }
95    }
96    order
97        .into_iter()
98        .filter(|k| edges[k].1 == 1 || edges[k].2)
99        .map(|(p, q)| [mesh.positions[p as usize], mesh.positions[q as usize]])
100        .collect()
101}
102
103/// Whether every edge of the mesh is shared by exactly two triangles that
104/// run along it in opposite directions: a closed, consistently wound
105/// surface, the only kind with a volume.
106pub fn is_closed(mesh: &Mesh) -> bool {
107    let welded = weld(&mesh.positions);
108    let mut directed: HashMap<(u32, u32), u32> = HashMap::new();
109    for t in mesh.indices.as_chunks::<3>().0.iter() {
110        let w = t.map(|i| welded[i as usize]);
111        for (p, q) in [(w[0], w[1]), (w[1], w[2]), (w[2], w[0])] {
112            *directed.entry((p, q)).or_default() += 1;
113        }
114    }
115    !directed.is_empty() && directed.iter().all(|(&(p, q), &n)| n == 1 && directed.get(&(q, p)) == Some(&1))
116}
117
118/// What Eagle's model viewer lists under Details.
119#[derive(Debug, Clone, PartialEq)]
120pub struct Stats {
121    pub vertices: usize,
122    pub triangles: usize,
123    /// The bounding box's size along X, Y and Z.
124    pub size: V3,
125    /// The enclosed volume, when every mesh is closed.
126    pub volume: Option<f64>,
127    /// The total area of the triangles.
128    pub surface: f64,
129}
130
131pub fn stats(model: &Model) -> Stats {
132    let size = model.bounds().map_or([0.0; 3], |(lo, hi)| math::sub(hi, lo));
133    let mut surface = 0.0f64;
134    let mut volume = 0.0f64;
135    let mut closed = !model.meshes.is_empty();
136    for m in &model.meshes {
137        closed &= is_closed(m);
138        for t in m.indices.as_chunks::<3>().0.iter() {
139            let [a, b, c] = t.map(|i| m.positions[i as usize]);
140            surface += f64::from(double_area(a, b, c)) / 2.0;
141            // The signed volume of the tetrahedron from the origin.
142            let [a, b, c] = [a, b, c].map(|v| v.map(f64::from));
143            volume += (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6.0;
144        }
145    }
146    Stats { vertices: model.vertex_count(), triangles: model.triangle_count(), size, volume: closed.then_some(volume.abs()), surface }
147}
148
149/// The lighting of Eagle's viewer: a grey ambient light and a grey
150/// directional light shining from the camera, each at 0x88 (0.533), with a
151/// faint highlight. Both sides of a face are lit. `n` is the unit normal,
152/// `to_eye` the unit direction from the surface to the camera, `base` the
153/// surface colour; the result is a display colour in 0..1. The WGSL shader
154/// in the app computes the same thing.
155pub fn shade(base: V3, n: V3, to_eye: V3) -> V3 {
156    let d = math::dot(n, to_eye).abs();
157    let spec = SPECULAR * d.powi(SHININESS);
158    base.map(|c| (c * (AMBIENT + DIFFUSE * d) + spec).min(1.0))
159}
160
161/// The normal `n` turned by a tangent-space normal map's stored values
162/// `texel` (0..1, green up in the picture), on a surface where going along
163/// `q0` moves the picture coordinates (u right, v down) by `st0` and going
164/// along `q1` by `st1`. No tangents are stored: the frame comes from those
165/// differences, as in Schüler's "Normal Mapping Without Precomputed
166/// Tangents". `n` comes back alone where the coordinates don't change. The
167/// shader in the app computes the same from screen derivatives.
168pub fn normal_mapped(n: V3, q0: V3, q1: V3, st0: [f32; 2], st1: [f32; 2], texel: V3) -> V3 {
169    let (p1, p0) = (math::cross(q1, n), math::cross(n, q0));
170    let t = math::add(math::scale(p1, st0[0]), math::scale(p0, st1[0]));
171    let b = math::add(math::scale(p1, st0[1]), math::scale(p0, st1[1]));
172    let det = math::dot(t, t).max(math::dot(b, b));
173    if !(det.is_finite() && det > 0.0) {
174        return n;
175    }
176    let k = det.sqrt().recip();
177    let (x, y, z) = (texel[0] * 2.0 - 1.0, texel[1] * 2.0 - 1.0, texel[2] * 2.0 - 1.0);
178    // v runs down the picture, the map's green up it.
179    let tilted = math::add(math::add(math::scale(t, x * k), math::scale(b, -y * k)), math::scale(n, z));
180    let out = math::normalize(tilted);
181    if out == [0.0; 3] { n } else { out }
182}
183
184/// The normal `n` tilted by a height field that changes by `dh` (per unit
185/// of u, per unit of v) in picture coordinates, on a surface as in
186/// [`normal_mapped`]: Mikkelsen's surface gradient, heights in the model's
187/// units.
188pub fn bumped(n: V3, q0: V3, q1: V3, st0: [f32; 2], st1: [f32; 2], dh: [f32; 2]) -> V3 {
189    let (p1, p0) = (math::cross(q1, n), math::cross(n, q0));
190    let det = math::dot(q0, p1);
191    if !(det.is_finite() && det != 0.0) {
192        return n;
193    }
194    // Per unit along the surface: how u and v change, then the height.
195    let du = math::add(math::scale(p1, st0[0] / det), math::scale(p0, st1[0] / det));
196    let dv = math::add(math::scale(p1, st0[1] / det), math::scale(p0, st1[1] / det));
197    let slope = math::add(math::scale(du, dh[0]), math::scale(dv, dh[1]));
198    let out = math::normalize(math::sub(n, slope));
199    if out == [0.0; 3] || !out.iter().all(|c| c.is_finite()) { n } else { out }
200}
201
202/// `shade`'s result with the surface's own light added, in display values
203/// as in Eagle's viewer.
204pub fn add_light(lit: V3, emissive: V3) -> V3 {
205    std::array::from_fn(|k| (lit[k] + emissive[k]).min(1.0))
206}
207
208/// The middle of the box around the vertices of `mesh` that triangles use.
209pub fn center(mesh: &Mesh) -> Option<V3> {
210    let mut b: Option<(V3, V3)> = None;
211    for &i in &mesh.indices {
212        let p = *mesh.positions.get(i as usize)?;
213        if !p.iter().all(|c| c.is_finite()) {
214            continue;
215        }
216        let (lo, hi) = b.get_or_insert((p, p));
217        for k in 0..3 {
218            lo[k] = lo[k].min(p[k]);
219            hi[k] = hi[k].max(p[k]);
220        }
221    }
222    b.map(|(lo, hi)| math::scale(math::add(lo, hi), 0.5))
223}
224
225/// The order to draw see-through parts in: the indices of `centers`,
226/// farthest from the camera `vp` (projection times view) first.
227pub fn back_to_front(centers: &[V3], vp: &math::M4) -> Vec<usize> {
228    let depth = |p: V3| {
229        let v = [p[0], p[1], p[2], 1.0];
230        let z: f32 = (0..4).map(|k| vp[k][2] * v[k]).sum();
231        let w: f32 = (0..4).map(|k| vp[k][3] * v[k]).sum();
232        // Clip w is the distance in front of the eye under perspective;
233        // behind the eye counts as nearest.
234        if w > 0.0 { z / w } else { f32::NEG_INFINITY }
235    };
236    let keyed: Vec<f32> = centers.iter().map(|&c| depth(c)).collect();
237    let mut order: Vec<usize> = (0..centers.len()).collect();
238    order.sort_by(|&a, &b| keyed[b].total_cmp(&keyed[a]));
239    order
240}
241
242/// Ambient and directional light strengths (0x88 / 255).
243pub const AMBIENT: f32 = 136.0 / 255.0;
244pub const DIFFUSE: f32 = 136.0 / 255.0;
245/// The highlight's strength and sharpness.
246pub const SPECULAR: f32 = 0.045;
247pub const SHININESS: i32 = 30;
248
249#[cfg(test)]
250pub(crate) mod tests {
251    use super::*;
252
253    /// A flat map value leaves the normal alone; tilting towards red (+u)
254    /// tips it along +x, towards green (up the picture, -v) along +y.
255    #[test]
256    fn normal_maps_tilt_along_the_picture() {
257        let n = [0.0, 0.0, 1.0];
258        // +x is +u, and +y goes up the picture, so v falls.
259        let (q0, q1, st0, st1) = ([2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [1.0, 0.0], [0.0, -1.0]);
260        let flat = normal_mapped(n, q0, q1, st0, st1, [0.5, 0.5, 1.0]);
261        assert!(math::length(math::sub(flat, n)) < 1e-6, "{flat:?}");
262        let right = normal_mapped(n, q0, q1, st0, st1, [1.0, 0.5, 0.5]);
263        assert!(right[0] > 0.99 && right[1].abs() < 1e-5, "{right:?}");
264        let up = normal_mapped(n, q0, q1, st0, st1, [0.5, 1.0, 0.5]);
265        assert!(up[1] > 0.99 && up[0].abs() < 1e-5, "{up:?}");
266        // The same result whatever two directions the frame is built from.
267        let other = normal_mapped(n, [1.0, 1.0, 0.0], [0.0, 3.0, 0.0], [0.5, -0.5], [0.0, -1.5], [1.0, 0.5, 0.5]);
268        assert!(math::length(math::sub(other, right)) < 1e-5, "{other:?}");
269        // Coordinates that don't change give no frame.
270        assert_eq!(normal_mapped(n, q0, q1, [0.0; 2], [0.0; 2], [1.0, 0.5, 0.5]), n);
271    }
272
273    /// Height rising to the right leans the surface to the left.
274    #[test]
275    fn bumps_lean_away_from_rising_height() {
276        let n = [0.0, 0.0, 1.0];
277        let (q0, q1, st0, st1) = ([2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [1.0, 0.0], [0.0, -1.0]);
278        assert_eq!(bumped(n, q0, q1, st0, st1, [0.0, 0.0]), n);
279        // u goes 0..1 over 2 units, so dh/du = 2 is a slope of 1: 45 degrees.
280        let b = bumped(n, q0, q1, st0, st1, [2.0, 0.0]);
281        assert!((b[0] + 0.5f32.sqrt()).abs() < 1e-5 && (b[2] - 0.5f32.sqrt()).abs() < 1e-5, "{b:?}");
282        // Height rising down the picture, so falling along +y: leans up.
283        let b = bumped(n, q0, q1, st0, st1, [0.0, 2.0]);
284        assert!(b[1] > 0.7, "{b:?}");
285        // A degenerate frame leaves it alone.
286        assert_eq!(bumped(n, [0.0; 3], q1, st0, st1, [1.0, 1.0]), n);
287    }
288
289    #[test]
290    fn see_through_parts_sort_far_to_near() {
291        let view = math::look_at([0.0, 0.0, 5.0], [0.0; 3], [0.0, 1.0, 0.0]);
292        let proj = math::perspective(1.0, 1.0, 0.1, 100.0);
293        let vp = math::mul(&proj, &view);
294        assert_eq!(back_to_front(&[[0.0, 0.0, 1.0], [0.0, 0.0, -3.0], [0.0, 0.0, 9.0], [0.0, 0.0, 0.0]], &vp), [1, 3, 0, 2]);
295    }
296
297    #[test]
298    fn centers_use_the_vertices_of_triangles() {
299        let m = Mesh { positions: vec![[0.0; 3], [2.0, 0.0, 0.0], [0.0, 4.0, 0.0], [100.0; 3]], indices: vec![0, 1, 2], ..Default::default() };
300        assert_eq!(center(&m), Some([1.0, 2.0, 0.0]));
301        assert_eq!(center(&Mesh::default()), None);
302    }
303
304    #[test]
305    fn own_light_adds_and_clamps() {
306        assert_eq!(add_light([0.25, 0.5, 0.75], [0.25, 0.75, 0.0]), [0.5, 1.0, 0.75]);
307    }
308
309    /// A unit cube from (0,0,0) to (1,1,1), wound outward, with its corners
310    /// repeated per triangle the way STL stores them when `split` is true.
311    pub(crate) fn cube(split: bool) -> Mesh {
312        let corners: [V3; 8] =
313            [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
314        let quads = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [2, 3, 7, 6], [1, 2, 6, 5], [0, 4, 7, 3]];
315        let tris: Vec<u32> = quads.iter().flat_map(|q| [q[0], q[1], q[2], q[0], q[2], q[3]]).collect();
316        if split {
317            Mesh { positions: tris.iter().map(|&i| corners[i as usize]).collect(), indices: (0..36).collect(), ..Default::default() }
318        } else {
319            Mesh { positions: corners.to_vec(), indices: tris, ..Default::default() }
320        }
321    }
322
323    #[test]
324    fn welding_joins_repeated_corners() {
325        let w = weld(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [-0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
326        assert_eq!(w, vec![0, 1, 0, 1]);
327    }
328
329    #[test]
330    fn cube_measures() {
331        for split in [false, true] {
332            let model = Model { meshes: vec![cube(split)] };
333            let s = stats(&model);
334            assert_eq!(s.triangles, 12);
335            assert_eq!(s.size, [1.0, 1.0, 1.0]);
336            assert!((s.surface - 6.0).abs() < 1e-9);
337            assert!((s.volume.unwrap() - 1.0).abs() < 1e-9, "closed when split too");
338        }
339        // Without one face it is open and has no volume.
340        let mut open = cube(false);
341        open.indices.truncate(30);
342        assert_eq!(stats(&Model { meshes: vec![open] }).volume, None);
343    }
344
345    #[test]
346    fn cube_edges_skip_the_face_diagonals() {
347        // 12 cube edges; the 6 diagonals lie in flat faces.
348        assert_eq!(feature_edges(&cube(true), 1.0).len(), 12);
349        // An open square shows its four sides, not its diagonal.
350        let sq = Mesh {
351            positions: vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]],
352            indices: vec![0, 1, 2, 0, 2, 3],
353            ..Default::default()
354        };
355        assert_eq!(feature_edges(&sq, 1.0).len(), 4);
356        // At a 90 degree threshold the cube's right angles still count;
357        // past it they don't, leaving nothing.
358        assert_eq!(feature_edges(&cube(false), 89.0).len(), 12);
359        assert_eq!(feature_edges(&cube(false), 91.0).len(), 0);
360    }
361
362    #[test]
363    fn smooth_normals_point_out_of_the_corners() {
364        let n = smooth_normals(&cube(true));
365        // The corner at the origin points away from the cube's centre.
366        let k = -1.0 / 3f32.sqrt();
367        assert!(n[0].iter().all(|c| (c - k).abs() < 1e-5), "{:?}", n[0]);
368        // File normals win.
369        let mut m = cube(false);
370        m.normals = Some(vec![[0.0, 2.0, 0.0]; 8]);
371        assert_eq!(smooth_normals(&m)[3], [0.0, 1.0, 0.0]);
372    }
373
374    #[test]
375    fn shading_is_two_sided_and_brightest_head_on() {
376        let base = super::super::DEFAULT_COLOR;
377        let head_on = shade(base, [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]);
378        let back = shade(base, [0.0, 0.0, -1.0], [0.0, 0.0, 1.0]);
379        let grazing = shade(base, [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
380        assert_eq!(head_on, back);
381        assert!(head_on[0] > grazing[0]);
382        // Edge-on, only the ambient light is left.
383        assert!((grazing[0] - base[0] * AMBIENT).abs() < 1e-6);
384        assert!(head_on[0] <= 1.0);
385    }
386
387    #[test]
388    fn model_finish_drops_empty_meshes_and_bad_indices() {
389        let mut bad = cube(false);
390        bad.indices.extend([0, 1, 99]);
391        bad.colors = Some(vec![[1.0; 3]; 3]);
392        let m = Model { meshes: vec![bad, Mesh::default()] }.finish().unwrap();
393        assert_eq!(m.meshes.len(), 1);
394        assert_eq!(m.triangle_count(), 12);
395        assert_eq!(m.meshes[0].colors, None);
396        assert!(Model::default().finish().is_err());
397        let (c, r) = m.bounding_sphere().unwrap();
398        assert_eq!(c, [0.5, 0.5, 0.5]);
399        assert!((r - 0.75f32.sqrt()).abs() < 1e-6);
400    }
401}