Skip to main content

embedded_3dgfx/
mesh.rs

1#[cfg(feature = "aabb-cull")]
2use crate::bounds::Aabb;
3#[cfg(feature = "render-layers")]
4use crate::render_layers::RenderLayers;
5#[cfg(feature = "lod-crossfade")]
6use core::cell::Cell;
7use embedded_graphics_core::pixelcolor::{Rgb565, WebColors};
8use heapless::Vec;
9use heapless::index_set::FnvIndexSet;
10use log::error;
11use nalgebra::{Point3, Similarity3, UnitQuaternion, Vector3};
12
13#[cfg(not(feature = "std"))]
14#[allow(unused_imports)]
15use micromath::F32Ext;
16
17#[derive(Debug, PartialEq, Clone)]
18pub enum RenderMode {
19    Points,
20    Lines,
21    Solid,
22    #[cfg(feature = "lighting")]
23    SolidLightDir(Vector3<f32>),
24    #[cfg(feature = "lighting")]
25    BlinnPhong {
26        light_dir: Vector3<f32>,
27        specular_intensity: f32,
28        shininess: f32,
29    },
30    #[cfg(feature = "lighting")]
31    GouraudLightDir(Vector3<f32>),
32    /// Flat-shaded with a uniform brightness level (0=black, 255=full color).
33    /// Used for Doom-style sector-based lighting.
34    #[cfg(feature = "lighting")]
35    SectorBright(u8),
36    /// Flat, unlit, texture-sampled -- the `Solid` mode's texture-mapped
37    /// counterpart. Requires `geometry.uvs` (one per vertex) and
38    /// `geometry.texture_id`; faces are silently skipped if either is
39    /// missing. Must be drawn via [`crate::engine::K3dengine::execute_with_textures`]
40    /// (plain [`crate::engine::K3dengine::execute`] can't resolve `texture_id`
41    /// without a [`crate::texture::TextureManager`]).
42    #[cfg(feature = "textured")]
43    Textured,
44    /// Textured surface combined with Gouraud lighting.
45    #[cfg(feature = "textured")]
46    TexturedGouraud(Vector3<f32>),
47    /// Spherical Environment Mapping (MatCap) shading. Procedurally generates
48    /// UV coordinates based on camera-space normals. Uses the mesh's
49    /// `geometry.texture_id` as the MatCap sphere map.
50    #[cfg(feature = "textured")]
51    MatCap,
52    /// Toon cel-shading with a light direction and number of discrete shading bands.
53    #[cfg(feature = "lighting")]
54    Toon(Vector3<f32>, u8),
55}
56#[derive(Debug, Default, Copy, Clone)]
57pub struct Geometry<'a> {
58    pub vertices: &'a [[f32; 3]],
59    pub faces: &'a [[usize; 3]],
60    pub colors: &'a [Rgb565],
61    pub lines: &'a [[usize; 2]],
62    pub normals: &'a [[f32; 3]],
63    /// Per-vertex normals for smooth (Gouraud) shading.
64    /// If non-empty, must have the same length as `vertices`.
65    pub vertex_normals: &'a [[f32; 3]],
66    /// UV texture coordinates (one per vertex)
67    pub uvs: &'a [[f32; 2]],
68    /// Optional texture ID for this geometry
69    pub texture_id: Option<u32>,
70}
71
72impl Geometry<'_> {
73    fn check_validity(&self) -> bool {
74        if self.vertices.is_empty() {
75            error!("Vertices are empty");
76            return false;
77        }
78
79        for face in self.faces {
80            if face[0] >= self.vertices.len()
81                || face[1] >= self.vertices.len()
82                || face[2] >= self.vertices.len()
83            {
84                error!("Face vertices are out of bounds");
85                return false;
86            }
87        }
88
89        for line in self.lines {
90            if line[0] >= self.vertices.len() || line[1] >= self.vertices.len() {
91                error!("Line vertices are out of bounds");
92                return false;
93            }
94        }
95
96        if !self.colors.is_empty() && self.colors.len() != self.vertices.len() {
97            error!("Colors are not the same length as vertices");
98            return false;
99        }
100
101        if !self.uvs.is_empty() && self.uvs.len() != self.vertices.len() {
102            error!("UVs are not the same length as vertices");
103            return false;
104        }
105
106        if !self.vertex_normals.is_empty() && self.vertex_normals.len() != self.vertices.len() {
107            error!("Vertex normals are not the same length as vertices");
108            return false;
109        }
110
111        true
112    }
113
114    /// Converts faces to unique edge pairs for line rendering.
115    ///
116    /// # Type Parameters
117    /// * `N` - Maximum capacity for the edges buffer. For a closed mesh, a good estimate is
118    ///   `faces.len() * 3 / 2` since each edge is typically shared by 2 faces.
119    ///
120    /// # Returns
121    /// A heapless Vec containing unique edge pairs. If capacity is exceeded, returns
122    /// partial results with an error logged.
123    pub fn lines_from_faces<const N: usize>(faces: &[[usize; 3]]) -> Vec<(usize, usize), N> {
124        let mut set: FnvIndexSet<(usize, usize), N> = FnvIndexSet::new();
125        for face in faces {
126            for &(i1, i2) in &[(face[0], face[1]), (face[1], face[2]), (face[2], face[0])] {
127                let edge = if i1 < i2 { (i1, i2) } else { (i2, i1) };
128                if set.insert(edge).is_err() {
129                    error!(
130                        "lines_from_faces: heapless Vec capacity exceeded (max {}). Some edges will not be rendered.",
131                        N
132                    );
133                    break;
134                }
135            }
136        }
137        set.iter().copied().collect()
138    }
139
140    /// Calculate the outward unit normal for a single triangular face from 3 vertex positions.
141    #[inline]
142    pub fn calculate_face_normal(v0: &[f32; 3], v1: &[f32; 3], v2: &[f32; 3]) -> [f32; 3] {
143        let edge1 = Vector3::new(v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]);
144        let edge2 = Vector3::new(v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]);
145        let cross = edge1.cross(&edge2);
146        let norm_sq = cross.norm_squared();
147        if norm_sq > 1e-12 {
148            let inv_len = 1.0 / nalgebra::ComplexField::sqrt(norm_sq);
149            [cross.x * inv_len, cross.y * inv_len, cross.z * inv_len]
150        } else {
151            [0.0, 1.0, 0.0]
152        }
153    }
154
155    /// Compute face normals for a set of vertices and faces into a caller-provided slice buffer.
156    ///
157    /// The `out_normals` slice should have at least `faces.len()` elements.
158    /// Returns the number of normals written.
159    pub fn compute_face_normals_into(
160        vertices: &[[f32; 3]],
161        faces: &[[usize; 3]],
162        out_normals: &mut [[f32; 3]],
163    ) -> usize {
164        let count = faces.len().min(out_normals.len());
165        for (i, face) in faces.iter().take(count).enumerate() {
166            if face[0] < vertices.len() && face[1] < vertices.len() && face[2] < vertices.len() {
167                out_normals[i] = Self::calculate_face_normal(
168                    &vertices[face[0]],
169                    &vertices[face[1]],
170                    &vertices[face[2]],
171                );
172            } else {
173                out_normals[i] = [0.0, 1.0, 0.0];
174            }
175        }
176        count
177    }
178
179    /// Helper for computing and allocating face normals as a standard `Vec` (available when `std` feature is enabled).
180    #[cfg(feature = "std")]
181    pub fn compute_face_normals(
182        vertices: &[[f32; 3]],
183        faces: &[[usize; 3]],
184    ) -> std::vec::Vec<[f32; 3]> {
185        let mut normals = std::vec::Vec::with_capacity(faces.len());
186        for face in faces {
187            if face[0] < vertices.len() && face[1] < vertices.len() && face[2] < vertices.len() {
188                normals.push(Self::calculate_face_normal(
189                    &vertices[face[0]],
190                    &vertices[face[1]],
191                    &vertices[face[2]],
192                ));
193            } else {
194                normals.push([0.0, 1.0, 0.0]);
195            }
196        }
197        normals
198    }
199}
200
201/// Level of Detail configuration for a mesh
202///
203/// Defines distance thresholds for switching between LOD levels:
204/// - 0 to high_distance: Use high detail geometry
205/// - high_distance to medium_distance: Use medium detail geometry
206/// - Beyond medium_distance: Use low detail geometry
207///
208/// When [`Self::fade_margin`] > 0, LODs crossfade over that distance band
209/// (distance-band margins) instead of switching abruptly.
210#[derive(Debug, Clone, Copy)]
211pub struct LODLevels {
212    /// Distance threshold for high detail (0 to this distance)
213    pub high_distance: f32,
214    /// Distance threshold for medium detail (high_distance to this distance)
215    pub medium_distance: f32,
216    /// Crossfade half-width in world units around each LOD boundary.
217    /// `0.0` (default) keeps abrupt switches. Requires `lod-crossfade`.
218    #[cfg(feature = "lod-crossfade")]
219    pub fade_margin: f32,
220}
221
222impl Default for LODLevels {
223    fn default() -> Self {
224        Self {
225            high_distance: 50.0,
226            medium_distance: 100.0,
227            #[cfg(feature = "lod-crossfade")]
228            fade_margin: 0.0,
229        }
230    }
231}
232
233/// Result of LOD selection, including optional crossfade.
234#[cfg(feature = "lod-crossfade")]
235#[derive(Debug, Clone, Copy)]
236pub enum LodPick<'a> {
237    /// Single geometry, fully opaque.
238    Single(&'a Geometry<'a>),
239    /// Blend between two LOD levels. `t = 0` is fully `near`, `t = 1` is fully `far`.
240    Crossfade {
241        near: &'a Geometry<'a>,
242        far: &'a Geometry<'a>,
243        t: f32,
244    },
245}
246
247/// A mesh with optional Level of Detail (LOD) support
248pub struct K3dMesh<'a> {
249    pub similarity: Similarity3<f32>,
250    pub model_matrix: nalgebra::Matrix4<f32>,
251
252    pub color: Rgb565,
253    pub render_mode: RenderMode,
254    pub geometry: Geometry<'a>,
255
256    /// Optional LOD geometries (medium detail, low detail)
257    /// If None, only the main geometry is used
258    pub lod_medium: Option<Geometry<'a>>,
259    pub lod_low: Option<Geometry<'a>>,
260    pub lod_levels: LODLevels,
261    pub priority: u8,
262    pub outline_color: Option<Rgb565>,
263    pub outline_width: f32,
264    /// Cached model-space AABB used for two-stage frustum culling / raycast.
265    /// Call [`Self::cache_aabb`] after geometry changes (or at load time).
266    #[cfg(feature = "aabb-cull")]
267    pub aabb: Option<Aabb>,
268    /// Visibility layers. Default: layer 0 (intersects the default camera).
269    #[cfg(feature = "render-layers")]
270    pub layers: RenderLayers,
271    /// Force a specific LOD level for the next render pass (`0` high, `1` medium, `2` low).
272    /// Used internally for crossfade; clear after use.
273    #[cfg(feature = "lod-crossfade")]
274    pub(crate) lod_force: Cell<Option<u8>>,
275    /// Optional per-primitive alpha override for the next render pass (crossfade).
276    #[cfg(feature = "lod-crossfade")]
277    pub(crate) draw_alpha: Cell<Option<u8>>,
278}
279
280impl<'a> K3dMesh<'a> {
281    pub fn new(geometry: Geometry) -> K3dMesh {
282        assert!(geometry.check_validity());
283        let sim = Similarity3::new(Vector3::new(0.0, 0.0, 0.0), nalgebra::zero(), 1.0);
284        K3dMesh {
285            model_matrix: sim.to_homogeneous(),
286            similarity: sim,
287            color: Rgb565::CSS_WHITE,
288            render_mode: RenderMode::Points,
289            geometry,
290            lod_medium: None,
291            lod_low: None,
292            lod_levels: LODLevels::default(),
293            priority: 128,
294            outline_color: None,
295            outline_width: 0.0,
296            #[cfg(feature = "aabb-cull")]
297            aabb: Aabb::enclosing(geometry.vertices.iter()),
298            #[cfg(feature = "render-layers")]
299            layers: RenderLayers::DEFAULT,
300            #[cfg(feature = "lod-crossfade")]
301            lod_force: Cell::new(None),
302            #[cfg(feature = "lod-crossfade")]
303            draw_alpha: Cell::new(None),
304        }
305    }
306
307    /// Set LOD geometries for this mesh
308    ///
309    /// # Arguments
310    /// * `medium` - Medium detail geometry (optional)
311    /// * `low` - Low detail geometry (optional)
312    /// * `levels` - Distance thresholds for switching LOD levels
313    pub fn set_lod<'b>(
314        &mut self,
315        medium: Option<Geometry<'b>>,
316        low: Option<Geometry<'b>>,
317        levels: LODLevels,
318    ) where
319        'b: 'a,
320    {
321        if let Some(ref geom) = medium {
322            assert!(geom.check_validity());
323        }
324        if let Some(ref geom) = low {
325            assert!(geom.check_validity());
326        }
327        self.lod_medium = medium;
328        self.lod_low = low;
329        self.lod_levels = levels;
330    }
331
332    /// Select the appropriate geometry based on distance from camera
333    #[inline]
334    pub fn select_lod(&self, distance: f32) -> &Geometry<'_> {
335        #[cfg(feature = "lod-crossfade")]
336        {
337            if let Some(level) = self.lod_force.get() {
338                return self.geometry_for_lod_level(level);
339            }
340            match self.select_lod_pick(distance) {
341                LodPick::Single(g) => g,
342                LodPick::Crossfade { near, far, t } => {
343                    if t < 0.5 {
344                        near
345                    } else {
346                        far
347                    }
348                }
349            }
350        }
351        #[cfg(not(feature = "lod-crossfade"))]
352        {
353            if distance < self.lod_levels.high_distance {
354                &self.geometry
355            } else if distance < self.lod_levels.medium_distance {
356                self.lod_medium.as_ref().unwrap_or(&self.geometry)
357            } else {
358                self.lod_low
359                    .as_ref()
360                    .unwrap_or(self.lod_medium.as_ref().unwrap_or(&self.geometry))
361            }
362        }
363    }
364
365    #[cfg(feature = "lod-crossfade")]
366    #[inline]
367    fn geometry_for_lod_level(&self, level: u8) -> &Geometry<'_> {
368        match level {
369            0 => &self.geometry,
370            1 => self.lod_medium.as_ref().unwrap_or(&self.geometry),
371            _ => self
372                .lod_low
373                .as_ref()
374                .unwrap_or(self.lod_medium.as_ref().unwrap_or(&self.geometry)),
375        }
376    }
377
378    /// Map a geometry pointer from [`Self::select_lod_pick`] back to a LOD level id.
379    #[cfg(feature = "lod-crossfade")]
380    #[inline]
381    pub(crate) fn lod_level_of(&self, geom: &Geometry<'_>) -> u8 {
382        if core::ptr::eq(geom as *const _, &self.geometry as *const _) {
383            0
384        } else if self
385            .lod_medium
386            .as_ref()
387            .is_some_and(|m| core::ptr::eq(geom as *const _, m as *const _))
388        {
389            1
390        } else {
391            2
392        }
393    }
394
395    /// LOD selection with optional crossfade when [`LODLevels::fade_margin`] > 0.
396    #[cfg(feature = "lod-crossfade")]
397    #[inline]
398    pub fn select_lod_pick(&self, distance: f32) -> LodPick<'_> {
399        let high = &self.geometry;
400        let medium = self.lod_medium.as_ref().unwrap_or(high);
401        let low = self
402            .lod_low
403            .as_ref()
404            .unwrap_or(self.lod_medium.as_ref().unwrap_or(high));
405
406        let margin = self.lod_levels.fade_margin;
407        if margin <= 0.0 {
408            return if distance < self.lod_levels.high_distance {
409                LodPick::Single(high)
410            } else if distance < self.lod_levels.medium_distance {
411                LodPick::Single(medium)
412            } else {
413                LodPick::Single(low)
414            };
415        }
416
417        let h = self.lod_levels.high_distance;
418        let m = self.lod_levels.medium_distance;
419
420        if distance < h - margin {
421            LodPick::Single(high)
422        } else if distance < h + margin {
423            let t = ((distance - (h - margin)) / (2.0 * margin)).clamp(0.0, 1.0);
424            if core::ptr::eq(high as *const _, medium as *const _) {
425                LodPick::Single(high)
426            } else {
427                LodPick::Crossfade {
428                    near: high,
429                    far: medium,
430                    t,
431                }
432            }
433        } else if distance < m - margin {
434            LodPick::Single(medium)
435        } else if distance < m + margin {
436            let t = ((distance - (m - margin)) / (2.0 * margin)).clamp(0.0, 1.0);
437            if core::ptr::eq(medium as *const _, low as *const _) {
438                LodPick::Single(medium)
439            } else {
440                LodPick::Crossfade {
441                    near: medium,
442                    far: low,
443                    t,
444                }
445            }
446        } else {
447            LodPick::Single(low)
448        }
449    }
450
451    pub fn set_color(&mut self, color: Rgb565) {
452        self.color = color;
453    }
454
455    pub fn set_render_mode(&mut self, mode: RenderMode) {
456        self.render_mode = mode;
457    }
458
459    pub fn set_priority(&mut self, priority: u8) {
460        self.priority = priority;
461    }
462
463    #[cfg(feature = "render-layers")]
464    pub fn set_layers(&mut self, layers: RenderLayers) {
465        self.layers = layers;
466    }
467
468    /// Recompute and cache the model-space AABB from the primary geometry.
469    #[cfg(feature = "aabb-cull")]
470    pub fn cache_aabb(&mut self) {
471        self.aabb = Aabb::enclosing(self.geometry.vertices.iter());
472    }
473
474    /// Override the cached AABB (e.g. skinned bounds in model space).
475    #[cfg(feature = "aabb-cull")]
476    pub fn set_aabb(&mut self, aabb: Aabb) {
477        self.aabb = Some(aabb);
478    }
479
480    /// Model-space AABB, computing on demand if not cached.
481    #[cfg(feature = "aabb-cull")]
482    #[inline]
483    pub fn model_aabb(&self) -> Aabb {
484        self.aabb
485            .unwrap_or_else(|| Aabb::enclosing(self.geometry.vertices.iter()).unwrap_or(Aabb::ZERO))
486    }
487
488    pub fn set_position(&mut self, x: f32, y: f32, z: f32) {
489        self.similarity.isometry.translation.x = x;
490        self.similarity.isometry.translation.y = y;
491        self.similarity.isometry.translation.z = z;
492        self.update_model_matrix();
493    }
494
495    pub fn get_position(&self) -> Point3<f32> {
496        self.similarity.isometry.translation.vector.into()
497    }
498
499    pub fn set_attitude(&mut self, roll: f32, pitch: f32, yaw: f32) {
500        self.similarity.isometry.rotation = UnitQuaternion::from_euler_angles(roll, pitch, yaw);
501        self.update_model_matrix();
502    }
503
504    /// Set orientation directly from a unit quaternion.
505    pub fn set_rotation(&mut self, rotation: UnitQuaternion<f32>) {
506        self.similarity.isometry.rotation = rotation;
507        self.update_model_matrix();
508    }
509
510    pub fn set_target(&mut self, target: Point3<f32>) {
511        let view = Similarity3::look_at_rh(
512            &self.similarity.isometry.translation.vector.into(),
513            &target,
514            &Vector3::y(),
515            1.0,
516        );
517
518        self.similarity = view;
519        self.model_matrix = self.similarity.to_homogeneous();
520    }
521
522    pub fn set_scale(&mut self, s: f32) {
523        if s == 0.0 {
524            return;
525        }
526        self.similarity.set_scaling(s);
527        self.update_model_matrix();
528    }
529
530    fn update_model_matrix(&mut self) {
531        self.model_matrix = self.similarity.to_homogeneous();
532    }
533
534    /// Compute the squared bounding sphere radius of the mesh in world-ish
535    /// scale (model-space radius × uniform scale²).
536    ///
537    /// With `aabb-cull`, uses the cached AABB when present (tighter than
538    /// origin-centered verts).
539    #[inline]
540    pub fn compute_bounding_radius_sq(&self) -> f32 {
541        let scale = self.similarity.scaling();
542        let scale_sq = scale * scale;
543        #[cfg(feature = "aabb-cull")]
544        if let Some(aabb) = self.aabb {
545            let center_sq = aabb.center.norm_squared();
546            let r = aabb.radius();
547            let rad = r + center_sq.sqrt();
548            return rad * rad * scale_sq;
549        }
550        let mut max_dist_sq = 0.0f32;
551        for vertex in self.geometry.vertices {
552            let dist_sq = vertex[0] * vertex[0] + vertex[1] * vertex[1] + vertex[2] * vertex[2];
553            if dist_sq > max_dist_sq {
554                max_dist_sq = dist_sq;
555            }
556        }
557        max_dist_sq * scale_sq
558    }
559}
560
561/// Compute per-vertex normals by averaging the face normals of all faces
562/// that share each vertex, then normalizing.
563///
564/// # Type Parameters
565/// * `V` - Maximum number of vertices (capacity of the returned Vec)
566///
567/// # Returns
568/// A heapless Vec with one normal per vertex. Vertices not referenced by any
569/// face get a zero normal.
570pub fn compute_vertex_normals<const V: usize>(
571    vertices: &[[f32; 3]],
572    faces: &[[usize; 3]],
573    face_normals: &[[f32; 3]],
574) -> Vec<[f32; 3], V> {
575    let mut normals = Vec::<[f32; 3], V>::new();
576    for _ in 0..vertices.len() {
577        if normals.push([0.0, 0.0, 0.0]).is_err() {
578            break;
579        }
580    }
581
582    for (face, fn_arr) in faces.iter().zip(face_normals.iter()) {
583        for &vi in face {
584            if vi < normals.len() {
585                normals[vi][0] += fn_arr[0];
586                normals[vi][1] += fn_arr[1];
587                normals[vi][2] += fn_arr[2];
588            }
589        }
590    }
591
592    for n in normals.iter_mut() {
593        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
594        if len > 1e-10 {
595            n[0] /= len;
596            n[1] /= len;
597            n[2] /= len;
598        }
599    }
600
601    normals
602}
603
604#[cfg(test)]
605mod tests {
606    extern crate std;
607    use super::*;
608
609    #[test]
610    fn test_geometry_validation_valid() {
611        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
612        let faces = [[0, 1, 2]];
613
614        let geometry = Geometry {
615            vertices: &vertices,
616            faces: &faces,
617            colors: &[],
618            lines: &[],
619            normals: &[],
620            vertex_normals: &[],
621            uvs: &[],
622            texture_id: None,
623        };
624
625        assert!(geometry.check_validity());
626    }
627
628    #[test]
629    #[should_panic]
630    fn test_geometry_validation_invalid_face_index() {
631        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
632        let faces = [[0, 1, 5]]; // Index 5 is out of bounds
633
634        let geometry = Geometry {
635            vertices: &vertices,
636            faces: &faces,
637            colors: &[],
638            lines: &[],
639            normals: &[],
640            vertex_normals: &[],
641            uvs: &[],
642            texture_id: None,
643        };
644
645        // This should panic because we call assert! in K3dMesh::new
646        K3dMesh::new(geometry);
647    }
648
649    #[test]
650    #[should_panic]
651    fn test_geometry_validation_invalid_line_index() {
652        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
653        let lines = [[0, 10]]; // Index 10 is out of bounds
654
655        let geometry = Geometry {
656            vertices: &vertices,
657            faces: &[],
658            colors: &[],
659            lines: &lines,
660            normals: &[],
661            vertex_normals: &[],
662            uvs: &[],
663            texture_id: None,
664        };
665
666        K3dMesh::new(geometry);
667    }
668
669    #[test]
670    #[should_panic]
671    fn test_geometry_validation_color_length_mismatch() {
672        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
673        let colors = [Rgb565::CSS_RED]; // Only 1 color for 2 vertices
674
675        let geometry = Geometry {
676            vertices: &vertices,
677            faces: &[],
678            colors: &colors,
679            lines: &[],
680            normals: &[],
681            vertex_normals: &[],
682            uvs: &[],
683            texture_id: None,
684        };
685
686        K3dMesh::new(geometry);
687    }
688
689    #[test]
690    fn test_lines_from_faces_basic() {
691        let faces = [[0, 1, 2]];
692        let lines = Geometry::lines_from_faces::<16>(&faces);
693
694        // Triangle should produce 3 unique edges
695        assert_eq!(lines.len(), 3);
696
697        // Check that edges are unique and normalized (smaller index first)
698        let expected_edges = [(0, 1), (0, 2), (1, 2)];
699        for edge in expected_edges.iter() {
700            assert!(lines.contains(edge));
701        }
702    }
703
704    #[test]
705    fn test_lines_from_faces_shared_edges() {
706        let faces = [[0, 1, 2], [0, 2, 3]];
707        let lines = Geometry::lines_from_faces::<16>(&faces);
708
709        // Two triangles sharing edge (0,2) should produce 5 unique edges
710        assert_eq!(lines.len(), 5);
711    }
712
713    #[test]
714    fn test_lines_from_faces_capacity_limit() {
715        let faces = [[0, 1, 2], [3, 4, 5]];
716        // Small capacity that can't hold all 6 edges (needs at least 16 for IndexSet)
717        let lines = Geometry::lines_from_faces::<16>(&faces);
718
719        // Should contain all 6 edges since capacity is sufficient
720        assert_eq!(lines.len(), 6);
721    }
722
723    #[test]
724    fn test_mesh_creation() {
725        let vertices = [[0.0, 0.0, 0.0]];
726        let geometry = Geometry {
727            vertices: &vertices,
728            faces: &[],
729            colors: &[],
730            lines: &[],
731            normals: &[],
732            vertex_normals: &[],
733            uvs: &[],
734            texture_id: None,
735        };
736
737        let mesh = K3dMesh::new(geometry);
738        assert_eq!(mesh.color, Rgb565::CSS_WHITE);
739        assert_eq!(mesh.render_mode, RenderMode::Points);
740        assert_eq!(mesh.get_position(), Point3::new(0.0, 0.0, 0.0));
741    }
742
743    #[test]
744    fn test_mesh_set_color() {
745        let vertices = [[0.0, 0.0, 0.0]];
746        let geometry = Geometry {
747            vertices: &vertices,
748            faces: &[],
749            colors: &[],
750            lines: &[],
751            normals: &[],
752            vertex_normals: &[],
753            uvs: &[],
754            texture_id: None,
755        };
756
757        let mut mesh = K3dMesh::new(geometry);
758        mesh.set_color(Rgb565::CSS_RED);
759        assert_eq!(mesh.color, Rgb565::CSS_RED);
760    }
761
762    #[test]
763    fn test_mesh_set_position() {
764        let vertices = [[0.0, 0.0, 0.0]];
765        let geometry = Geometry {
766            vertices: &vertices,
767            faces: &[],
768            colors: &[],
769            lines: &[],
770            normals: &[],
771            vertex_normals: &[],
772            uvs: &[],
773            texture_id: None,
774        };
775
776        let mut mesh = K3dMesh::new(geometry);
777        mesh.set_position(5.0, 10.0, 15.0);
778        assert_eq!(mesh.get_position(), Point3::new(5.0, 10.0, 15.0));
779    }
780
781    #[test]
782    fn test_mesh_set_scale() {
783        let vertices = [[0.0, 0.0, 0.0]];
784        let geometry = Geometry {
785            vertices: &vertices,
786            faces: &[],
787            colors: &[],
788            lines: &[],
789            normals: &[],
790            vertex_normals: &[],
791            uvs: &[],
792            texture_id: None,
793        };
794
795        let mut mesh = K3dMesh::new(geometry);
796        mesh.set_scale(2.0);
797        assert!((mesh.similarity.scaling() - 2.0).abs() < 0.001);
798    }
799
800    #[test]
801    fn test_mesh_set_scale_zero_ignored() {
802        let vertices = [[0.0, 0.0, 0.0]];
803        let geometry = Geometry {
804            vertices: &vertices,
805            faces: &[],
806            colors: &[],
807            lines: &[],
808            normals: &[],
809            vertex_normals: &[],
810            uvs: &[],
811            texture_id: None,
812        };
813
814        let mut mesh = K3dMesh::new(geometry);
815        let original_scale = mesh.similarity.scaling();
816        mesh.set_scale(0.0);
817        // Scale should remain unchanged
818        assert_eq!(mesh.similarity.scaling(), original_scale);
819    }
820
821    #[test]
822    fn test_mesh_set_attitude() {
823        let vertices = [[0.0, 0.0, 0.0]];
824        let geometry = Geometry {
825            vertices: &vertices,
826            faces: &[],
827            colors: &[],
828            lines: &[],
829            normals: &[],
830            vertex_normals: &[],
831            uvs: &[],
832            texture_id: None,
833        };
834
835        let mut mesh = K3dMesh::new(geometry);
836        mesh.set_attitude(0.1, 0.2, 0.3);
837        // Just verify it doesn't panic and updates the matrix
838        assert_ne!(mesh.model_matrix, nalgebra::Matrix4::identity());
839    }
840
841    #[test]
842    fn test_mesh_set_target() {
843        let vertices = [[0.0, 0.0, 0.0]];
844        let geometry = Geometry {
845            vertices: &vertices,
846            faces: &[],
847            colors: &[],
848            lines: &[],
849            normals: &[],
850            vertex_normals: &[],
851            uvs: &[],
852            texture_id: None,
853        };
854
855        let mut mesh = K3dMesh::new(geometry);
856        mesh.set_position(5.0, 5.0, 5.0);
857        mesh.set_target(Point3::new(0.0, 0.0, 0.0));
858        // Mesh should now be oriented toward origin
859        // Just verify it doesn't panic
860        assert_ne!(mesh.model_matrix, nalgebra::Matrix4::identity());
861    }
862
863    #[test]
864    fn test_mesh_render_mode_changes() {
865        let vertices = [[0.0, 0.0, 0.0]];
866        let geometry = Geometry {
867            vertices: &vertices,
868            faces: &[],
869            colors: &[],
870            lines: &[],
871            normals: &[],
872            vertex_normals: &[],
873            uvs: &[],
874            texture_id: None,
875        };
876
877        let mut mesh = K3dMesh::new(geometry);
878
879        mesh.set_render_mode(RenderMode::Lines);
880        assert_eq!(mesh.render_mode, RenderMode::Lines);
881
882        mesh.set_render_mode(RenderMode::Solid);
883        assert_eq!(mesh.render_mode, RenderMode::Solid);
884
885        #[cfg(feature = "lighting")]
886        {
887            mesh.set_render_mode(RenderMode::SolidLightDir(Vector3::new(0.0, 1.0, 0.0)));
888            assert!(matches!(mesh.render_mode, RenderMode::SolidLightDir(_)));
889        }
890    }
891
892    #[test]
893    fn test_compute_vertex_normals_single_triangle() {
894        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
895        let faces = [[0, 1, 2]];
896        let face_normals = [[0.0, 0.0, 1.0]];
897
898        let vn = compute_vertex_normals::<8>(&vertices, &faces, &face_normals);
899        assert_eq!(vn.len(), 3);
900        for n in vn.iter() {
901            assert!((n[0] - 0.0).abs() < 1e-5);
902            assert!((n[1] - 0.0).abs() < 1e-5);
903            assert!((n[2] - 1.0).abs() < 1e-5);
904        }
905    }
906
907    #[test]
908    fn test_compute_vertex_normals_shared_edge() {
909        // Two triangles sharing edge (0,1), with normals pointing in +Z and +Y
910        let vertices = [
911            [0.0, 0.0, 0.0],
912            [1.0, 0.0, 0.0],
913            [0.5, 0.0, 1.0],
914            [0.5, 1.0, 0.0],
915        ];
916        let faces = [[0, 1, 2], [0, 1, 3]];
917        let face_normals = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
918
919        let vn = compute_vertex_normals::<8>(&vertices, &faces, &face_normals);
920        assert_eq!(vn.len(), 4);
921
922        // Shared vertices 0 and 1 should have averaged normals
923        let _expected_len = (0.5f32 * 0.5 + 0.5 * 0.5).sqrt(); // ~0.707
924        for i in 0..2 {
925            let n = &vn[i];
926            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
927            assert!((len - 1.0).abs() < 1e-5, "Normal should be unit length");
928            assert!(
929                (n[1] - n[2]).abs() < 1e-5,
930                "Y and Z components should be equal for shared verts"
931            );
932        }
933
934        // Vertex 2: only in face 0, should be [0,0,1]
935        assert!((vn[2][2] - 1.0).abs() < 1e-5);
936        // Vertex 3: only in face 1, should be [0,1,0]
937        assert!((vn[3][1] - 1.0).abs() < 1e-5);
938    }
939
940    #[test]
941    fn test_geometry_validation_vertex_normals_length_mismatch() {
942        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
943        let vn = [[0.0, 0.0, 1.0]]; // Only 1 normal for 2 vertices
944
945        let geometry = Geometry {
946            vertices: &vertices,
947            faces: &[],
948            colors: &[],
949            lines: &[],
950            normals: &[],
951            vertex_normals: &vn,
952            uvs: &[],
953            texture_id: None,
954        };
955
956        assert!(!geometry.check_validity());
957    }
958
959    #[cfg(feature = "lod-crossfade")]
960    #[test]
961    fn test_lod_crossfade_pick() {
962        let high = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
963        let med = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
964        let faces = [[0usize, 1, 2]];
965        let mut mesh = K3dMesh::new(Geometry {
966            vertices: &high,
967            faces: &faces,
968            colors: &[],
969            lines: &[],
970            normals: &[],
971            vertex_normals: &[],
972            uvs: &[],
973            texture_id: None,
974        });
975        mesh.set_lod(
976            Some(Geometry {
977                vertices: &med,
978                faces: &faces,
979                colors: &[],
980                lines: &[],
981                normals: &[],
982                vertex_normals: &[],
983                uvs: &[],
984                texture_id: None,
985            }),
986            None,
987            LODLevels {
988                high_distance: 10.0,
989                medium_distance: 20.0,
990                fade_margin: 2.0,
991            },
992        );
993        assert!(matches!(mesh.select_lod_pick(5.0), LodPick::Single(_)));
994        assert!(matches!(
995            mesh.select_lod_pick(10.0),
996            LodPick::Crossfade { .. }
997        ));
998    }
999
1000    #[cfg(feature = "aabb-cull")]
1001    #[test]
1002    fn test_mesh_aabb_cached_on_new() {
1003        let vertices = [[-2.0, -1.0, 0.0], [2.0, 1.0, 0.0]];
1004        let mesh = K3dMesh::new(Geometry {
1005            vertices: &vertices,
1006            faces: &[],
1007            colors: &[],
1008            lines: &[],
1009            normals: &[],
1010            vertex_normals: &[],
1011            uvs: &[],
1012            texture_id: None,
1013        });
1014        let aabb = mesh.aabb.expect("cached");
1015        assert!((aabb.half_extents.x - 2.0).abs() < 1e-5);
1016        assert!((aabb.half_extents.y - 1.0).abs() < 1e-5);
1017    }
1018
1019    #[test]
1020    fn test_compute_face_normals() {
1021        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1022        let faces = [[0usize, 1, 2]];
1023        let mut normals = [[0.0f32; 3]; 1];
1024
1025        let written = Geometry::compute_face_normals_into(&vertices, &faces, &mut normals);
1026        assert_eq!(written, 1);
1027        // Face (0,0,0) -> (1,0,0) -> (0,1,0) has outward normal pointing in +Z (0, 0, 1)
1028        assert!((normals[0][0] - 0.0).abs() < 1e-5);
1029        assert!((normals[0][1] - 0.0).abs() < 1e-5);
1030        assert!((normals[0][2] - 1.0).abs() < 1e-5);
1031    }
1032}