Skip to main content

kvas_engine/
mesh_uv.rs

1//! UV unwrapping and normal smoothing for [`EditableMesh`]: projections, UV transforms, flat shading
2//! and crease-angle smoothing. All methods work on local-space geometry and mark the mesh as changed.
3//!
4//! ```
5//! use kvas_engine::{mesh::MeshData, mesh_edit::EditableMesh};
6//! use glam::Vec3;
7//! let mut mesh = EditableMesh::from_mesh(&MeshData::primitive("cube").unwrap()).unwrap();
8//! mesh.project_planar(Vec3::Y, 2.0);   // 2 metres per texture repeat, seen from above
9//! mesh.normalize_uv(0.02);             // fit into 0..1 with a small border
10//! mesh.smooth_normals(40.0);           // edges sharper than 40 degrees stay hard
11//! ```
12
13use crate::mesh_edit::EditableMesh;
14use glam::Vec3;
15use std::collections::HashMap;
16
17fn key(p: [f32; 3]) -> [i64; 3] {
18    p.map(|v| (v as f64 * 10_000.0).round() as i64)
19}
20
21impl EditableMesh {
22    /// Planar projection: UVs are the position on the plane perpendicular to `axis`, divided by `scale`
23    /// (metres per UV unit). Best for floors, walls and decals. Does nothing for a zero axis or `scale <= 0`.
24    pub fn project_planar(&mut self, axis: Vec3, scale: f32) {
25        let n = axis.normalize_or_zero();
26        if n == Vec3::ZERO || scale <= 0.0 {
27            return;
28        }
29        let u = n.any_orthonormal_vector();
30        let v = n.cross(u);
31        for vert in &mut self.vertices {
32            let p = Vec3::from(vert.pos);
33            vert.uv = [p.dot(u) / scale, p.dot(v) / scale];
34        }
35        self.changed();
36    }
37
38    /// Cylindrical projection around the Y axis: U is the angle (0..1 around), V the height divided by `scale`.
39    /// Good for trunks, bottles and columns. The seam sits at +X.
40    pub fn project_cylindrical(&mut self, scale: f32) {
41        if scale <= 0.0 {
42            return;
43        }
44        for vert in &mut self.vertices {
45            let p = Vec3::from(vert.pos);
46            vert.uv = [p.z.atan2(p.x) / std::f32::consts::TAU + 0.5, p.y / scale];
47        }
48        self.changed();
49    }
50
51    /// Spherical projection around the origin: U is longitude, V latitude (both 0..1). Good for planets and balls.
52    pub fn project_spherical(&mut self) {
53        for vert in &mut self.vertices {
54            let p = Vec3::from(vert.pos).normalize_or_zero();
55            vert.uv = [p.z.atan2(p.x) / std::f32::consts::TAU + 0.5, p.y.clamp(-1.0, 1.0).asin() / std::f32::consts::PI + 0.5];
56        }
57        self.changed();
58    }
59
60    /// Fits the UVs into the 0..1 square keeping their proportions, with a `margin` border (0..0.45).
61    pub fn normalize_uv(&mut self, margin: f32) {
62        if self.vertices.is_empty() {
63            return;
64        }
65        let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
66        for v in &self.vertices {
67            for k in 0..2 {
68                lo[k] = lo[k].min(v.uv[k]);
69                hi[k] = hi[k].max(v.uv[k]);
70            }
71        }
72        let margin = margin.clamp(0.0, 0.45);
73        let size = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-6);
74        let k = (1.0 - 2.0 * margin) / size;
75        for v in &mut self.vertices {
76            v.uv = [(v.uv[0] - lo[0]) * k + margin, (v.uv[1] - lo[1]) * k + margin];
77        }
78        self.changed();
79    }
80
81    /// Scales, rotates (degrees) and offsets every UV around (0.5, 0.5).
82    pub fn transform_uv(&mut self, scale: [f32; 2], rotation_degrees: f32, offset: [f32; 2]) {
83        let (s, c) = rotation_degrees.to_radians().sin_cos();
84        for v in &mut self.vertices {
85            let (x, y) = ((v.uv[0] - 0.5) * scale[0], (v.uv[1] - 0.5) * scale[1]);
86            v.uv = [c * x - s * y + 0.5 + offset[0], s * x + c * y + 0.5 + offset[1]];
87        }
88        self.changed();
89    }
90
91    /// Flat shading: every triangle gets its own face normal (vertices are split per triangle).
92    pub fn flat_normals(&mut self) {
93        let mut vertices = Vec::with_capacity(self.indices.len());
94        for t in self.indices.chunks_exact(3) {
95            let (a, b, c) = (self.vertices[t[0] as usize], self.vertices[t[1] as usize], self.vertices[t[2] as usize]);
96            let n = (Vec3::from(b.pos) - Vec3::from(a.pos)).cross(Vec3::from(c.pos) - Vec3::from(a.pos)).normalize_or_zero().to_array();
97            for mut v in [a, b, c] {
98                v.normal = n;
99                vertices.push(v);
100            }
101        }
102        self.indices = (0..vertices.len() as u32).collect();
103        self.vertices = vertices;
104        self.changed();
105    }
106
107    /// Smooth shading with a crease angle: neighbouring faces closer than `angle_degrees` share a smooth
108    /// normal, sharper edges stay hard. Vertices are split along creases.
109    pub fn smooth_normals(&mut self, angle_degrees: f32) {
110        let faces: Vec<(Vec3, [usize; 3])> = self
111            .indices
112            .chunks_exact(3)
113            .map(|t| {
114                let p = |i: u32| Vec3::from(self.vertices[i as usize].pos);
115                ((p(t[1]) - p(t[0])).cross(p(t[2]) - p(t[0])), [t[0] as usize, t[1] as usize, t[2] as usize])
116            })
117            .collect();
118        let mut at_point = HashMap::<[i64; 3], Vec<usize>>::new();
119        for (f, (_, ids)) in faces.iter().enumerate() {
120            for &i in ids {
121                at_point.entry(key(self.vertices[i].pos)).or_default().push(f);
122            }
123        }
124        let cos = angle_degrees.to_radians().cos();
125        let mut vertices = Vec::with_capacity(self.indices.len());
126        for (f, (normal, ids)) in faces.iter().enumerate() {
127            let n0 = normal.normalize_or_zero();
128            for &i in ids {
129                let mut sum = Vec3::ZERO;
130                for &other in &at_point[&key(self.vertices[i].pos)] {
131                    if other == f || n0.dot(faces[other].0.normalize_or_zero()) >= cos {
132                        sum += faces[other].0;
133                    }
134                }
135                let mut v = self.vertices[i];
136                v.normal = sum.normalize_or(if n0 == Vec3::ZERO { Vec3::from(v.normal) } else { n0 }).to_array();
137                vertices.push(v);
138            }
139        }
140        self.indices = (0..vertices.len() as u32).collect();
141        self.vertices = vertices;
142        self.changed();
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::mesh::MeshData;
150
151    fn primitive(name: &str) -> EditableMesh {
152        EditableMesh::from_mesh(&MeshData::primitive(name).unwrap()).unwrap()
153    }
154
155    #[test]
156    fn projections_stay_finite_and_normalize_into_the_unit_square() {
157        let mut m = primitive("cube");
158        m.project_planar(Vec3::Y, 2.0);
159        m.normalize_uv(0.05);
160        assert!(m.vertices.iter().all(|v| v.uv.iter().all(|c| (0.0..=1.0).contains(c))));
161        m.project_spherical();
162        m.project_cylindrical(1.0);
163        m.transform_uv([2.0, 2.0], 45.0, [0.1, 0.0]);
164        assert!(m.vertices.iter().all(|v| v.uv.iter().all(|c| c.is_finite())));
165        m.project_planar(Vec3::ZERO, 1.0);
166    }
167
168    #[test]
169    fn flat_and_crease_normals() {
170        let mut m = primitive("cube");
171        let faces = m.indices.len() / 3;
172        m.flat_normals();
173        assert_eq!(m.vertex_count(), faces * 3);
174        let mut sphere = primitive("sphere");
175        sphere.smooth_normals(60.0);
176        assert!(sphere.vertices.iter().all(|v| (Vec3::from(v.normal).length() - 1.0).abs() < 1e-3));
177        let mut hard = primitive("cube");
178        hard.smooth_normals(30.0);
179        assert!(hard.vertices.iter().all(|v| Vec3::from(v.normal).abs().max_element() > 0.99), "90 degree edges stay hard");
180    }
181}