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