Skip to main content

embedded_3dgfx/
lib.rs

1#![no_std]
2#[cfg(feature = "std")]
3extern crate std;
4use camera::Camera;
5use embedded_graphics_core::pixelcolor::Rgb565;
6use embedded_graphics_core::pixelcolor::RgbColor;
7use mesh::K3dMesh;
8use mesh::RenderMode;
9use nalgebra::Matrix4;
10use nalgebra::Point2;
11use nalgebra::Point3;
12use nalgebra::Vector3;
13
14// ComplexField provides sqrt() for f32 in no_std via libm
15// It appears "unused" in tests because tests use std, but it's required for no_std builds
16#[allow(unused_imports)]
17use nalgebra::ComplexField;
18
19pub mod animation;
20pub mod billboard;
21pub mod bridge;
22pub mod camera;
23pub mod command_buffer;
24pub mod config;
25pub mod display_backend;
26pub mod draw;
27pub mod error;
28pub mod fixed_math;
29pub mod hardware_profile;
30pub mod hud;
31pub mod lut;
32pub mod mesh;
33#[cfg(feature = "std")]
34pub mod painters;
35#[cfg(feature = "perfcounter")]
36pub mod perfcounter;
37pub mod physics;
38pub mod renderer;
39pub mod scene_format;
40pub mod scene_stream;
41pub mod skeleton;
42pub mod softbody;
43pub mod swapchain;
44pub mod telemetry;
45pub mod texture;
46pub mod tilebin;
47pub mod transform_anim;
48pub mod tween;
49
50// Re-export framebuffer types from external crate for user convenience
51pub use embedded_graphics_framebuf::{
52    FrameBuf,
53    backends::{DMACapableFrameBufferBackend, EndianCorrectedBuffer, EndianCorrection},
54};
55
56#[cfg(feature = "aa")]
57pub use draw::ReadPixel;
58
59pub use bridge::{
60    AsEgPoint, AsNalgebraPoint, draw_to, eg_to_nalgebra, nalgebra_to_eg, render_drawable_to_buffer,
61};
62pub use renderer::{DirtyRegion, FrameCtx};
63pub use tilebin::{TileBinStats, TileConfig};
64pub use transform_anim::{AnimationPlayer, SampledTransform, TransformKeyframe, TransformTrack};
65pub use tween::{Easing, Tween, Tween3, apply_easing, lerp, lerp3, scale_rgb565};
66
67#[derive(Debug, Clone)]
68pub enum DrawPrimitive {
69    ColoredPoint(Point2<i32>, Rgb565),
70    Line([Point2<i32>; 2], Rgb565),
71    ColoredTriangle([Point2<i32>; 3], Rgb565),
72    ColoredTriangleWithDepth {
73        points: [Point2<i32>; 3],
74        depths: [f32; 3],
75        color: Rgb565,
76    },
77    GouraudTriangle {
78        points: [Point2<i32>; 3],
79        colors: [Rgb565; 3],
80    },
81    GouraudTriangleWithDepth {
82        points: [Point2<i32>; 3],
83        depths: [f32; 3],
84        colors: [Rgb565; 3],
85    },
86    TexturedTriangle {
87        points: [Point2<i32>; 3],
88        uvs: [[f32; 2]; 3],
89        texture_id: u32,
90    },
91    TexturedTriangleWithDepth {
92        points: [Point2<i32>; 3],
93        depths: [f32; 3],
94        ws: [f32; 3],
95        uvs: [[f32; 2]; 3],
96        texture_id: u32,
97    },
98}
99
100pub struct K3dengine {
101    pub camera: Camera,
102    width: u16,
103    height: u16,
104    caps: Option<crate::config::ProfileCaps>,
105    quality_tier: crate::config::QualityTier,
106    material_profile: crate::config::MaterialProfile,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct BudgetFallbackOutcome {
111    pub used_fallback: bool,
112    pub primary_budget_error: Option<crate::error::BudgetKind>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct DegradationOutcome {
117    pub used_degradation: bool,
118    pub steps_applied: usize,
119    pub dropped_meshes: usize,
120    pub final_quality_tier: crate::config::QualityTier,
121    pub primary_budget_error: Option<crate::error::BudgetKind>,
122}
123
124impl K3dengine {
125    pub fn new(width: u16, height: u16) -> K3dengine {
126        K3dengine {
127            camera: Camera::new(width as f32 / height as f32),
128            width,
129            height,
130            caps: None,
131            quality_tier: crate::config::QualityTier::Balanced,
132            material_profile: crate::config::MaterialProfile::Lambert,
133        }
134    }
135
136    pub fn set_caps(&mut self, caps: crate::config::ProfileCaps) {
137        self.caps = Some(caps);
138        self.apply_render_defaults(crate::config::render_defaults_for_profile(caps));
139    }
140
141    pub fn clear_caps(&mut self) {
142        self.caps = None;
143    }
144
145    pub fn set_quality_tier(&mut self, tier: crate::config::QualityTier) {
146        self.quality_tier = tier;
147    }
148
149    pub fn set_material_profile(&mut self, profile: crate::config::MaterialProfile) {
150        self.material_profile = profile;
151    }
152
153    pub fn apply_render_defaults(&mut self, defaults: crate::config::RenderDefaults) {
154        self.quality_tier = defaults.quality_tier;
155        self.material_profile = defaults.material_profile;
156    }
157
158    fn resolve_render_mode(&self, mode: &RenderMode) -> RenderMode {
159        use crate::config::{MaterialProfile, QualityTier};
160        match self.quality_tier {
161            QualityTier::Fastest => match mode {
162                RenderMode::BlinnPhong { .. }
163                | RenderMode::GouraudLightDir(_)
164                | RenderMode::SolidLightDir(_) => RenderMode::Solid,
165                _ => mode.clone(),
166            },
167            QualityTier::Balanced => match (self.material_profile, mode) {
168                (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
169                | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
170                | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
171                (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
172                    RenderMode::SolidLightDir(light_dir.clone())
173                }
174                _ => mode.clone(),
175            },
176            QualityTier::Quality => match (self.material_profile, mode) {
177                (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
178                | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
179                | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
180                (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
181                    RenderMode::SolidLightDir(light_dir.clone())
182                }
183                _ => mode.clone(),
184            },
185        }
186    }
187
188    /// Fast frustum culling check using bounding sphere.
189    /// Returns true if the mesh should be culled (not rendered).
190    #[inline]
191    fn should_cull_mesh(&self, mesh: &K3dMesh) -> bool {
192        // Get mesh position in world space
193        let mesh_pos = mesh.get_position();
194
195        // Compute distance from camera to mesh center
196        let to_mesh = mesh_pos - self.camera.position;
197        let distance = to_mesh.norm(); // Uses libm sqrt via nalgebra
198
199        // Get squared bounding radius and compute radius
200        // This is only called once per mesh, not in the inner loop
201        let radius_sq = mesh.compute_bounding_radius_sq();
202        let radius = radius_sq.sqrt(); // Uses libm sqrt (one call per mesh is acceptable)
203
204        // Far plane culling: mesh sphere is entirely beyond far plane
205        if distance - radius > self.camera.far {
206            return true;
207        }
208
209        // Near plane culling: mesh sphere is entirely before near plane
210        if distance + radius < self.camera.near {
211            return true;
212        }
213
214        // Passed culling tests - render the mesh
215        false
216    }
217
218    #[inline(always)]
219    fn transform_point(&self, point: &[f32; 3], model_matrix: Matrix4<f32>) -> Option<Point3<i32>> {
220        #[cfg(feature = "fixed-transform")]
221        {
222            return self.transform_point_fixed(point, model_matrix);
223        }
224        #[cfg(not(feature = "fixed-transform"))]
225        {
226            let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
227            let point = model_matrix * point;
228
229            if point.w < 0.0 {
230                return None;
231            }
232            if point.z < self.camera.near || point.z > self.camera.far {
233                return None;
234            }
235
236            let point = Point3::from_homogeneous(point)?;
237
238            let x = ((1.0 + point.x) * 0.5 * self.width as f32) as i32;
239            let y = ((1.0 - point.y) * 0.5 * self.height as f32) as i32;
240
241            if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
242                return None;
243            }
244
245            Some(Point3::new(
246                x,
247                y,
248                (point.z * (self.camera.far - self.camera.near) + self.camera.near) as i32,
249            ))
250        }
251    }
252
253    #[cfg(feature = "fixed-transform")]
254    #[inline(always)]
255    fn transform_point_fixed(
256        &self,
257        point: &[f32; 3],
258        model_matrix: Matrix4<f32>,
259    ) -> Option<Point3<i32>> {
260        use crate::fixed_math::{div_fp, from_fp, to_fp};
261
262        let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
263        let point = model_matrix * point;
264
265        if point.w <= 0.0 {
266            return None;
267        }
268
269        let x_fp = div_fp(to_fp(point.x), to_fp(point.w))?;
270        let y_fp = div_fp(to_fp(point.y), to_fp(point.w))?;
271        let z_ndc = from_fp(div_fp(to_fp(point.z), to_fp(point.w))?);
272        if z_ndc < self.camera.near || z_ndc > self.camera.far {
273            return None;
274        }
275
276        let x = ((1.0 + from_fp(x_fp)) * 0.5 * self.width as f32) as i32;
277        let y = ((1.0 - from_fp(y_fp)) * 0.5 * self.height as f32) as i32;
278
279        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
280            return None;
281        }
282
283        Some(Point3::new(
284            x,
285            y,
286            (z_ndc * (self.camera.far - self.camera.near) + self.camera.near) as i32,
287        ))
288    }
289
290    #[inline(always)]
291    pub fn transform_points<const N: usize>(
292        &self,
293        indices: &[usize; N],
294        vertices: &[[f32; 3]],
295        model_matrix: Matrix4<f32>,
296    ) -> Option<[Point3<i32>; N]> {
297        let mut ret = [Point3::new(0, 0, 0); N];
298
299        for i in 0..N {
300            ret[i] = self.transform_point(&vertices[indices[i]], model_matrix)?;
301        }
302
303        Some(ret)
304    }
305
306    /// Like `transform_point` but also returns the clip-space W for perspective-correct interpolation.
307    /// Returns (screen_point, w_clip). w_clip is the clip-space W before perspective division.
308    fn transform_point_with_w(
309        &self,
310        point: &[f32; 3],
311        model_matrix: Matrix4<f32>,
312    ) -> Option<(Point3<i32>, f32)> {
313        let v = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
314        let clip = model_matrix * v;
315        if clip.w <= 0.0 {
316            return None;
317        }
318        let ndc_x = clip.x / clip.w;
319        let ndc_y = clip.y / clip.w;
320        let ndc_z = clip.z / clip.w;
321        if ndc_z < self.camera.near || ndc_z > self.camera.far {
322            return None;
323        }
324        let x = ((1.0 + ndc_x) * 0.5 * self.width as f32) as i32;
325        let y = ((1.0 - ndc_y) * 0.5 * self.height as f32) as i32;
326        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
327            return None;
328        }
329        let z = (ndc_z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
330        Some((Point3::new(x, y, z), clip.w))
331    }
332
333    /// Like `transform_points` but also returns clip-space W values for perspective-correct UV.
334    #[inline(always)]
335    pub fn transform_points_with_w<const N: usize>(
336        &self,
337        indices: &[usize; N],
338        vertices: &[[f32; 3]],
339        model_matrix: Matrix4<f32>,
340    ) -> Option<([Point3<i32>; N], [f32; N])> {
341        let mut pts = [Point3::new(0, 0, 0); N];
342        let mut ws = [1.0f32; N];
343        for i in 0..N {
344            let (p, w) = self.transform_point_with_w(&vertices[indices[i]], model_matrix)?;
345            pts[i] = p;
346            ws[i] = w;
347        }
348        Some((pts, ws))
349    }
350
351    fn render<'a, MS, F>(&self, meshes: MS, mut callback: F)
352    where
353        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
354        F: FnMut(DrawPrimitive),
355    {
356        for mesh in meshes {
357            if mesh.geometry.vertices.is_empty() {
358                continue;
359            }
360
361            // Frustum culling: Skip meshes that are completely outside the view frustum
362            // This can improve performance by 50-90% by avoiding transformation and rendering
363            // of off-screen objects
364            if self.should_cull_mesh(mesh) {
365                continue;
366            }
367
368            // LOD Selection: Choose geometry based on distance from camera
369            let mesh_pos = mesh.get_position();
370            let distance = (mesh_pos - self.camera.position).norm();
371            let geometry = mesh.select_lod(distance);
372
373            let transform_matrix = self.camera.vp_matrix * mesh.model_matrix;
374
375            let render_mode = self.resolve_render_mode(&mesh.render_mode);
376            match render_mode {
377                RenderMode::Points => {
378                    let screen_space_points = geometry
379                        .vertices
380                        .iter()
381                        .filter_map(|v| self.transform_point(v, transform_matrix));
382
383                    if geometry.colors.len() == geometry.vertices.len() {
384                        for (point, color) in screen_space_points.zip(geometry.colors) {
385                            callback(DrawPrimitive::ColoredPoint(point.xy(), *color));
386                        }
387                    } else {
388                        for point in screen_space_points {
389                            callback(DrawPrimitive::ColoredPoint(point.xy(), mesh.color));
390                        }
391                    }
392                }
393
394                RenderMode::Lines if !geometry.lines.is_empty() => {
395                    for line in geometry.lines {
396                        if let Some([p1, p2]) =
397                            self.transform_points(line, geometry.vertices, transform_matrix)
398                        {
399                            callback(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
400                        }
401                    }
402                }
403
404                RenderMode::Lines if !geometry.faces.is_empty() => {
405                    for face in geometry.faces {
406                        if let Some([p1, p2, p3]) =
407                            self.transform_points(face, geometry.vertices, transform_matrix)
408                        {
409                            callback(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
410                            callback(DrawPrimitive::Line([p2.xy(), p3.xy()], mesh.color));
411                            callback(DrawPrimitive::Line([p3.xy(), p1.xy()], mesh.color));
412                        }
413                    }
414                }
415
416                RenderMode::Lines => {}
417
418                RenderMode::SolidLightDir(direction) => {
419                    // Pre-compute lighting constants (once per mesh, not per face)
420                    // This optimization reduces redundant calculations in the inner loop
421                    let color_as_float = Vector3::new(
422                        mesh.color.r() as f32 / 32.0,
423                        mesh.color.g() as f32 / 64.0,
424                        mesh.color.b() as f32 / 32.0,
425                    );
426
427                    // Pre-compute ambient lighting term
428                    let ambient_color = color_as_float * 0.1;
429
430                    // Pre-compute adjusted light direction
431                    // Negate only Z component of direction to fix front/back while keeping left/right
432                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
433
434                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
435                        //Backface culling
436                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
437
438                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
439
440                        // Backface culling: cull faces pointing away from camera
441                        // This improves performance by ~50% (don't render back faces)
442                        // Z-buffer handles depth ordering, but culling avoids wasted work
443                        if self.camera.get_direction().dot(&transformed_normal) < 0.0 {
444                            continue;
445                        }
446
447                        if let Some([p1, p2, p3]) =
448                            self.transform_points(face, geometry.vertices, transform_matrix)
449                        {
450                            // Calculate lighting intensity
451                            let intensity = transformed_normal.dot(&adjusted_dir).max(0.0);
452
453                            // Compute final color using pre-computed constants
454                            let final_color = color_as_float * intensity + ambient_color;
455
456                            let final_color = Vector3::new(
457                                final_color.x.clamp(0.0, 1.0),
458                                final_color.y.clamp(0.0, 1.0),
459                                final_color.z.clamp(0.0, 1.0),
460                            );
461
462                            let color = Rgb565::new(
463                                (final_color.x * 31.0) as u8,
464                                (final_color.y * 63.0) as u8,
465                                (final_color.z * 31.0) as u8,
466                            );
467                            callback(DrawPrimitive::ColoredTriangleWithDepth {
468                                points: [p1.xy(), p2.xy(), p3.xy()],
469                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
470                                color,
471                            });
472                        }
473                    }
474                }
475
476                RenderMode::GouraudLightDir(direction) => {
477                    let color_as_float = Vector3::new(
478                        mesh.color.r() as f32 / 32.0,
479                        mesh.color.g() as f32 / 64.0,
480                        mesh.color.b() as f32 / 32.0,
481                    );
482                    let ambient_color = color_as_float * 0.1;
483                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
484
485                    for (face, face_normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
486                        let fn_vec = Vector3::new(face_normal[0], face_normal[1], face_normal[2]);
487                        let transformed_fn = mesh.model_matrix.transform_vector(&fn_vec);
488
489                        if self.camera.get_direction().dot(&transformed_fn) < 0.0 {
490                            continue;
491                        }
492
493                        if let Some([p1, p2, p3]) =
494                            self.transform_points(face, geometry.vertices, transform_matrix)
495                        {
496                            // Compute per-vertex colors
497                            let vertex_colors: [Rgb565; 3] = core::array::from_fn(|k| {
498                                let vn = if !geometry.vertex_normals.is_empty() {
499                                    let vn_arr = geometry.vertex_normals[face[k]];
500                                    let vn_vec = Vector3::new(vn_arr[0], vn_arr[1], vn_arr[2]);
501                                    mesh.model_matrix.transform_vector(&vn_vec)
502                                } else {
503                                    transformed_fn
504                                };
505
506                                let intensity = vn.dot(&adjusted_dir).max(0.0);
507                                let c = color_as_float * intensity + ambient_color;
508                                Rgb565::new(
509                                    (c.x.clamp(0.0, 1.0) * 31.0) as u8,
510                                    (c.y.clamp(0.0, 1.0) * 63.0) as u8,
511                                    (c.z.clamp(0.0, 1.0) * 31.0) as u8,
512                                )
513                            });
514
515                            callback(DrawPrimitive::GouraudTriangleWithDepth {
516                                points: [p1.xy(), p2.xy(), p3.xy()],
517                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
518                                colors: vertex_colors,
519                            });
520                        }
521                    }
522                }
523
524                RenderMode::BlinnPhong {
525                    light_dir,
526                    specular_intensity,
527                    shininess,
528                } => {
529                    // Pre-compute lighting constants (once per mesh, not per face)
530                    let color_as_float = Vector3::new(
531                        mesh.color.r() as f32 / 32.0,
532                        mesh.color.g() as f32 / 64.0,
533                        mesh.color.b() as f32 / 32.0,
534                    );
535
536                    // Pre-compute ambient lighting term
537                    let ambient_color = color_as_float * 0.1;
538
539                    // Pre-compute adjusted light direction
540                    // Negate only Z component of direction to fix front/back while keeping left/right
541                    let adjusted_light_dir = Vector3::new(light_dir.x, light_dir.y, -light_dir.z);
542
543                    // Normalize light direction
544                    let light_dir_normalized = adjusted_light_dir.normalize();
545
546                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
547                        //Backface culling
548                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
549                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
550                        let normalized_normal = transformed_normal.normalize();
551
552                        // Backface culling: cull faces pointing away from camera
553                        if self.camera.get_direction().dot(&normalized_normal) < 0.0 {
554                            continue;
555                        }
556
557                        if let Some([p1, p2, p3]) =
558                            self.transform_points(face, geometry.vertices, transform_matrix)
559                        {
560                            // Calculate face center in world space for view direction
561                            let v0 = geometry.vertices[face[0]];
562                            let v1 = geometry.vertices[face[1]];
563                            let v2 = geometry.vertices[face[2]];
564                            let face_center = Point3::new(
565                                (v0[0] + v1[0] + v2[0]) / 3.0,
566                                (v0[1] + v1[1] + v2[1]) / 3.0,
567                                (v0[2] + v1[2] + v2[2]) / 3.0,
568                            );
569                            let face_center_world = mesh.model_matrix.transform_point(&face_center);
570
571                            // View direction: from face to camera
572                            let view_dir = (self.camera.position - face_center_world).normalize();
573
574                            // Blinn-Phong half vector: H = normalize(L + V)
575                            let half_vector = (light_dir_normalized + view_dir).normalize();
576
577                            // Diffuse term: N·L
578                            let diffuse_intensity =
579                                normalized_normal.dot(&light_dir_normalized).max(0.0);
580
581                            // Specular term: (N·H)^shininess
582                            let specular_term =
583                                normalized_normal.dot(&half_vector).max(0.0).powf(shininess);
584
585                            // Compute final color: ambient + diffuse + specular
586                            let diffuse_color = color_as_float * diffuse_intensity;
587                            let specular_color =
588                                Vector3::new(1.0, 1.0, 1.0) * specular_term * specular_intensity;
589                            let final_color = ambient_color + diffuse_color + specular_color;
590
591                            let final_color = Vector3::new(
592                                final_color.x.clamp(0.0, 1.0),
593                                final_color.y.clamp(0.0, 1.0),
594                                final_color.z.clamp(0.0, 1.0),
595                            );
596
597                            let color = Rgb565::new(
598                                (final_color.x * 31.0) as u8,
599                                (final_color.y * 63.0) as u8,
600                                (final_color.z * 31.0) as u8,
601                            );
602                            callback(DrawPrimitive::ColoredTriangleWithDepth {
603                                points: [p1.xy(), p2.xy(), p3.xy()],
604                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
605                                color,
606                            });
607                        }
608                    }
609                }
610
611                RenderMode::Solid => {
612                    if geometry.normals.is_empty() {
613                        for face in geometry.faces.iter() {
614                            if let Some([p1, p2, p3]) =
615                                self.transform_points(face, geometry.vertices, transform_matrix)
616                            {
617                                callback(DrawPrimitive::ColoredTriangleWithDepth {
618                                    points: [p1.xy(), p2.xy(), p3.xy()],
619                                    depths: [p1.z as f32, p2.z as f32, p3.z as f32],
620                                    color: mesh.color,
621                                });
622                            }
623                        }
624                    } else {
625                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
626                            //Backface culling
627                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
628
629                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
630
631                            // Backface culling: cull faces pointing away from camera
632                            if self.camera.get_direction().dot(&transformed_normal) < 0.0 {
633                                continue;
634                            }
635
636                            if let Some([p1, p2, p3]) =
637                                self.transform_points(face, geometry.vertices, transform_matrix)
638                            {
639                                callback(DrawPrimitive::ColoredTriangleWithDepth {
640                                    points: [p1.xy(), p2.xy(), p3.xy()],
641                                    depths: [p1.z as f32, p2.z as f32, p3.z as f32],
642                                    color: mesh.color,
643                                });
644                            }
645                        }
646                    }
647                }
648
649                RenderMode::SectorBright(brightness) => {
650                    // Scale mesh color by brightness factor (Doom-style sector lighting)
651                    let factor = brightness as f32 / 255.0;
652                    let scaled_r = (mesh.color.r() as f32 * factor) as u8;
653                    let scaled_g = (mesh.color.g() as f32 * factor) as u8;
654                    let scaled_b = (mesh.color.b() as f32 * factor) as u8;
655                    let scaled_color = Rgb565::new(scaled_r, scaled_g, scaled_b);
656
657                    if geometry.normals.is_empty() {
658                        for face in geometry.faces.iter() {
659                            if let Some([p1, p2, p3]) =
660                                self.transform_points(face, geometry.vertices, transform_matrix)
661                            {
662                                callback(DrawPrimitive::ColoredTriangleWithDepth {
663                                    points: [p1.xy(), p2.xy(), p3.xy()],
664                                    depths: [p1.z as f32, p2.z as f32, p3.z as f32],
665                                    color: scaled_color,
666                                });
667                            }
668                        }
669                    } else {
670                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
671                            // Backface culling
672                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
673                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
674
675                            if self.camera.get_direction().dot(&transformed_normal) < 0.0 {
676                                continue;
677                            }
678
679                            if let Some([p1, p2, p3]) =
680                                self.transform_points(face, geometry.vertices, transform_matrix)
681                            {
682                                callback(DrawPrimitive::ColoredTriangleWithDepth {
683                                    points: [p1.xy(), p2.xy(), p3.xy()],
684                                    depths: [p1.z as f32, p2.z as f32, p3.z as f32],
685                                    color: scaled_color,
686                                });
687                            }
688                        }
689                    }
690                }
691            }
692        }
693    }
694
695    pub fn record<'a, MS, const MAX: usize>(
696        &self,
697        meshes: MS,
698        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
699        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
700    ) -> Result<(), crate::error::RenderError>
701    where
702        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
703    {
704        self.record_impl(meshes, commands, telemetry)
705    }
706
707    fn record_impl<'a, MS, const MAX: usize>(
708        &self,
709        meshes: MS,
710        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
711        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
712    ) -> Result<(), crate::error::RenderError>
713    where
714        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
715    {
716        use crate::command_buffer::RenderCommand;
717        use crate::error::{BudgetKind, RenderError};
718
719        commands.clear();
720        commands.push(RenderCommand::ClearDepth(u32::MAX))?;
721        if let Some(caps) = self.caps {
722            caps.validate_framebuffer(self.width as usize, self.height as usize)?;
723        }
724
725        let mut first_error = None;
726        let mut visible_meshes = 0usize;
727        let mut used_texture_ids: heapless::Vec<u32, 64> = heapless::Vec::new();
728        let mut meshes_total = 0usize;
729
730        for mesh in meshes {
731            meshes_total += 1;
732            if mesh.geometry.vertices.is_empty() {
733                continue;
734            }
735            if self.should_cull_mesh(mesh) {
736                continue;
737            }
738
739            let distance = (mesh.get_position() - self.camera.position).norm();
740            let geometry = mesh.select_lod(distance);
741
742            if let Some(caps) = self.caps {
743                visible_meshes += 1;
744                if visible_meshes > caps.max_meshes_per_frame {
745                    return Err(RenderError::OutOfBudget(BudgetKind::MeshesPerFrame {
746                        attempted: visible_meshes,
747                        max: caps.max_meshes_per_frame,
748                    }));
749                }
750
751                if geometry.vertices.len() > caps.max_vertices_per_mesh {
752                    return Err(RenderError::OutOfBudget(BudgetKind::VerticesPerMesh {
753                        attempted: geometry.vertices.len(),
754                        max: caps.max_vertices_per_mesh,
755                    }));
756                }
757
758                if geometry.faces.len() > caps.max_triangles_per_mesh {
759                    return Err(RenderError::OutOfBudget(BudgetKind::TrianglesPerMesh {
760                        attempted: geometry.faces.len(),
761                        max: caps.max_triangles_per_mesh,
762                    }));
763                }
764
765                if let Some(texture_id) = geometry.texture_id
766                    && !used_texture_ids.iter().any(|id| *id == texture_id)
767                {
768                    let attempted = used_texture_ids.len() + 1;
769                    if attempted > caps.max_textures {
770                        return Err(RenderError::OutOfBudget(BudgetKind::Textures {
771                            attempted,
772                            max: caps.max_textures,
773                        }));
774                    }
775
776                    if used_texture_ids.push(texture_id).is_err() {
777                        return Err(RenderError::OutOfBudget(BudgetKind::Textures {
778                            attempted,
779                            max: caps.max_textures,
780                        }));
781                    }
782                }
783            }
784
785            self.render(core::iter::once(mesh), |primitive| {
786                if first_error.is_none()
787                    && let Err(e) = commands.push(RenderCommand::Draw(primitive))
788                {
789                    first_error = Some(e);
790                }
791            });
792            if let Some(err) = first_error {
793                return Err(err);
794            }
795        }
796
797        if let Some(t) = telemetry {
798            t.meshes_total = meshes_total;
799            t.meshes_visible = visible_meshes;
800            t.unique_textures = used_texture_ids.len();
801            t.draw_commands = commands
802                .iter()
803                .filter(|cmd| matches!(cmd, RenderCommand::Draw(_)))
804                .count();
805            t.fallback_used = false;
806            t.degradation_steps_applied = 0;
807            t.dropped_meshes = 0;
808        }
809
810        Ok(())
811    }
812
813    pub fn record_with_fallback<'a, MS, FS, const MAX: usize>(
814        &self,
815        primary: MS,
816        fallback: FS,
817        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
818        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
819    ) -> Result<BudgetFallbackOutcome, crate::error::RenderError>
820    where
821        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
822        FS: IntoIterator<Item = &'a K3dMesh<'a>>,
823    {
824        use crate::error::RenderError;
825
826        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
827        match self.record_impl(primary, commands, Some(&mut local_telemetry)) {
828            Ok(()) => {
829                if let Some(t) = telemetry {
830                    *t = local_telemetry;
831                    t.fallback_used = false;
832                }
833                Ok(BudgetFallbackOutcome {
834                    used_fallback: false,
835                    primary_budget_error: None,
836                })
837            }
838            Err(RenderError::OutOfBudget(kind)) => {
839                let mut fallback_telemetry = crate::telemetry::RecordTelemetry::default();
840                self.record_impl(fallback, commands, Some(&mut fallback_telemetry))?;
841                if let Some(t) = telemetry {
842                    *t = fallback_telemetry;
843                    t.fallback_used = true;
844                }
845                Ok(BudgetFallbackOutcome {
846                    used_fallback: true,
847                    primary_budget_error: Some(kind),
848                })
849            }
850            Err(e) => Err(e),
851        }
852    }
853
854    fn downgraded_quality_tier(tier: crate::config::QualityTier) -> crate::config::QualityTier {
855        use crate::config::QualityTier;
856        match tier {
857            QualityTier::Quality => QualityTier::Balanced,
858            QualityTier::Balanced => QualityTier::Fastest,
859            QualityTier::Fastest => QualityTier::Fastest,
860        }
861    }
862
863    pub fn record_with_degradation<'a, const MAX: usize>(
864        &mut self,
865        meshes: &[&'a K3dMesh<'a>],
866        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
867        policy: crate::config::DegradationPolicy<'_>,
868        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
869    ) -> Result<DegradationOutcome, crate::error::RenderError> {
870        use crate::config::DegradationStep;
871        use crate::error::RenderError;
872
873        let original_quality = self.quality_tier;
874        let mut active_quality = self.quality_tier;
875
876        let mut outcome = DegradationOutcome {
877            used_degradation: false,
878            steps_applied: 0,
879            dropped_meshes: 0,
880            final_quality_tier: active_quality,
881            primary_budget_error: None,
882        };
883
884        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
885        match self.record_impl(meshes.iter().copied(), commands, Some(&mut local_telemetry)) {
886            Ok(()) => {
887                if let Some(t) = telemetry {
888                    *t = local_telemetry;
889                }
890                return Ok(outcome);
891            }
892            Err(RenderError::OutOfBudget(kind)) => {
893                outcome.primary_budget_error = Some(kind);
894            }
895            Err(e) => return Err(e),
896        }
897
898        for step in policy.steps {
899            outcome.used_degradation = true;
900            outcome.steps_applied += 1;
901
902            let mut selected: heapless::Vec<&K3dMesh<'_>, 512> = heapless::Vec::new();
903            match *step {
904                DegradationStep::RaisePriorityFloor(min_priority) => {
905                    for mesh in meshes {
906                        if mesh.priority >= min_priority {
907                            let _ = selected.push(*mesh);
908                        } else {
909                            outcome.dropped_meshes += 1;
910                        }
911                    }
912                }
913                DegradationStep::MeshDecimationStride(stride) => {
914                    if stride == 0 {
915                        self.quality_tier = original_quality;
916                        return Err(RenderError::InvalidInput(
917                            "mesh decimation stride must be >= 1",
918                        ));
919                    }
920                    for (idx, mesh) in meshes.iter().enumerate() {
921                        if idx % stride == 0 {
922                            let _ = selected.push(*mesh);
923                        } else {
924                            outcome.dropped_meshes += 1;
925                        }
926                    }
927                }
928                DegradationStep::DowngradeQuality => {
929                    active_quality = Self::downgraded_quality_tier(active_quality);
930                    self.quality_tier = active_quality;
931                    for mesh in meshes {
932                        let _ = selected.push(*mesh);
933                    }
934                }
935            }
936
937            if selected.is_empty() {
938                continue;
939            }
940
941            let mut step_telemetry = crate::telemetry::RecordTelemetry::default();
942            let attempt = self.record_impl(
943                selected.iter().copied(),
944                commands,
945                Some(&mut step_telemetry),
946            );
947
948            if let Ok(()) = attempt {
949                outcome.final_quality_tier = self.quality_tier;
950                if let Some(t) = telemetry {
951                    *t = step_telemetry;
952                    t.fallback_used = true;
953                    t.degradation_steps_applied = outcome.steps_applied;
954                    t.dropped_meshes = outcome.dropped_meshes;
955                }
956                self.quality_tier = original_quality;
957                return Ok(outcome);
958            }
959        }
960
961        self.quality_tier = original_quality;
962        Err(crate::error::RenderError::Recoverable {
963            fault: crate::error::RuntimeFaultKind::Budget(outcome.primary_budget_error.unwrap_or(
964                crate::error::BudgetKind::DrawPrimitives {
965                    attempted: commands.len(),
966                    max: MAX,
967                },
968            )),
969            action: crate::error::RecoveryAction::SkipFrame,
970        })
971    }
972
973    pub fn execute<D, const MAX: usize>(
974        &self,
975        fb: &mut D,
976        frame: &mut crate::renderer::FrameCtx<'_>,
977        commands: &crate::command_buffer::CommandBuffer<MAX>,
978        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
979    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
980    where
981        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
982            + embedded_graphics_core::prelude::OriginDimensions,
983        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
984    {
985        if let Some(t) = telemetry {
986            t.commands_total = commands.len();
987            t.draw_commands = commands
988                .iter()
989                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
990                .count();
991            t.clear_color_commands = commands
992                .iter()
993                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
994                .count();
995            t.clear_depth_commands = commands
996                .iter()
997                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
998                .count();
999        }
1000        crate::renderer::execute_commands_with_dirty_region(fb, frame, commands)
1001    }
1002
1003    pub fn execute_tiled<D, const MAX: usize, const BIN_CAP: usize>(
1004        &self,
1005        fb: &mut D,
1006        frame: &mut crate::renderer::FrameCtx<'_>,
1007        commands: &crate::command_buffer::CommandBuffer<MAX>,
1008        tile: crate::tilebin::TileConfig,
1009    ) -> Result<crate::tilebin::TileBinStats, crate::error::RenderError>
1010    where
1011        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
1012            + embedded_graphics_core::prelude::OriginDimensions,
1013        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
1014    {
1015        crate::renderer::execute_commands_tiled::<D, MAX, BIN_CAP>(fb, frame, commands, tile)
1016    }
1017}
1018
1019/// Result of a ray cast against triangle geometry.
1020#[derive(Debug, Clone, Copy)]
1021pub struct MeshRayCastHit {
1022    /// Distance along the ray to the hit point
1023    pub distance: f32,
1024    /// Hit point in world space
1025    pub point: Vector3<f32>,
1026    /// Face normal (from cross product of edges, not per-vertex normals)
1027    pub normal: Vector3<f32>,
1028    /// Index of the triangle face that was hit
1029    pub face_index: usize,
1030    /// Barycentric-interpolated UV at the hit point (or [0.0, 0.0] if no UVs present)
1031    pub uv: [f32; 2],
1032}
1033
1034/// Ray-cast against triangle geometry using Möller–Trumbore intersection.
1035///
1036/// `ray_origin` and `ray_dir` are in world space. `model_matrix` transforms mesh
1037/// vertices to world space. Returns the closest hit within `max_distance`, or `None`.
1038pub fn mesh_ray_cast(
1039    ray_origin: Vector3<f32>,
1040    ray_dir: Vector3<f32>,
1041    geometry: &mesh::Geometry<'_>,
1042    model_matrix: &Matrix4<f32>,
1043    max_distance: f32,
1044) -> Option<MeshRayCastHit> {
1045    let mut nearest: Option<MeshRayCastHit> = None;
1046    let mut min_dist = max_distance;
1047
1048    for (face_index, face) in geometry.faces.iter().enumerate() {
1049        let raw_v0 = geometry.vertices[face[0]];
1050        let raw_v1 = geometry.vertices[face[1]];
1051        let raw_v2 = geometry.vertices[face[2]];
1052
1053        // Transform vertices to world space
1054        let v0 = model_matrix
1055            .transform_point(&Point3::new(raw_v0[0], raw_v0[1], raw_v0[2]))
1056            .coords;
1057        let v1 = model_matrix
1058            .transform_point(&Point3::new(raw_v1[0], raw_v1[1], raw_v1[2]))
1059            .coords;
1060        let v2 = model_matrix
1061            .transform_point(&Point3::new(raw_v2[0], raw_v2[1], raw_v2[2]))
1062            .coords;
1063
1064        // Möller–Trumbore
1065        let edge1 = v1 - v0;
1066        let edge2 = v2 - v0;
1067        let h = ray_dir.cross(&edge2);
1068        let det = edge1.dot(&h);
1069
1070        // Parallel ray: skip
1071        if det.abs() < 1e-6 {
1072            continue;
1073        }
1074
1075        let inv_det = 1.0 / det;
1076        let s = ray_origin - v0;
1077        let bary_u = inv_det * s.dot(&h);
1078        if bary_u < 0.0 || bary_u > 1.0 {
1079            continue;
1080        }
1081
1082        let q = s.cross(&edge1);
1083        let bary_v = inv_det * ray_dir.dot(&q);
1084        if bary_v < 0.0 || bary_u + bary_v > 1.0 {
1085            continue;
1086        }
1087
1088        let t = inv_det * edge2.dot(&q);
1089        if t <= 0.0 || t >= min_dist {
1090            continue;
1091        }
1092
1093        // Face normal from edge cross product
1094        let normal = edge1.cross(&edge2).normalize();
1095
1096        // Barycentric weights: w0 = 1 - u - v, w1 = u, w2 = v
1097        let bary_w = 1.0 - bary_u - bary_v;
1098
1099        // Interpolate UV if available
1100        let uv = if geometry.uvs.len() > face[0]
1101            && geometry.uvs.len() > face[1]
1102            && geometry.uvs.len() > face[2]
1103        {
1104            let uv0 = geometry.uvs[face[0]];
1105            let uv1 = geometry.uvs[face[1]];
1106            let uv2 = geometry.uvs[face[2]];
1107            [
1108                bary_w * uv0[0] + bary_u * uv1[0] + bary_v * uv2[0],
1109                bary_w * uv0[1] + bary_u * uv1[1] + bary_v * uv2[1],
1110            ]
1111        } else {
1112            [0.0, 0.0]
1113        };
1114
1115        let point = ray_origin + ray_dir * t;
1116        min_dist = t;
1117        nearest = Some(MeshRayCastHit {
1118            distance: t,
1119            point,
1120            normal,
1121            face_index,
1122            uv,
1123        });
1124    }
1125
1126    nearest
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    extern crate std;
1132    use super::*;
1133
1134    #[test]
1135    fn test_engine_creation() {
1136        let engine = K3dengine::new(640, 480);
1137        assert_eq!(engine.width, 640);
1138        assert_eq!(engine.height, 480);
1139        assert!((engine.camera.get_aspect_ratio() - 640.0 / 480.0).abs() < 0.001);
1140    }
1141
1142    #[test]
1143    fn test_transform_point_basic() {
1144        let engine = K3dengine::new(640, 480);
1145        // Use camera's VP matrix directly
1146        let transform_matrix = engine.camera.vp_matrix;
1147
1148        // Point in front of default camera, within view frustum
1149        // Default camera is at origin looking at origin, so we need a point in front
1150        let point = [0.0, 0.0, -5.0];
1151        let result = engine.transform_point(&point, transform_matrix);
1152
1153        if let Some(transformed) = result {
1154            // Should be within screen bounds
1155            assert!(transformed.x >= 0 && transformed.x < 640);
1156            assert!(transformed.y >= 0 && transformed.y < 480);
1157        }
1158        // If None, the point was culled which is also valid behavior
1159    }
1160
1161    #[test]
1162    fn test_transform_point_clamps_out_of_bounds() {
1163        let engine = K3dengine::new(640, 480);
1164        let model_matrix = nalgebra::Matrix4::identity();
1165
1166        // Point way outside the viewport should be clamped/rejected
1167        let point = [100.0, 100.0, -5.0];
1168        let result = engine.transform_point(&point, model_matrix);
1169        // Should return None because coordinates are clamped out
1170        assert!(result.is_none());
1171    }
1172
1173    #[test]
1174    fn test_transform_point_behind_camera() {
1175        let engine = K3dengine::new(640, 480);
1176        let transform_matrix = engine.camera.vp_matrix;
1177
1178        // Point with positive z (behind default camera orientation)
1179        let point = [0.0, 0.0, 1.0];
1180        let _result = engine.transform_point(&point, transform_matrix);
1181        // Point behind camera or outside frustum should return None
1182        // (actual behavior depends on camera setup and projection)
1183        // This test just verifies the function doesn't panic
1184    }
1185
1186    #[test]
1187    fn test_transform_point_near_plane_clipping() {
1188        let engine = K3dengine::new(640, 480);
1189        let model_matrix = nalgebra::Matrix4::identity();
1190
1191        // Point too close to camera (before near plane)
1192        let point = [0.0, 0.0, -0.01];
1193        let result = engine.transform_point(&point, model_matrix);
1194        assert!(result.is_none());
1195    }
1196
1197    #[test]
1198    fn test_transform_point_far_plane_clipping() {
1199        let engine = K3dengine::new(640, 480);
1200        let model_matrix = nalgebra::Matrix4::identity();
1201
1202        // Point too far from camera (beyond far plane)
1203        let point = [0.0, 0.0, -1000.0];
1204        let result = engine.transform_point(&point, model_matrix);
1205        assert!(result.is_none());
1206    }
1207
1208    #[test]
1209    fn test_transform_points_array() {
1210        let engine = K3dengine::new(640, 480);
1211        let transform_matrix = engine.camera.vp_matrix;
1212
1213        let vertices = [[0.0, 0.0, -5.0], [0.1, 0.0, -5.0], [0.0, 0.1, -5.0]];
1214        let indices = [0, 1, 2];
1215
1216        let result = engine.transform_points(&indices, &vertices, transform_matrix);
1217
1218        // If transform succeeds, verify we get 3 points
1219        if let Some(points) = result {
1220            assert_eq!(points.len(), 3);
1221        }
1222        // If None, one or more points were culled which is valid
1223    }
1224
1225    #[test]
1226    fn test_render_empty_faces_mesh() {
1227        let engine = K3dengine::new(640, 480);
1228        let vertices = [[0.0, 0.0, -5.0]]; // At least one vertex required
1229        let geometry = mesh::Geometry {
1230            vertices: &vertices,
1231            faces: &[],
1232            colors: &[],
1233            lines: &[],
1234            normals: &[],
1235            vertex_normals: &[],
1236            uvs: &[],
1237            texture_id: None,
1238        };
1239        let mesh = mesh::K3dMesh::new(geometry);
1240
1241        let mut callback_count = 0;
1242        engine.render(std::iter::once(&mesh), |_| {
1243            callback_count += 1;
1244        });
1245
1246        // Mesh with no faces/lines should trigger one point callback (default is Points mode)
1247        assert!(callback_count > 0);
1248    }
1249
1250    #[test]
1251    fn test_render_points_mode() {
1252        let engine = K3dengine::new(640, 480);
1253
1254        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0]];
1255
1256        let geometry = mesh::Geometry {
1257            vertices: &vertices,
1258            faces: &[],
1259            colors: &[],
1260            lines: &[],
1261            normals: &[],
1262            vertex_normals: &[],
1263            uvs: &[],
1264            texture_id: None,
1265        };
1266
1267        let mut mesh = mesh::K3dMesh::new(geometry);
1268        mesh.set_render_mode(mesh::RenderMode::Points);
1269
1270        let mut primitives = std::vec::Vec::new();
1271        engine.render(std::iter::once(&mesh), |prim| {
1272            primitives.push(prim);
1273        });
1274
1275        // Should render points
1276        assert!(primitives.len() > 0);
1277        for prim in primitives {
1278            assert!(matches!(prim, DrawPrimitive::ColoredPoint(_, _)));
1279        }
1280    }
1281
1282    #[test]
1283    fn test_render_lines_mode_with_faces() {
1284        let engine = K3dengine::new(640, 480);
1285
1286        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0], [0.0, 0.5, -5.0]];
1287
1288        let faces = [[0, 1, 2]];
1289
1290        let geometry = mesh::Geometry {
1291            vertices: &vertices,
1292            faces: &faces,
1293            colors: &[],
1294            lines: &[],
1295            normals: &[],
1296            vertex_normals: &[],
1297            uvs: &[],
1298            texture_id: None,
1299        };
1300
1301        let mut mesh = mesh::K3dMesh::new(geometry);
1302        mesh.set_render_mode(mesh::RenderMode::Lines);
1303
1304        let mut primitives = std::vec::Vec::new();
1305        engine.render(std::iter::once(&mesh), |prim| {
1306            primitives.push(prim);
1307        });
1308
1309        // Should render 3 lines (edges of triangle)
1310        assert_eq!(primitives.len(), 3);
1311        for prim in primitives {
1312            assert!(matches!(prim, DrawPrimitive::Line(_, _)));
1313        }
1314    }
1315
1316    #[test]
1317    fn test_render_gouraud_light_dir() {
1318        let mut engine = K3dengine::new(640, 480);
1319        engine.camera.set_position(Point3::new(0.0, 0.0, -10.0));
1320        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
1321
1322        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1323        let faces = [[0, 1, 2]];
1324        let normals = [[0.0, 0.0, -1.0]]; // face normal pointing toward camera
1325        let vertex_normals = [[0.0, 0.0, -1.0], [0.0, 0.0, -1.0], [0.0, 0.0, -1.0]];
1326
1327        let geometry = mesh::Geometry {
1328            vertices: &vertices,
1329            faces: &faces,
1330            colors: &[],
1331            lines: &[],
1332            normals: &normals,
1333            vertex_normals: &vertex_normals,
1334            uvs: &[],
1335            texture_id: None,
1336        };
1337
1338        let mut mesh = mesh::K3dMesh::new(geometry);
1339        mesh.set_render_mode(mesh::RenderMode::GouraudLightDir(Vector3::new(
1340            0.0, 0.0, 1.0,
1341        )));
1342
1343        let mut primitives = std::vec::Vec::new();
1344        engine.render(std::iter::once(&mesh), |prim| {
1345            primitives.push(prim);
1346        });
1347
1348        // Should emit GouraudTriangleWithDepth primitives
1349        assert!(!primitives.is_empty());
1350        for prim in &primitives {
1351            assert!(matches!(
1352                prim,
1353                DrawPrimitive::GouraudTriangleWithDepth { .. }
1354            ));
1355        }
1356    }
1357}