Skip to main content

embedded_3dgfx/
lib.rs

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