Skip to main content

embedded_3dgfx/
lib.rs

1#![no_std]
2#![allow(
3    clippy::too_many_arguments,
4    clippy::type_complexity,
5    clippy::needless_range_loop,
6    clippy::result_unit_err,
7    clippy::manual_div_ceil,
8    clippy::manual_is_multiple_of,
9    clippy::explicit_counter_loop,
10    clippy::needless_return,
11    clippy::useless_conversion,
12    clippy::manual_checked_ops,
13    clippy::collapsible_if,
14    clippy::new_without_default,
15    clippy::doc_overindented_list_items,
16    clippy::unnecessary_cast
17)]
18#[cfg(feature = "std")]
19extern crate std;
20
21#[cfg(feature = "depth-u16")]
22pub type ZDepth = u16;
23#[cfg(feature = "depth-u16")]
24pub const Z_MAX_VALUE: ZDepth = u16::MAX;
25#[cfg(feature = "depth-u16")]
26pub const DEPTH_EPSILON: ZDepth = 1;
27
28#[cfg(not(feature = "depth-u16"))]
29pub type ZDepth = u32;
30#[cfg(not(feature = "depth-u16"))]
31pub const Z_MAX_VALUE: ZDepth = u32::MAX;
32#[cfg(not(feature = "depth-u16"))]
33pub const DEPTH_EPSILON: ZDepth = 128;
34
35#[inline(always)]
36pub const fn to_zdepth(z: u32) -> ZDepth {
37    #[cfg(feature = "depth-u16")]
38    {
39        (z >> 16) as u16
40    }
41    #[cfg(not(feature = "depth-u16"))]
42    {
43        z
44    }
45}
46
47#[cfg(feature = "dma2d")]
48unsafe extern "Rust" {
49    #[cfg(feature = "depth-u16")]
50    fn dma2d_clear_zbuffer_u16(ptr: *mut u16, len: usize, value: u16);
51    #[cfg(not(feature = "depth-u16"))]
52    fn dma2d_clear_zbuffer_u32(ptr: *mut u32, len: usize, value: u32);
53}
54
55#[inline(always)]
56pub fn clear_zbuffer(zbuffer: &mut [ZDepth], value: ZDepth) {
57    #[cfg(feature = "dma2d")]
58    {
59        #[cfg(feature = "depth-u16")]
60        unsafe {
61            dma2d_clear_zbuffer_u16(zbuffer.as_mut_ptr(), zbuffer.len(), value);
62        }
63        #[cfg(not(feature = "depth-u16"))]
64        unsafe {
65            dma2d_clear_zbuffer_u32(zbuffer.as_mut_ptr(), zbuffer.len(), value);
66        }
67    }
68    #[cfg(not(feature = "dma2d"))]
69    {
70        zbuffer.fill(value);
71    }
72}
73
74#[allow(unused_imports)]
75use crate::mesh::K3dMesh;
76#[allow(unused_imports)]
77use nalgebra::Vector4;
78use nalgebra::{Matrix4, Point3, Vector3};
79
80// ComplexField provides sqrt() for f32 in no_std via libm
81// It appears "unused" in tests because tests use std, but it's required for no_std builds
82#[allow(unused_imports)]
83use nalgebra::ComplexField;
84
85pub mod animation;
86#[cfg(feature = "scene")]
87pub mod billboard;
88pub mod engine;
89pub mod primitive;
90
91/// Result of a ray cast against triangle geometry.
92#[derive(Debug, Clone, Copy)]
93pub struct MeshRayCastHit {
94    /// Distance along the ray to the hit point
95    pub distance: f32,
96    /// Hit point in world space
97    pub point: Vector3<f32>,
98    /// Face normal (from cross product of edges, not per-vertex normals)
99    pub normal: Vector3<f32>,
100    /// Index of the triangle face that was hit
101    pub face_index: usize,
102    /// Barycentric-interpolated UV at the hit point (or [0.0, 0.0] if no UVs present)
103    pub uv: [f32; 2],
104}
105
106#[cfg(feature = "aabb-cull")]
107pub mod bounds;
108pub mod bridge;
109#[cfg(feature = "raycast")]
110pub mod bsp;
111pub mod camera;
112pub mod camera_controller;
113#[cfg(feature = "scene")]
114pub mod character;
115pub mod color;
116pub mod command_buffer;
117pub mod completion;
118pub mod config;
119pub mod display_backend;
120pub mod dither;
121pub mod draw;
122#[cfg(feature = "embassy")]
123pub mod embassy;
124pub mod error;
125#[cfg(feature = "gizmos")]
126pub mod gizmos;
127pub mod hardware_profile;
128#[cfg(feature = "hud")]
129pub mod hud;
130pub mod input;
131pub mod lens_flare;
132#[cfg(feature = "lighting")]
133pub mod lights;
134pub mod lod;
135pub mod matcap;
136pub mod mesh;
137#[cfg(feature = "painters")]
138pub mod painters;
139#[cfg(feature = "scene")]
140pub mod particles;
141#[cfg(feature = "perfcounter")]
142pub mod perfcounter;
143#[cfg(feature = "physics")]
144pub mod physics;
145pub mod raster;
146pub mod ray_primitive;
147pub mod raycast;
148#[cfg(feature = "render-layers")]
149pub mod render_layers;
150pub mod renderer;
151#[cfg(feature = "gizmos")]
152pub mod simplex_stroke_font;
153// retro types (palette/tint/stipple) are used by the always-on draw path;
154// the heavier `painters` helpers stay opt-in.
155pub mod retro;
156pub mod shader;
157pub mod shapes;
158pub mod simd_dsp;
159
160#[cfg(feature = "anim-blend")]
161pub mod absm;
162pub mod color_gradient;
163pub mod curve;
164pub mod decal;
165pub mod navmesh;
166pub mod pool;
167
168pub mod occlusion;
169#[cfg(feature = "scene")]
170pub mod scene_format;
171#[cfg(feature = "scene")]
172pub mod scene_stream;
173#[cfg(feature = "raycast")]
174pub mod sector_lights;
175#[cfg(feature = "scene")]
176pub mod skeleton;
177#[cfg(feature = "physics")]
178pub mod softbody;
179pub mod state_machine;
180pub mod swapchain;
181pub mod telemetry;
182#[cfg(feature = "textured")]
183pub mod texture;
184pub mod tilebin;
185pub mod timer;
186#[cfg(feature = "scene")]
187pub mod transform_anim;
188#[cfg(feature = "scene")]
189pub mod tween;
190pub mod view_frustum;
191
192/// Ray-cast against triangle geometry using Möller–Trumbore intersection.
193///
194/// `ray_origin` and `ray_dir` are in world space. `model_matrix` transforms mesh
195/// vertices to world space. Returns the closest hit within `max_distance`, or `None`.
196pub fn mesh_ray_cast(
197    ray_origin: Vector3<f32>,
198    ray_dir: Vector3<f32>,
199    geometry: &mesh::Geometry<'_>,
200    model_matrix: &Matrix4<f32>,
201    max_distance: f32,
202) -> Option<MeshRayCastHit> {
203    #[cfg(feature = "aabb-cull")]
204    {
205        return mesh_ray_cast_bounded(
206            ray_origin,
207            ray_dir,
208            geometry,
209            model_matrix,
210            max_distance,
211            None,
212        );
213    }
214    #[cfg(not(feature = "aabb-cull"))]
215    {
216        mesh_ray_cast_world(ray_origin, ray_dir, geometry, model_matrix, max_distance)
217    }
218}
219
220#[cfg(not(feature = "aabb-cull"))]
221fn mesh_ray_cast_world(
222    ray_origin: Vector3<f32>,
223    ray_dir: Vector3<f32>,
224    geometry: &mesh::Geometry<'_>,
225    model_matrix: &Matrix4<f32>,
226    max_distance: f32,
227) -> Option<MeshRayCastHit> {
228    let mut nearest: Option<MeshRayCastHit> = None;
229    let mut min_dist = max_distance;
230
231    for (face_index, face) in geometry.faces.iter().enumerate() {
232        let raw_v0 = geometry.vertices[face[0]];
233        let raw_v1 = geometry.vertices[face[1]];
234        let raw_v2 = geometry.vertices[face[2]];
235
236        let v0 = model_matrix
237            .transform_point(&Point3::new(raw_v0[0], raw_v0[1], raw_v0[2]))
238            .coords;
239        let v1 = model_matrix
240            .transform_point(&Point3::new(raw_v1[0], raw_v1[1], raw_v1[2]))
241            .coords;
242        let v2 = model_matrix
243            .transform_point(&Point3::new(raw_v2[0], raw_v2[1], raw_v2[2]))
244            .coords;
245
246        let edge1 = v1 - v0;
247        let edge2 = v2 - v0;
248        let h = ray_dir.cross(&edge2);
249        let det = edge1.dot(&h);
250        if det.abs() < 1e-6 {
251            continue;
252        }
253        let inv_det = 1.0 / det;
254        let s = ray_origin - v0;
255        let bary_u = inv_det * s.dot(&h);
256        if !(0.0..=1.0).contains(&bary_u) {
257            continue;
258        }
259        let q = s.cross(&edge1);
260        let bary_v = inv_det * ray_dir.dot(&q);
261        if bary_v < 0.0 || bary_u + bary_v > 1.0 {
262            continue;
263        }
264        let t = inv_det * edge2.dot(&q);
265        if t <= 0.0 || t >= min_dist {
266            continue;
267        }
268        let normal = edge1.cross(&edge2).normalize();
269        let bary_w = 1.0 - bary_u - bary_v;
270        let uv = if geometry.uvs.len() > face[0]
271            && geometry.uvs.len() > face[1]
272            && geometry.uvs.len() > face[2]
273        {
274            let uv0 = geometry.uvs[face[0]];
275            let uv1 = geometry.uvs[face[1]];
276            let uv2 = geometry.uvs[face[2]];
277            [
278                bary_w * uv0[0] + bary_u * uv1[0] + bary_v * uv2[0],
279                bary_w * uv0[1] + bary_u * uv1[1] + bary_v * uv2[1],
280            ]
281        } else {
282            [0.0, 0.0]
283        };
284        let point = ray_origin + ray_dir * t;
285        min_dist = t;
286        nearest = Some(MeshRayCastHit {
287            distance: t,
288            point,
289            normal,
290            face_index,
291            uv,
292        });
293    }
294    nearest
295}
296
297/// Like [`mesh_ray_cast`], with an optional model-space AABB broadphase.
298/// Requires the `aabb-cull` feature.
299#[cfg(feature = "aabb-cull")]
300pub fn mesh_ray_cast_bounded(
301    ray_origin: Vector3<f32>,
302    ray_dir: Vector3<f32>,
303    geometry: &mesh::Geometry<'_>,
304    model_matrix: &Matrix4<f32>,
305    max_distance: f32,
306    model_aabb: Option<&bounds::Aabb>,
307) -> Option<MeshRayCastHit> {
308    let inv = model_matrix.try_inverse()?;
309    let origin4 = inv * Vector4::new(ray_origin.x, ray_origin.y, ray_origin.z, 1.0);
310    let dir4 = inv * Vector4::new(ray_dir.x, ray_dir.y, ray_dir.z, 0.0);
311    if origin4.w.abs() < 1e-8 {
312        return None;
313    }
314    let local_origin = Vector3::new(origin4.x, origin4.y, origin4.z) / origin4.w;
315    let local_dir = Vector3::new(dir4.x, dir4.y, dir4.z);
316    let dir_len = local_dir.norm();
317    if dir_len < 1e-8 {
318        return None;
319    }
320    let local_dir_n = local_dir / dir_len;
321    let local_max = max_distance * dir_len;
322
323    if let Some(aabb) = model_aabb
324        && aabb
325            .intersect_ray(local_origin, local_dir_n, local_max)
326            .is_none()
327    {
328        return None;
329    }
330
331    let mut nearest: Option<MeshRayCastHit> = None;
332    let mut min_dist = local_max;
333
334    for (face_index, face) in geometry.faces.iter().enumerate() {
335        let v0 = Vector3::new(
336            geometry.vertices[face[0]][0],
337            geometry.vertices[face[0]][1],
338            geometry.vertices[face[0]][2],
339        );
340        let v1 = Vector3::new(
341            geometry.vertices[face[1]][0],
342            geometry.vertices[face[1]][1],
343            geometry.vertices[face[1]][2],
344        );
345        let v2 = Vector3::new(
346            geometry.vertices[face[2]][0],
347            geometry.vertices[face[2]][1],
348            geometry.vertices[face[2]][2],
349        );
350
351        let edge1 = v1 - v0;
352        let edge2 = v2 - v0;
353        let h = local_dir_n.cross(&edge2);
354        let det = edge1.dot(&h);
355        if det.abs() < 1e-6 {
356            continue;
357        }
358        let inv_det = 1.0 / det;
359        let s = local_origin - v0;
360        let bary_u = inv_det * s.dot(&h);
361        if !(0.0..=1.0).contains(&bary_u) {
362            continue;
363        }
364        let q = s.cross(&edge1);
365        let bary_v = inv_det * local_dir_n.dot(&q);
366        if bary_v < 0.0 || bary_u + bary_v > 1.0 {
367            continue;
368        }
369        let t_local = inv_det * edge2.dot(&q);
370        if t_local <= 0.0 || t_local >= min_dist {
371            continue;
372        }
373
374        let normal_local = edge1.cross(&edge2).normalize();
375        let rot = model_matrix.fixed_view::<3, 3>(0, 0);
376        let normal = (rot * normal_local).normalize();
377
378        let bary_w = 1.0 - bary_u - bary_v;
379        let uv = if geometry.uvs.len() > face[0]
380            && geometry.uvs.len() > face[1]
381            && geometry.uvs.len() > face[2]
382        {
383            let uv0 = geometry.uvs[face[0]];
384            let uv1 = geometry.uvs[face[1]];
385            let uv2 = geometry.uvs[face[2]];
386            [
387                bary_w * uv0[0] + bary_u * uv1[0] + bary_v * uv2[0],
388                bary_w * uv0[1] + bary_u * uv1[1] + bary_v * uv2[1],
389            ]
390        } else {
391            [0.0, 0.0]
392        };
393
394        let local_hit = local_origin + local_dir_n * t_local;
395        let world_hit = model_matrix
396            .transform_point(&Point3::from(local_hit))
397            .coords;
398        let world_t = (world_hit - ray_origin).norm();
399        if world_t >= max_distance {
400            continue;
401        }
402
403        min_dist = t_local;
404        nearest = Some(MeshRayCastHit {
405            distance: world_t,
406            point: world_hit,
407            normal,
408            face_index,
409            uv,
410        });
411    }
412
413    nearest
414}
415
416/// Convenience: ray-cast a [`K3dMesh`] using its model matrix and cached AABB.
417#[cfg(feature = "aabb-cull")]
418pub fn mesh_ray_cast_mesh(
419    ray_origin: Vector3<f32>,
420    ray_dir: Vector3<f32>,
421    mesh: &K3dMesh<'_>,
422    max_distance: f32,
423) -> Option<MeshRayCastHit> {
424    let distance = (mesh.get_position() - Point3::from(ray_origin)).norm();
425    let geometry = mesh.select_lod(distance);
426    let aabb = mesh.model_aabb();
427    mesh_ray_cast_bounded(
428        ray_origin,
429        ray_dir,
430        geometry,
431        &mesh.model_matrix,
432        max_distance,
433        Some(&aabb),
434    )
435}
436
437#[cfg(feature = "dma2d")]
438mod dma2d_stubs {
439    #[cfg(feature = "depth-u16")]
440    #[unsafe(no_mangle)]
441    extern "Rust" fn dma2d_clear_zbuffer_u16(ptr: *mut u16, len: usize, value: u16) {
442        let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
443        slice.fill(value);
444    }
445
446    #[cfg(not(feature = "depth-u16"))]
447    #[unsafe(no_mangle)]
448    extern "Rust" fn dma2d_clear_zbuffer_u32(ptr: *mut u32, len: usize, value: u32) {
449        let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
450        slice.fill(value);
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    extern crate std;
457
458    use super::*;
459    use crate::engine::K3dengine;
460    use crate::primitive::DrawPrimitive;
461
462    #[test]
463    fn test_engine_creation() {
464        let engine = K3dengine::new(640, 480);
465        assert_eq!(engine.width, 640);
466        assert_eq!(engine.height, 480);
467        assert!((engine.camera.get_aspect_ratio() - 640.0 / 480.0).abs() < 0.001);
468    }
469
470    #[test]
471    fn test_transform_point_basic() {
472        let engine = K3dengine::new(640, 480);
473        // Use camera's VP matrix directly
474        let transform_matrix = engine.camera.vp_matrix;
475
476        // Point in front of default camera, within view frustum
477        // Default camera is at origin looking at origin, so we need a point in front
478        let point = [0.0, 0.0, -5.0];
479        let result = engine.transform_point(&point, transform_matrix);
480
481        if let Some(transformed) = result {
482            // Should be within screen bounds
483            assert!(transformed.x >= 0 && transformed.x < 640);
484            assert!(transformed.y >= 0 && transformed.y < 480);
485        }
486        // If None, the point was culled which is also valid behavior
487    }
488
489    #[test]
490    fn test_transform_point_clamps_out_of_bounds() {
491        let engine = K3dengine::new(640, 480);
492        let model_matrix = nalgebra::Matrix4::identity();
493
494        // Point way outside the viewport should be clamped/rejected
495        let point = [100.0, 100.0, -5.0];
496        let result = engine.transform_point(&point, model_matrix);
497        // Should return None because coordinates are clamped out
498        assert!(result.is_none());
499    }
500
501    #[test]
502    fn test_transform_point_behind_camera() {
503        let engine = K3dengine::new(640, 480);
504        let transform_matrix = engine.camera.vp_matrix;
505
506        // Point with positive z (behind default camera orientation)
507        let point = [0.0, 0.0, 1.0];
508        let _result = engine.transform_point(&point, transform_matrix);
509        // Point behind camera or outside frustum should return None
510        // (actual behavior depends on camera setup and projection)
511        // This test just verifies the function doesn't panic
512    }
513
514    #[test]
515    fn test_transform_point_near_plane_clipping() {
516        let engine = K3dengine::new(640, 480);
517        // Use the camera's real projection, not a raw identity matrix: the
518        // near/far check now tests clip-space W (view depth), which is
519        // only meaningful under an actual perspective projection -- an
520        // identity "model_matrix" leaves W pinned at the homogeneous 1.0
521        // regardless of the point's z, so it can't exercise this check.
522        let transform_matrix = engine.camera.vp_matrix;
523
524        // Point too close to camera: distance 0.1, before the near=0.4 plane.
525        let point = [0.0, 0.0, -0.1];
526        let result = engine.transform_point(&point, transform_matrix);
527        assert!(result.is_none());
528    }
529
530    #[test]
531    fn test_transform_point_far_plane_clipping() {
532        let engine = K3dengine::new(640, 480);
533        let transform_matrix = engine.camera.vp_matrix;
534
535        // Point too far from camera: distance 1000, beyond the far=20 plane.
536        let point = [0.0, 0.0, -1000.0];
537        let result = engine.transform_point(&point, transform_matrix);
538        assert!(result.is_none());
539    }
540
541    #[test]
542    fn test_transform_point_within_near_far_not_culled() {
543        // Regression test: `transform_point`'s near/far check used to
544        // compare pre-divide clip.z against camera.near/far, which for the
545        // default near=0.4/far=20 camera silently culled anything closer
546        // than roughly 1.17 units -- even though it's well within
547        // [near, far]. z=-0.8 falls in that dead zone.
548        let engine = K3dengine::new(640, 480);
549        let transform_matrix = engine.camera.vp_matrix;
550
551        let point = [0.0, 0.0, -0.8];
552        let result = engine.transform_point(&point, transform_matrix);
553        assert!(
554            result.is_some(),
555            "a point at distance 0.8 (within [near=0.4, far=20]) should not be culled"
556        );
557    }
558
559    #[test]
560    fn test_transform_points_array() {
561        let engine = K3dengine::new(640, 480);
562        let transform_matrix = engine.camera.vp_matrix;
563
564        let vertices = [[0.0, 0.0, -5.0], [0.1, 0.0, -5.0], [0.0, 0.1, -5.0]];
565        let indices = [0, 1, 2];
566
567        let result = engine.transform_points(&indices, &vertices, transform_matrix);
568
569        // If transform succeeds, verify we get 3 points
570        if let Some(points) = result {
571            assert_eq!(points.len(), 3);
572        }
573        // If None, one or more points were culled which is valid
574    }
575
576    #[test]
577    fn test_render_empty_faces_mesh() {
578        let engine = K3dengine::new(640, 480);
579        let vertices = [[0.0, 0.0, -5.0]]; // At least one vertex required
580        let geometry = mesh::Geometry {
581            vertices: &vertices,
582            faces: &[],
583            colors: &[],
584            lines: &[],
585            normals: &[],
586            vertex_normals: &[],
587            uvs: &[],
588            texture_id: None,
589        };
590        let mesh = mesh::K3dMesh::new(geometry);
591
592        let mut callback_count = 0;
593        engine.render(std::iter::once(&mesh), |_| {
594            callback_count += 1;
595        });
596
597        // Mesh with no faces/lines should trigger one point callback (default is Points mode)
598        assert!(callback_count > 0);
599    }
600
601    #[test]
602    fn test_render_points_mode() {
603        let engine = K3dengine::new(640, 480);
604
605        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0]];
606
607        let geometry = mesh::Geometry {
608            vertices: &vertices,
609            faces: &[],
610            colors: &[],
611            lines: &[],
612            normals: &[],
613            vertex_normals: &[],
614            uvs: &[],
615            texture_id: None,
616        };
617
618        let mut mesh = mesh::K3dMesh::new(geometry);
619        mesh.set_render_mode(mesh::RenderMode::Points);
620
621        let mut primitives = std::vec::Vec::new();
622        engine.render(std::iter::once(&mesh), |prim| {
623            primitives.push(prim);
624        });
625
626        // Should render points
627        assert!(!primitives.is_empty());
628        for prim in primitives {
629            assert!(matches!(prim, DrawPrimitive::ColoredPoint(_, _)));
630        }
631    }
632
633    #[test]
634    fn test_render_lines_mode_with_faces() {
635        let engine = K3dengine::new(640, 480);
636
637        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0], [0.0, 0.5, -5.0]];
638
639        let faces = [[0, 1, 2]];
640
641        let geometry = mesh::Geometry {
642            vertices: &vertices,
643            faces: &faces,
644            colors: &[],
645            lines: &[],
646            normals: &[],
647            vertex_normals: &[],
648            uvs: &[],
649            texture_id: None,
650        };
651
652        let mut mesh = mesh::K3dMesh::new(geometry);
653        mesh.set_render_mode(mesh::RenderMode::Lines);
654
655        let mut primitives = std::vec::Vec::new();
656        engine.render(std::iter::once(&mesh), |prim| {
657            primitives.push(prim);
658        });
659
660        // Should render 3 lines (edges of triangle)
661        assert_eq!(primitives.len(), 3);
662        for prim in primitives {
663            assert!(matches!(prim, DrawPrimitive::Line(_, _)));
664        }
665    }
666
667    #[test]
668    #[cfg(feature = "lighting")]
669    fn test_render_gouraud_light_dir() {
670        let mut engine = K3dengine::new(640, 480);
671        engine.camera.set_position(Point3::new(0.0, 0.0, -10.0));
672        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
673
674        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
675        let faces = [[0, 1, 2]];
676        let normals = [[0.0, 0.0, -1.0]]; // face normal pointing toward camera
677        let vertex_normals = [[0.0, 0.0, -1.0], [0.0, 0.0, -1.0], [0.0, 0.0, -1.0]];
678
679        let geometry = mesh::Geometry {
680            vertices: &vertices,
681            faces: &faces,
682            colors: &[],
683            lines: &[],
684            normals: &normals,
685            vertex_normals: &vertex_normals,
686            uvs: &[],
687            texture_id: None,
688        };
689
690        let mut mesh = mesh::K3dMesh::new(geometry);
691        mesh.set_render_mode(mesh::RenderMode::GouraudLightDir(Vector3::new(
692            0.0, 0.0, 1.0,
693        )));
694
695        let mut primitives = std::vec::Vec::new();
696        engine.render(std::iter::once(&mesh), |prim| {
697            primitives.push(prim);
698        });
699
700        // Should emit GouraudTriangleWithDepth primitives
701        assert!(!primitives.is_empty());
702        for prim in &primitives {
703            assert!(matches!(
704                prim,
705                DrawPrimitive::GouraudTriangleWithDepth { .. }
706            ));
707        }
708    }
709
710    /// Verify that Solid-with-normals renders an interior box correctly.
711    ///
712    /// Places a camera at the centre of a simple box whose face normals point
713    /// inward (toward the camera).  The Solid render path must emit at least
714    /// one primitive — if backface culling incorrectly fires for all faces
715    /// this test will catch it.
716    #[test]
717    fn test_solid_inward_normals_interior_camera() {
718        let mut engine = K3dengine::new(320, 240);
719        // Camera inside the box, looking north (–Z).
720        engine
721            .camera
722            .set_position(nalgebra::Point3::new(0.0, 0.0, 0.0));
723        engine
724            .camera
725            .set_target(nalgebra::Point3::new(0.0, 0.0, -1.0));
726
727        // Single north wall: z = –2, vertices form a quad centred on the axis.
728        // Inward normal points toward the camera = +Z.
729        #[rustfmt::skip]
730        let vertices: &[[f32; 3]] = &[
731            [-1.0, -1.0, -2.0],
732            [ 1.0, -1.0, -2.0],
733            [ 1.0,  1.0, -2.0],
734            [-1.0,  1.0, -2.0],
735        ];
736        let faces: &[[usize; 3]] = &[[0, 1, 2], [0, 2, 3]];
737        let normals: &[[f32; 3]] = &[[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]; // inward (+Z)
738
739        let geometry = mesh::Geometry {
740            vertices,
741            faces,
742            normals,
743            colors: &[],
744            lines: &[],
745            vertex_normals: &[],
746            uvs: &[],
747            texture_id: None,
748        };
749        let mut m = mesh::K3dMesh::new(geometry);
750        m.set_render_mode(mesh::RenderMode::Solid);
751
752        let mut count = 0usize;
753        engine.render(std::iter::once(&m), |_| count += 1);
754
755        assert!(
756            count > 0,
757            "interior Solid-with-inward-normals emitted 0 primitives — culling is wrong"
758        );
759    }
760}