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