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;
13use nalgebra::Vector4;
14
15// ComplexField provides sqrt() for f32 in no_std via libm
16// It appears "unused" in tests because tests use std, but it's required for no_std builds
17#[allow(unused_imports)]
18use nalgebra::ComplexField;
19
20pub mod animation;
21pub mod billboard;
22pub mod bridge;
23pub mod bsp;
24pub mod camera;
25pub mod character;
26pub mod command_buffer;
27pub mod config;
28pub mod display_backend;
29pub mod draw;
30pub mod error;
31pub mod hardware_profile;
32pub mod hud;
33pub mod input;
34pub mod lights;
35pub mod mesh;
36#[cfg(feature = "std")]
37pub mod painters;
38pub mod particles;
39#[cfg(feature = "perfcounter")]
40pub mod perfcounter;
41#[cfg(feature = "physics")]
42pub mod physics;
43pub mod renderer;
44pub mod retro;
45pub mod scene_format;
46pub mod scene_stream;
47pub mod sector_lights;
48pub mod skeleton;
49#[cfg(feature = "physics")]
50pub mod softbody;
51pub mod swapchain;
52pub mod telemetry;
53pub mod texture;
54pub mod tilebin;
55pub mod transform_anim;
56pub mod tween;
57
58// Re-export framebuffer types from external crate for user convenience
59pub use embedded_graphics_framebuf::{
60    FrameBuf,
61    backends::{DMACapableFrameBufferBackend, EndianCorrectedBuffer, EndianCorrection},
62};
63
64#[cfg(feature = "aa")]
65pub use draw::ReadPixel;
66
67pub use bridge::{
68    AsEgPoint, AsNalgebraPoint, draw_to, eg_to_nalgebra, nalgebra_to_eg, render_drawable_to_buffer,
69};
70pub use character::CharacterController;
71#[cfg(feature = "aa")]
72pub use draw::draw_zbuffered_2xssaa;
73pub use draw::{
74    DitherConfig, FogConfig, fast_blend_rgb565, fast_blend_rgba8888, fast_blend_rgba8888_to_rgb565,
75    reverse_color_rgb565, reverse_color_rgba8888,
76};
77// Q16.16 fixed-point math now lives in embedded-dsp (shared with
78// embedded-gui) instead of a bespoke copy in this crate. Only available
79// when the "fixed-transform" feature (which requires "dsp") is enabled.
80#[cfg(feature = "fixed-transform")]
81pub use embedded_dsp::fixed_point::{
82    FP_ONE, Q16, Q16_MAX, Q16_MIN, ScanlineInterp, abs_q16, angle_to_q16, div_f_q16, div_n_q16,
83    div_q16, from_i16_q16, from_q16, lerp_q16, mul_f_q16, mul_n_q16, mul_q16, q16_to_q31,
84    q31_to_q16, qadd_q16, qsub_q16, recip_q16, to_i16_q16, to_q16,
85};
86pub use input::InputState;
87pub use lights::{PointLight, PointLightSet};
88pub use particles::{ParticleSpawn, ParticleSystem};
89pub use renderer::{DirtyRegion, FrameCtx};
90pub use retro::{
91    LightLevels, PaletteMode, RetroStyle, ScreenTint, SkyConfig, StippleMode, TextureMapping,
92};
93pub use sector_lights::{LightEffectKind, SectorLight, light_level_at, light_level_u8_at};
94pub use tilebin::{TileBinStats, TileConfig};
95pub use transform_anim::{AnimationPlayer, SampledTransform, TransformKeyframe, TransformTrack};
96pub use tween::{Easing, Tween, Tween3, apply_easing, lerp, lerp3, scale_rgb565};
97
98#[derive(Debug, Clone)]
99pub enum DrawPrimitive {
100    ColoredPoint(Point2<i32>, Rgb565),
101    Line([Point2<i32>; 2], Rgb565),
102    ColoredTriangle([Point2<i32>; 3], Rgb565),
103    ColoredTriangleWithDepth {
104        points: [Point2<i32>; 3],
105        depths: [f32; 3],
106        color: Rgb565,
107    },
108    TranslucentTriangleWithDepth {
109        points: [Point2<i32>; 3],
110        depths: [f32; 3],
111        color: Rgb565,
112        alpha: u8,
113    },
114    GouraudTriangle {
115        points: [Point2<i32>; 3],
116        colors: [Rgb565; 3],
117    },
118    GouraudTriangleWithDepth {
119        points: [Point2<i32>; 3],
120        depths: [f32; 3],
121        colors: [Rgb565; 3],
122    },
123    TexturedTriangle {
124        points: [Point2<i32>; 3],
125        uvs: [[f32; 2]; 3],
126        texture_id: u32,
127    },
128    TexturedTriangleWithDepth {
129        points: [Point2<i32>; 3],
130        depths: [f32; 3],
131        ws: [f32; 3],
132        uvs: [[f32; 2]; 3],
133        texture_id: u32,
134    },
135    /// Perspective-correct textured triangle with baked lightmap.
136    ///
137    /// The final pixel colour is:
138    /// `clamp(surface.sample(su,sv) × lightmap.sample(lu,lv) + dynamic_tint)`
139    /// where `×` is per-channel normalised multiply.
140    /// Set `lightmap_id = u32::MAX` to fall back to full-bright surface colour.
141    /// Set `dynamic_tint = Rgb565::new(0,0,0)` for no dynamic lighting.
142    LightmappedTriangle {
143        points: [Point2<i32>; 3],
144        depths: [f32; 3],
145        ws: [f32; 3],
146        surface_uvs: [[f32; 2]; 3],
147        lm_uvs: [[f32; 2]; 3],
148        texture_id: u32,
149        lightmap_id: u32,
150        /// Per-face brightness multiplier in 0..=255 (255 = no darkening).
151        brightness: u8,
152        /// Additive RGB565 tint from runtime point lights.
153        dynamic_tint: Rgb565,
154    },
155}
156
157pub struct K3dengine {
158    pub camera: Camera,
159    width: u16,
160    height: u16,
161    caps: Option<crate::config::ProfileCaps>,
162    quality_tier: crate::config::QualityTier,
163    material_profile: crate::config::MaterialProfile,
164    /// Depth-based fog applied during `execute` / `execute_tiled`.
165    fog: Option<crate::draw::FogConfig>,
166    /// Ordered dithering applied during `execute` / `execute_tiled`.
167    dither: Option<crate::draw::DitherConfig>,
168    /// Optional NDC snap precision for retro-style vertex jitter.
169    vertex_snap_bits: u8,
170    /// Texture interpolation mode for textured raster paths.
171    texture_mapping: crate::retro::TextureMapping,
172    /// Sector brightness behavior.
173    light_levels: crate::retro::LightLevels,
174    /// Optional stipple mode for textured/lightmapped passes.
175    stipple_mode: crate::retro::StippleMode,
176    /// Optional full-screen tint blended during rasterization.
177    screen_tint: Option<crate::retro::ScreenTint>,
178    /// Optional palette quantization.
179    palette_mode: crate::retro::PaletteMode,
180    /// Optional sky background rendered before scene geometry.
181    sky: Option<crate::retro::SkyConfig>,
182    /// Runtime point lights (max 16).  Applied at face-centre granularity
183    /// during `record` for mesh geometry and at face level for BSP.
184    point_lights: heapless::Vec<crate::lights::PointLight, 16>,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct BudgetFallbackOutcome {
189    pub used_fallback: bool,
190    pub primary_budget_error: Option<crate::error::BudgetKind>,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct DegradationOutcome {
195    pub used_degradation: bool,
196    pub steps_applied: usize,
197    pub dropped_meshes: usize,
198    pub final_quality_tier: crate::config::QualityTier,
199    pub primary_budget_error: Option<crate::error::BudgetKind>,
200}
201
202impl K3dengine {
203    pub fn new(width: u16, height: u16) -> K3dengine {
204        K3dengine {
205            camera: Camera::new(width as f32 / height as f32),
206            width,
207            height,
208            caps: None,
209            quality_tier: crate::config::QualityTier::Balanced,
210            material_profile: crate::config::MaterialProfile::Lambert,
211            fog: None,
212            dither: None,
213            vertex_snap_bits: 0,
214            texture_mapping: crate::retro::TextureMapping::PerspectiveCorrect,
215            light_levels: crate::retro::LightLevels::Linear,
216            stipple_mode: crate::retro::StippleMode::Off,
217            screen_tint: None,
218            palette_mode: crate::retro::PaletteMode::Off,
219            sky: None,
220            point_lights: heapless::Vec::new(),
221        }
222    }
223
224    /// Enable depth-based fog for subsequent [`execute`][Self::execute] calls.
225    pub fn set_fog(&mut self, fog: crate::draw::FogConfig) {
226        self.fog = Some(fog);
227    }
228
229    /// Disable fog (default state).
230    pub fn clear_fog(&mut self) {
231        self.fog = None;
232    }
233
234    /// Enable ordered dithering for subsequent execute passes.
235    pub fn set_dither(&mut self, dither: crate::draw::DitherConfig) {
236        self.dither = Some(dither);
237    }
238
239    /// Disable ordered dithering.
240    pub fn clear_dither(&mut self) {
241        self.dither = None;
242    }
243
244    /// Set NDC vertex snap precision. `0` disables snapping.
245    pub fn set_vertex_snap_bits(&mut self, bits: u8) {
246        self.vertex_snap_bits = bits.min(16);
247    }
248
249    /// Select texture interpolation mode.
250    pub fn set_texture_mapping(&mut self, mapping: crate::retro::TextureMapping) {
251        self.texture_mapping = mapping;
252    }
253
254    /// Select sector light quantization model.
255    pub fn set_light_levels(&mut self, levels: crate::retro::LightLevels) {
256        self.light_levels = levels;
257    }
258
259    /// Set stipple mode used by textured/lightmapped raster paths.
260    pub fn set_stipple_mode(&mut self, mode: crate::retro::StippleMode) {
261        self.stipple_mode = mode;
262    }
263
264    /// Set an optional full-screen tint.
265    pub fn set_screen_tint(&mut self, tint: crate::retro::ScreenTint) {
266        self.screen_tint = Some(tint);
267    }
268
269    /// Disable full-screen tint.
270    pub fn clear_screen_tint(&mut self) {
271        self.screen_tint = None;
272    }
273
274    /// Set output palette quantization mode.
275    pub fn set_palette_mode(&mut self, mode: crate::retro::PaletteMode) {
276        self.palette_mode = mode;
277    }
278
279    /// Set procedural sky rendering parameters.
280    pub fn set_sky(&mut self, sky: crate::retro::SkyConfig) {
281        self.sky = Some(sky);
282    }
283
284    /// Disable procedural sky rendering.
285    pub fn clear_sky(&mut self) {
286        self.sky = None;
287    }
288
289    /// Apply a coarse retro visual preset.
290    pub fn apply_retro_style(&mut self, style: crate::retro::RetroStyle) {
291        self.fog = style.fog;
292        self.dither = style.dither;
293        self.set_vertex_snap_bits(style.vertex_snap_bits);
294        self.texture_mapping = style.texture_mapping;
295        self.light_levels = style.light_levels;
296        self.stipple_mode = style.stipple_mode;
297        self.screen_tint = style.screen_tint;
298        self.palette_mode = style.palette_mode;
299        self.sky = style.sky;
300    }
301
302    /// Add a dynamic point light.  Returns `false` when the 16-light limit
303    /// is reached.
304    pub fn add_point_light(&mut self, light: crate::lights::PointLight) -> bool {
305        self.point_lights.push(light).is_ok()
306    }
307
308    /// Remove all dynamic point lights.
309    pub fn clear_point_lights(&mut self) {
310        self.point_lights.clear();
311    }
312
313    /// Compute the summed additive RGB565 tint from all registered point
314    /// lights at `world_pos`.
315    #[inline]
316    fn light_tint_at(&self, world_pos: Point3<f32>) -> Rgb565 {
317        let mut r = 0u32;
318        let mut g = 0u32;
319        let mut b = 0u32;
320        for light in &self.point_lights {
321            let c = light.contribution_at(world_pos);
322            r += c.r() as u32;
323            g += c.g() as u32;
324            b += c.b() as u32;
325        }
326        Rgb565::new(r.min(31) as u8, g.min(63) as u8, b.min(31) as u8)
327    }
328
329    /// Additively blend `tint` into `base`, saturating per channel.
330    #[inline]
331    fn add_tint(base: Rgb565, tint: Rgb565) -> Rgb565 {
332        Rgb565::new(
333            (base.r() as u16 + tint.r() as u16).min(31) as u8,
334            (base.g() as u16 + tint.g() as u16).min(63) as u8,
335            (base.b() as u16 + tint.b() as u16).min(31) as u8,
336        )
337    }
338
339    /// Non-linear 32-level light ramp for Doom-style sector attenuation.
340    const DOOM_LIGHT_TABLE: [u8; 32] = [
341        8, 12, 16, 20, 24, 28, 34, 40, 48, 56, 64, 72, 82, 92, 102, 112, 124, 136, 148, 160, 172,
342        184, 196, 206, 216, 224, 232, 238, 244, 248, 252, 255,
343    ];
344
345    #[inline]
346    fn sector_shaded_color(
347        &self,
348        base: Rgb565,
349        brightness: u8,
350        face_center: Point3<f32>,
351    ) -> Rgb565 {
352        let level_u8 = match self.light_levels {
353            crate::retro::LightLevels::Linear => brightness,
354            crate::retro::LightLevels::Doom32 => {
355                let base_level = (brightness as usize * 31) / 255;
356                let distance = (face_center - self.camera.position).norm();
357                // 2.0 buckets per world-unit gives coarse banding similar to classic software renderers.
358                let distance_drop = (distance * 2.0) as usize;
359                let idx = base_level.saturating_sub(distance_drop).min(31);
360                Self::DOOM_LIGHT_TABLE[idx]
361            }
362        };
363
364        let factor = level_u8 as f32 / 255.0;
365        Rgb565::new(
366            (base.r() as f32 * factor) as u8,
367            (base.g() as f32 * factor) as u8,
368            (base.b() as f32 * factor) as u8,
369        )
370    }
371
372    /// Compute the world-space centroid of a triangle face.
373    #[inline]
374    fn face_world_center(
375        face: &[usize; 3],
376        vertices: &[[f32; 3]],
377        model_matrix: Matrix4<f32>,
378    ) -> Point3<f32> {
379        let v0 = vertices[face[0]];
380        let v1 = vertices[face[1]];
381        let v2 = vertices[face[2]];
382        let cx = (v0[0] + v1[0] + v2[0]) / 3.0;
383        let cy = (v0[1] + v1[1] + v2[1]) / 3.0;
384        let cz = (v0[2] + v1[2] + v2[2]) / 3.0;
385        model_matrix.transform_point(&Point3::new(cx, cy, cz))
386    }
387
388    pub fn set_caps(&mut self, caps: crate::config::ProfileCaps) {
389        self.caps = Some(caps);
390        self.apply_render_defaults(crate::config::render_defaults_for_profile(caps));
391    }
392
393    pub fn clear_caps(&mut self) {
394        self.caps = None;
395    }
396
397    pub fn set_quality_tier(&mut self, tier: crate::config::QualityTier) {
398        self.quality_tier = tier;
399    }
400
401    pub fn set_material_profile(&mut self, profile: crate::config::MaterialProfile) {
402        self.material_profile = profile;
403    }
404
405    pub fn apply_render_defaults(&mut self, defaults: crate::config::RenderDefaults) {
406        self.quality_tier = defaults.quality_tier;
407        self.material_profile = defaults.material_profile;
408    }
409
410    fn resolve_render_mode(&self, mode: &RenderMode) -> RenderMode {
411        use crate::config::{MaterialProfile, QualityTier};
412        match self.quality_tier {
413            QualityTier::Fastest => match mode {
414                RenderMode::BlinnPhong { .. }
415                | RenderMode::GouraudLightDir(_)
416                | RenderMode::SolidLightDir(_) => RenderMode::Solid,
417                _ => mode.clone(),
418            },
419            QualityTier::Balanced => match (self.material_profile, mode) {
420                (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
421                | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
422                | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
423                (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
424                    RenderMode::SolidLightDir(light_dir.clone())
425                }
426                _ => mode.clone(),
427            },
428            QualityTier::Quality => match (self.material_profile, mode) {
429                (MaterialProfile::Unlit, RenderMode::BlinnPhong { .. })
430                | (MaterialProfile::Unlit, RenderMode::GouraudLightDir(_))
431                | (MaterialProfile::Unlit, RenderMode::SolidLightDir(_)) => RenderMode::Solid,
432                (MaterialProfile::Lambert, RenderMode::BlinnPhong { light_dir, .. }) => {
433                    RenderMode::SolidLightDir(light_dir.clone())
434                }
435                _ => mode.clone(),
436            },
437        }
438    }
439
440    /// Fast frustum culling check using bounding sphere.
441    /// Returns true if the mesh should be culled (not rendered).
442    #[inline]
443    fn should_cull_mesh(&self, mesh: &K3dMesh) -> bool {
444        // Get mesh position in world space
445        let mesh_pos = mesh.get_position();
446
447        // Compute distance from camera to mesh center
448        let to_mesh = mesh_pos - self.camera.position;
449        let distance = to_mesh.norm(); // Uses libm sqrt via nalgebra
450
451        // Get squared bounding radius and compute radius
452        // This is only called once per mesh, not in the inner loop
453        let radius_sq = mesh.compute_bounding_radius_sq();
454        let radius = radius_sq.sqrt(); // Uses libm sqrt (one call per mesh is acceptable)
455
456        // Far plane culling: mesh sphere is entirely beyond far plane
457        if distance - radius > self.camera.far {
458            return true;
459        }
460
461        // Near plane culling: mesh sphere is entirely before near plane
462        if distance + radius < self.camera.near {
463            return true;
464        }
465
466        // Passed culling tests - render the mesh
467        false
468    }
469
470    #[inline(always)]
471    fn transform_point(&self, point: &[f32; 3], model_matrix: Matrix4<f32>) -> Option<Point3<i32>> {
472        #[cfg(feature = "fixed-transform")]
473        {
474            return self.transform_point_fixed(point, model_matrix);
475        }
476        #[cfg(not(feature = "fixed-transform"))]
477        {
478            let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
479            let point = model_matrix * point;
480
481            if point.w < 0.0 {
482                return None;
483            }
484            // `point.w` is the view-space depth (distance along the view
485            // direction) for a standard perspective projection, so this is
486            // a proper "is the view depth within [near, far]" test. This
487            // used to compare pre-divide clip.z instead, which is not
488            // linear in view depth -- the "safe" window it accepted started
489            // well above `near` itself, silently culling geometry closer to
490            // the camera than roughly the midpoint of [near, far]. Matches
491            // the fix already applied to `transform_point_with_w`.
492            if point.w < self.camera.near || point.w > self.camera.far {
493                return None;
494            }
495
496            let point = Point3::from_homogeneous(point)?;
497
498            let x = ((1.0 + point.x) * 0.5 * self.width as f32) as i32;
499            let y = ((1.0 - point.y) * 0.5 * self.height as f32) as i32;
500
501            if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
502                return None;
503            }
504
505            Some(Point3::new(
506                x,
507                y,
508                (point.z * (self.camera.far - self.camera.near) + self.camera.near) as i32,
509            ))
510        }
511    }
512
513    #[cfg(feature = "fixed-transform")]
514    #[inline(always)]
515    fn transform_point_fixed(
516        &self,
517        point: &[f32; 3],
518        model_matrix: Matrix4<f32>,
519    ) -> Option<Point3<i32>> {
520        use embedded_dsp::fixed_point::{Q16, from_q16, to_q16};
521
522        // Q16.16 division that returns `None` on a zero divisor, matching
523        // the early-return-via-`?` control flow below (`div_q16` itself
524        // just returns 0 on divide-by-zero).
525        #[inline(always)]
526        fn div_checked(a: Q16, b: Q16) -> Option<Q16> {
527            if b == 0 {
528                None
529            } else {
530                Some(embedded_dsp::fixed_point::div_q16(a, b))
531            }
532        }
533
534        let point = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
535        let point = model_matrix * point;
536
537        if point.w <= 0.0 {
538            return None;
539        }
540        // Same fix as `transform_point`/`transform_point_with_w`: test the
541        // view-space depth (`point.w`) against `[near, far]`, not the
542        // post-divide NDC z (which is confined to roughly `[-1, 1]` and can
543        // never satisfy a "world scale" near/far pair).
544        if point.w < self.camera.near || point.w > self.camera.far {
545            return None;
546        }
547
548        let x_fp = div_checked(to_q16(point.x), to_q16(point.w))?;
549        let y_fp = div_checked(to_q16(point.y), to_q16(point.w))?;
550        let z_ndc = from_q16(div_checked(to_q16(point.z), to_q16(point.w))?);
551
552        let x = ((1.0 + from_q16(x_fp)) * 0.5 * self.width as f32) as i32;
553        let y = ((1.0 - from_q16(y_fp)) * 0.5 * self.height as f32) as i32;
554
555        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
556            return None;
557        }
558
559        Some(Point3::new(
560            x,
561            y,
562            (z_ndc * (self.camera.far - self.camera.near) + self.camera.near) as i32,
563        ))
564    }
565
566    #[inline(always)]
567    pub fn transform_points<const N: usize>(
568        &self,
569        indices: &[usize; N],
570        vertices: &[[f32; 3]],
571        model_matrix: Matrix4<f32>,
572    ) -> Option<[Point3<i32>; N]> {
573        let mut ret = [Point3::new(0, 0, 0); N];
574
575        for i in 0..N {
576            ret[i] = self.transform_point(&vertices[indices[i]], model_matrix)?;
577        }
578
579        Some(ret)
580    }
581
582    /// Like `transform_point` but also returns the clip-space W for perspective-correct interpolation.
583    /// Returns (screen_point, w_clip). w_clip is the clip-space W before perspective division.
584    fn transform_point_with_w(
585        &self,
586        point: &[f32; 3],
587        model_matrix: Matrix4<f32>,
588    ) -> Option<(Point3<i32>, f32)> {
589        let v = nalgebra::Vector4::new(point[0], point[1], point[2], 1.0);
590        let clip = model_matrix * v;
591        // clip.w is the view-space depth (distance along the view direction).
592        // Previously this compared ndc_z (range -1..+1) against camera.near/far
593        // (world-space values like 0.4 and 20.0), which is a unit mismatch that
594        // caused close particles to bleed through unrendered geometry.
595        if clip.w < self.camera.near || clip.w > self.camera.far {
596            return None;
597        }
598        let ndc_x = clip.x / clip.w;
599        let ndc_y = clip.y / clip.w;
600        let ndc_z = clip.z / clip.w;
601        let x = ((1.0 + ndc_x) * 0.5 * self.width as f32) as i32;
602        let y = ((1.0 - ndc_y) * 0.5 * self.height as f32) as i32;
603        if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
604            return None;
605        }
606        let z = (ndc_z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
607        Some((Point3::new(x, y, z), clip.w))
608    }
609
610    /// Like `transform_points` but also returns clip-space W values for perspective-correct UV.
611    #[inline(always)]
612    pub fn transform_points_with_w<const N: usize>(
613        &self,
614        indices: &[usize; N],
615        vertices: &[[f32; 3]],
616        model_matrix: Matrix4<f32>,
617    ) -> Option<([Point3<i32>; N], [f32; N])> {
618        let mut pts = [Point3::new(0, 0, 0); N];
619        let mut ws = [1.0f32; N];
620        for i in 0..N {
621            let (p, w) = self.transform_point_with_w(&vertices[indices[i]], model_matrix)?;
622            pts[i] = p;
623            ws[i] = w;
624        }
625        Some((pts, ws))
626    }
627
628    /// Position-based backface cull.
629    ///
630    /// Returns `true` when the face should be skipped (camera is on the
631    /// back/outer side of the surface).
632    ///
633    /// Using the camera *position* rather than *direction* means the result is
634    /// independent of where the camera looks — a horizontal floor stays visible
635    /// even when the camera pitches up or down.  The direction-based test
636    /// (`camera.get_direction() · normal`) incorrectly flips sign as soon as
637    /// pitch ≠ 0, culling the floor on upward tilt and the ceiling on downward.
638    #[inline]
639    fn is_backface(
640        &self,
641        face: &[usize; 3],
642        vertices: &[[f32; 3]],
643        model_matrix: Matrix4<f32>,
644        world_normal: &Vector3<f32>,
645    ) -> bool {
646        let v0 = vertices[face[0]];
647        let v0_world = if model_matrix == Matrix4::identity() {
648            Point3::new(v0[0], v0[1], v0[2])
649        } else {
650            model_matrix.transform_point(&Point3::new(v0[0], v0[1], v0[2]))
651        };
652        (self.camera.position - v0_world).dot(world_normal) < 0.0
653    }
654
655    // ── Near-plane triangle clipping ──────────────────────────────────────────
656    //
657    // The engine's vertex-by-vertex `transform_point` returns `None` for any
658    // vertex that projects outside the screen, causing the whole triangle to
659    // be dropped.  For interior scenes (floor, ceiling, walls close to the
660    // camera) this makes large surfaces invisible.
661    //
662    // The fix is a one-plane Sutherland-Hodgman clip against w = CLIP_NEAR_W
663    // before perspective divide.  Clipped triangles are emitted directly after
664    // projection; the rasterizer's existing scanline bounds-checks handle any
665    // remaining X/Y screen overshoot safely.
666
667    /// Project a single homogeneous clip-space vertex to integer screen coords.
668    /// Unlike `transform_point`, this does NOT reject off-screen X/Y — the
669    /// rasterizer clips scanlines to screen bounds already.
670    /// Screen coordinates are guard-banded to ±8× the framebuffer dimension so
671    /// the rasterizer scanline loop is always bounded even for near-plane clips.
672    #[inline]
673    fn clip_to_screen(&self, c: Vector4<f32>) -> Option<Point3<i32>> {
674        if c.w <= 0.0 {
675            return None;
676        }
677        let mut ndc = Point3::from_homogeneous(c)?;
678        if self.vertex_snap_bits > 0 {
679            let scale = (1u32 << self.vertex_snap_bits) as f32;
680            ndc.x = (ndc.x * scale).round() / scale;
681            ndc.y = (ndc.y * scale).round() / scale;
682        }
683        let w = self.width as f32;
684        let h = self.height as f32;
685        let x = ((1.0 + ndc.x) * 0.5 * w).clamp(-w * 8.0, w * 9.0) as i32;
686        let y = ((1.0 - ndc.y) * 0.5 * h).clamp(-h * 8.0, h * 9.0) as i32;
687        let depth = (ndc.z * (self.camera.far - self.camera.near) + self.camera.near) as i32;
688        Some(Point3::new(x, y, depth))
689    }
690
691    /// Project three clip-space vertices and emit one `ColoredTriangleWithDepth`
692    /// command if all three project successfully.
693    #[inline]
694    fn project_and_emit<F>(
695        &self,
696        c0: Vector4<f32>,
697        c1: Vector4<f32>,
698        c2: Vector4<f32>,
699        color: Rgb565,
700        callback: &mut F,
701    ) where
702        F: FnMut(DrawPrimitive),
703    {
704        if let (Some(p0), Some(p1), Some(p2)) = (
705            self.clip_to_screen(c0),
706            self.clip_to_screen(c1),
707            self.clip_to_screen(c2),
708        ) {
709            callback(DrawPrimitive::ColoredTriangleWithDepth {
710                points: [p0.xy(), p1.xy(), p2.xy()],
711                depths: [p0.z as f32, p1.z as f32, p2.z as f32],
712                color,
713            });
714        }
715    }
716
717    /// One Sutherland-Hodgman pass against a single clip-space plane.
718    ///
719    /// `dist(v) >= 0.0` means the vertex is on the inside of the plane.
720    /// Returns the number of vertices written into `output`.
721    fn clip_polygon_plane(
722        input: &[Vector4<f32>],
723        output: &mut [Vector4<f32>; 8],
724        dist: impl Fn(Vector4<f32>) -> f32,
725    ) -> usize {
726        let n = input.len();
727        let mut m = 0usize;
728        for i in 0..n {
729            let prev = input[(n + i - 1) % n];
730            let curr = input[i];
731            let d_prev = dist(prev);
732            let d_curr = dist(curr);
733            if d_curr >= 0.0 {
734                if d_prev < 0.0 {
735                    // Crossing from outside → inside: emit the boundary vertex.
736                    let t = d_prev / (d_prev - d_curr);
737                    if m < 8 {
738                        output[m] = prev + (curr - prev) * t;
739                        m += 1;
740                    }
741                }
742                if m < 8 {
743                    output[m] = curr;
744                    m += 1;
745                }
746            } else if d_prev >= 0.0 {
747                // Crossing from inside → outside: emit the boundary vertex.
748                let t = d_prev / (d_prev - d_curr);
749                if m < 8 {
750                    output[m] = prev + (curr - prev) * t;
751                    m += 1;
752                }
753            }
754        }
755        m
756    }
757
758    /// Clip a triangle against all 5 frustum planes and emit the resulting
759    /// fan of screen-space triangles.
760    ///
761    /// Uses a full Sutherland-Hodgman pass in clip space (near, left, right,
762    /// bottom, top).  Clipping against only the near plane left NDC x/y values
763    /// like ±13 for near-clipped vertices touching a wall at a grazing angle,
764    /// causing the projected triangle to cover only a sliver instead of the
765    /// full wall — producing the black triangular holes.
766    fn emit_clipped<F>(&self, clip: [Vector4<f32>; 3], color: Rgb565, callback: &mut F)
767    where
768        F: FnMut(DrawPrimitive),
769    {
770        let nw = self.camera.near;
771
772        let mut a = [Vector4::zeros(); 8];
773        let mut b = [Vector4::zeros(); 8];
774        a[0] = clip[0];
775        a[1] = clip[1];
776        a[2] = clip[2];
777
778        // near:   w >= nw
779        let n = Self::clip_polygon_plane(&a[..3], &mut b, |v| v.w - nw);
780        if n < 3 {
781            return;
782        }
783        // left:   x >= -w  →  x + w >= 0
784        let n = Self::clip_polygon_plane(&b[..n], &mut a, |v| v.x + v.w);
785        if n < 3 {
786            return;
787        }
788        // right:  x <=  w  →  w - x >= 0
789        let n = Self::clip_polygon_plane(&a[..n], &mut b, |v| v.w - v.x);
790        if n < 3 {
791            return;
792        }
793        // bottom: y >= -w  →  y + w >= 0
794        let n = Self::clip_polygon_plane(&b[..n], &mut a, |v| v.y + v.w);
795        if n < 3 {
796            return;
797        }
798        // top:    y <=  w  →  w - y >= 0
799        let n = Self::clip_polygon_plane(&a[..n], &mut b, |v| v.w - v.y);
800        if n < 3 {
801            return;
802        }
803
804        // Triangulate the clipped polygon as a fan from vertex 0.
805        for i in 1..n - 1 {
806            self.project_and_emit(b[0], b[i], b[i + 1], color, callback);
807        }
808    }
809
810    fn render<'a, MS, F>(&self, meshes: MS, mut callback: F)
811    where
812        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
813        F: FnMut(DrawPrimitive),
814    {
815        for mesh in meshes {
816            if mesh.geometry.vertices.is_empty() {
817                continue;
818            }
819
820            // Frustum culling: Skip meshes that are completely outside the view frustum
821            // This can improve performance by 50-90% by avoiding transformation and rendering
822            // of off-screen objects
823            if self.should_cull_mesh(mesh) {
824                continue;
825            }
826
827            // LOD Selection: Choose geometry based on distance from camera
828            let mesh_pos = mesh.get_position();
829            let distance = (mesh_pos - self.camera.position).norm();
830            let geometry = mesh.select_lod(distance);
831
832            let transform_matrix = self.camera.vp_matrix * mesh.model_matrix;
833
834            let mut v_cache_plain: [Option<Option<Point3<i32>>>; 256] = [None; 256];
835            let mut v_cache_w: [Option<Option<(Point3<i32>, f32)>>; 256] = [None; 256];
836
837            let mut get_pt = |idx: usize| -> Option<Point3<i32>> {
838                if idx < 256 {
839                    if let Some(cached) = v_cache_plain[idx] {
840                        return cached;
841                    }
842                    let p = self.transform_point(&geometry.vertices[idx], transform_matrix);
843                    v_cache_plain[idx] = Some(p);
844                    p
845                } else {
846                    self.transform_point(&geometry.vertices[idx], transform_matrix)
847                }
848            };
849
850            let mut get_pt_w = |idx: usize| -> Option<(Point3<i32>, f32)> {
851                if idx < 256 {
852                    if let Some(cached) = v_cache_w[idx] {
853                        return cached;
854                    }
855                    let p = self.transform_point_with_w(&geometry.vertices[idx], transform_matrix);
856                    v_cache_w[idx] = Some(p);
857                    p
858                } else {
859                    self.transform_point_with_w(&geometry.vertices[idx], transform_matrix)
860                }
861            };
862
863            let mut tf_face = |face: &[usize; 3]| -> Option<[Point3<i32>; 3]> {
864                Some([get_pt(face[0])?, get_pt(face[1])?, get_pt(face[2])?])
865            };
866
867            let mut tf_face_w = |face: &[usize; 3]| -> Option<([Point3<i32>; 3], [f32; 3])> {
868                let (p0, w0) = get_pt_w(face[0])?;
869                let (p1, w1) = get_pt_w(face[1])?;
870                let (p2, w2) = get_pt_w(face[2])?;
871                Some(([p0, p1, p2], [w0, w1, w2]))
872            };
873
874            let render_mode = self.resolve_render_mode(&mesh.render_mode);
875            match render_mode {
876                RenderMode::Points => {
877                    let screen_space_points = (0..geometry.vertices.len()).filter_map(&mut get_pt);
878
879                    if geometry.colors.len() == geometry.vertices.len() {
880                        for (point, color) in screen_space_points.zip(geometry.colors) {
881                            callback(DrawPrimitive::ColoredPoint(point.xy(), *color));
882                        }
883                    } else {
884                        for point in screen_space_points {
885                            callback(DrawPrimitive::ColoredPoint(point.xy(), mesh.color));
886                        }
887                    }
888                }
889
890                RenderMode::Lines if !geometry.lines.is_empty() => {
891                    for line in geometry.lines {
892                        if let (Some(p1), Some(p2)) = (get_pt(line[0]), get_pt(line[1])) {
893                            callback(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
894                        }
895                    }
896                }
897
898                RenderMode::Lines if !geometry.faces.is_empty() => {
899                    for face in geometry.faces {
900                        if let Some([p1, p2, p3]) = tf_face(face) {
901                            callback(DrawPrimitive::Line([p1.xy(), p2.xy()], mesh.color));
902                            callback(DrawPrimitive::Line([p2.xy(), p3.xy()], mesh.color));
903                            callback(DrawPrimitive::Line([p3.xy(), p1.xy()], mesh.color));
904                        }
905                    }
906                }
907
908                RenderMode::Lines => {}
909
910                RenderMode::SolidLightDir(direction) => {
911                    let color_as_float = Vector3::new(
912                        mesh.color.r() as f32 / 32.0,
913                        mesh.color.g() as f32 / 64.0,
914                        mesh.color.b() as f32 / 32.0,
915                    );
916                    let ambient_color = color_as_float * 0.1;
917                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
918
919                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
920                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
921                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
922                        if self.is_backface(
923                            face,
924                            geometry.vertices,
925                            mesh.model_matrix,
926                            &transformed_normal,
927                        ) {
928                            continue;
929                        }
930
931                        if let Some([p1, p2, p3]) = tf_face(face) {
932                            let intensity = transformed_normal.dot(&adjusted_dir).max(0.0);
933                            let final_color = color_as_float * intensity + ambient_color;
934                            let final_color = Vector3::new(
935                                final_color.x.clamp(0.0, 1.0),
936                                final_color.y.clamp(0.0, 1.0),
937                                final_color.z.clamp(0.0, 1.0),
938                            );
939                            let mut color = Rgb565::new(
940                                (final_color.x * 31.0) as u8,
941                                (final_color.y * 63.0) as u8,
942                                (final_color.z * 31.0) as u8,
943                            );
944                            if !self.point_lights.is_empty() {
945                                let wc = Self::face_world_center(
946                                    face,
947                                    geometry.vertices,
948                                    mesh.model_matrix,
949                                );
950                                color = Self::add_tint(color, self.light_tint_at(wc));
951                            }
952                            callback(DrawPrimitive::ColoredTriangleWithDepth {
953                                points: [p1.xy(), p2.xy(), p3.xy()],
954                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
955                                color,
956                            });
957                        }
958                    }
959                }
960
961                RenderMode::GouraudLightDir(direction) => {
962                    let color_as_float = Vector3::new(
963                        mesh.color.r() as f32 / 32.0,
964                        mesh.color.g() as f32 / 64.0,
965                        mesh.color.b() as f32 / 32.0,
966                    );
967                    let ambient_color = color_as_float * 0.1;
968                    let adjusted_dir = Vector3::new(direction.x, direction.y, -direction.z);
969
970                    for (face, face_normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
971                        let fn_vec = Vector3::new(face_normal[0], face_normal[1], face_normal[2]);
972                        let transformed_fn = mesh.model_matrix.transform_vector(&fn_vec);
973
974                        if self.is_backface(
975                            face,
976                            geometry.vertices,
977                            mesh.model_matrix,
978                            &transformed_fn,
979                        ) {
980                            continue;
981                        }
982
983                        if let Some([p1, p2, p3]) = tf_face(face) {
984                            let vertex_colors: [Rgb565; 3] = core::array::from_fn(|k| {
985                                let vn = if !geometry.vertex_normals.is_empty() {
986                                    let vn_arr = geometry.vertex_normals[face[k]];
987                                    let vn_vec = Vector3::new(vn_arr[0], vn_arr[1], vn_arr[2]);
988                                    mesh.model_matrix.transform_vector(&vn_vec)
989                                } else {
990                                    transformed_fn
991                                };
992
993                                let intensity = vn.dot(&adjusted_dir).max(0.0);
994                                let c = color_as_float * intensity + ambient_color;
995                                let mut vc = Rgb565::new(
996                                    (c.x.clamp(0.0, 1.0) * 31.0) as u8,
997                                    (c.y.clamp(0.0, 1.0) * 63.0) as u8,
998                                    (c.z.clamp(0.0, 1.0) * 31.0) as u8,
999                                );
1000                                if !self.point_lights.is_empty() {
1001                                    let vpos = geometry.vertices[face[k]];
1002                                    let wp = mesh
1003                                        .model_matrix
1004                                        .transform_point(&Point3::new(vpos[0], vpos[1], vpos[2]));
1005                                    vc = Self::add_tint(vc, self.light_tint_at(wp));
1006                                }
1007                                vc
1008                            });
1009
1010                            callback(DrawPrimitive::GouraudTriangleWithDepth {
1011                                points: [p1.xy(), p2.xy(), p3.xy()],
1012                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1013                                colors: vertex_colors,
1014                            });
1015                        }
1016                    }
1017                }
1018
1019                RenderMode::BlinnPhong {
1020                    light_dir,
1021                    specular_intensity,
1022                    shininess,
1023                } => {
1024                    // Pre-compute lighting constants (once per mesh, not per face)
1025                    let color_as_float = Vector3::new(
1026                        mesh.color.r() as f32 / 32.0,
1027                        mesh.color.g() as f32 / 64.0,
1028                        mesh.color.b() as f32 / 32.0,
1029                    );
1030
1031                    // Pre-compute ambient lighting term
1032                    let ambient_color = color_as_float * 0.1;
1033
1034                    // Pre-compute adjusted light direction
1035                    // Negate only Z component of direction to fix front/back while keeping left/right
1036                    let adjusted_light_dir = Vector3::new(light_dir.x, light_dir.y, -light_dir.z);
1037
1038                    // Normalize light direction
1039                    let light_dir_normalized = adjusted_light_dir.normalize();
1040
1041                    for (face, normal) in geometry.faces.iter().zip(geometry.normals.iter()) {
1042                        //Backface culling
1043                        let normal = Vector3::new(normal[0], normal[1], normal[2]);
1044                        let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1045                        let normalized_normal = transformed_normal.normalize();
1046
1047                        // Backface culling: cull faces pointing away from camera
1048                        if self.is_backface(
1049                            face,
1050                            geometry.vertices,
1051                            mesh.model_matrix,
1052                            &normalized_normal,
1053                        ) {
1054                            continue;
1055                        }
1056
1057                        if let Some([p1, p2, p3]) = tf_face(face) {
1058                            // Calculate face center in world space for view direction
1059                            let v0 = geometry.vertices[face[0]];
1060                            let v1 = geometry.vertices[face[1]];
1061                            let v2 = geometry.vertices[face[2]];
1062                            let face_center = Point3::new(
1063                                (v0[0] + v1[0] + v2[0]) / 3.0,
1064                                (v0[1] + v1[1] + v2[1]) / 3.0,
1065                                (v0[2] + v1[2] + v2[2]) / 3.0,
1066                            );
1067                            let face_center_world = mesh.model_matrix.transform_point(&face_center);
1068
1069                            // View direction: from face to camera
1070                            let view_dir = (self.camera.position - face_center_world).normalize();
1071
1072                            // Blinn-Phong half vector: H = normalize(L + V)
1073                            let half_vector = (light_dir_normalized + view_dir).normalize();
1074
1075                            // Diffuse term: N·L
1076                            let diffuse_intensity =
1077                                normalized_normal.dot(&light_dir_normalized).max(0.0);
1078
1079                            // Specular term: (N·H)^shininess
1080                            let specular_term =
1081                                normalized_normal.dot(&half_vector).max(0.0).powf(shininess);
1082
1083                            // Compute final color: ambient + diffuse + specular
1084                            let diffuse_color = color_as_float * diffuse_intensity;
1085                            let specular_color =
1086                                Vector3::new(1.0, 1.0, 1.0) * specular_term * specular_intensity;
1087                            let final_color = ambient_color + diffuse_color + specular_color;
1088
1089                            let final_color = Vector3::new(
1090                                final_color.x.clamp(0.0, 1.0),
1091                                final_color.y.clamp(0.0, 1.0),
1092                                final_color.z.clamp(0.0, 1.0),
1093                            );
1094
1095                            let mut color = Rgb565::new(
1096                                (final_color.x * 31.0) as u8,
1097                                (final_color.y * 63.0) as u8,
1098                                (final_color.z * 31.0) as u8,
1099                            );
1100                            if !self.point_lights.is_empty() {
1101                                color =
1102                                    Self::add_tint(color, self.light_tint_at(face_center_world));
1103                            }
1104                            callback(DrawPrimitive::ColoredTriangleWithDepth {
1105                                points: [p1.xy(), p2.xy(), p3.xy()],
1106                                depths: [p1.z as f32, p2.z as f32, p3.z as f32],
1107                                color,
1108                            });
1109                        }
1110                    }
1111                }
1112
1113                RenderMode::Solid => {
1114                    if geometry.normals.is_empty() {
1115                        for face in geometry.faces.iter() {
1116                            let color = if !self.point_lights.is_empty() {
1117                                let wc = Self::face_world_center(
1118                                    face,
1119                                    geometry.vertices,
1120                                    mesh.model_matrix,
1121                                );
1122                                Self::add_tint(mesh.color, self.light_tint_at(wc))
1123                            } else {
1124                                mesh.color
1125                            };
1126                            let v = &geometry.vertices;
1127                            let clip = [
1128                                transform_matrix
1129                                    * Vector4::new(
1130                                        v[face[0]][0],
1131                                        v[face[0]][1],
1132                                        v[face[0]][2],
1133                                        1.0,
1134                                    ),
1135                                transform_matrix
1136                                    * Vector4::new(
1137                                        v[face[1]][0],
1138                                        v[face[1]][1],
1139                                        v[face[1]][2],
1140                                        1.0,
1141                                    ),
1142                                transform_matrix
1143                                    * Vector4::new(
1144                                        v[face[2]][0],
1145                                        v[face[2]][1],
1146                                        v[face[2]][2],
1147                                        1.0,
1148                                    ),
1149                            ];
1150                            self.emit_clipped(clip, color, &mut callback);
1151                        }
1152                    } else {
1153                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1154                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1155                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1156                            if self.is_backface(
1157                                face,
1158                                geometry.vertices,
1159                                mesh.model_matrix,
1160                                &transformed_normal,
1161                            ) {
1162                                continue;
1163                            }
1164                            let color = if !self.point_lights.is_empty() {
1165                                let wc = Self::face_world_center(
1166                                    face,
1167                                    geometry.vertices,
1168                                    mesh.model_matrix,
1169                                );
1170                                Self::add_tint(mesh.color, self.light_tint_at(wc))
1171                            } else {
1172                                mesh.color
1173                            };
1174                            let v = &geometry.vertices;
1175                            let clip = [
1176                                transform_matrix
1177                                    * Vector4::new(
1178                                        v[face[0]][0],
1179                                        v[face[0]][1],
1180                                        v[face[0]][2],
1181                                        1.0,
1182                                    ),
1183                                transform_matrix
1184                                    * Vector4::new(
1185                                        v[face[1]][0],
1186                                        v[face[1]][1],
1187                                        v[face[1]][2],
1188                                        1.0,
1189                                    ),
1190                                transform_matrix
1191                                    * Vector4::new(
1192                                        v[face[2]][0],
1193                                        v[face[2]][1],
1194                                        v[face[2]][2],
1195                                        1.0,
1196                                    ),
1197                            ];
1198                            self.emit_clipped(clip, color, &mut callback);
1199                        }
1200                    }
1201                }
1202
1203                RenderMode::SectorBright(brightness) => {
1204                    if geometry.normals.is_empty() {
1205                        for face in geometry.faces.iter() {
1206                            let wc =
1207                                Self::face_world_center(face, geometry.vertices, mesh.model_matrix);
1208                            let mut color = self.sector_shaded_color(mesh.color, brightness, wc);
1209                            if !self.point_lights.is_empty() {
1210                                color = Self::add_tint(color, self.light_tint_at(wc));
1211                            }
1212                            let v = &geometry.vertices;
1213                            let clip = [
1214                                transform_matrix
1215                                    * Vector4::new(
1216                                        v[face[0]][0],
1217                                        v[face[0]][1],
1218                                        v[face[0]][2],
1219                                        1.0,
1220                                    ),
1221                                transform_matrix
1222                                    * Vector4::new(
1223                                        v[face[1]][0],
1224                                        v[face[1]][1],
1225                                        v[face[1]][2],
1226                                        1.0,
1227                                    ),
1228                                transform_matrix
1229                                    * Vector4::new(
1230                                        v[face[2]][0],
1231                                        v[face[2]][1],
1232                                        v[face[2]][2],
1233                                        1.0,
1234                                    ),
1235                            ];
1236                            self.emit_clipped(clip, color, &mut callback);
1237                        }
1238                    } else {
1239                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1240                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1241                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1242                            if self.is_backface(
1243                                face,
1244                                geometry.vertices,
1245                                mesh.model_matrix,
1246                                &transformed_normal,
1247                            ) {
1248                                continue;
1249                            }
1250                            let wc =
1251                                Self::face_world_center(face, geometry.vertices, mesh.model_matrix);
1252                            let mut color = self.sector_shaded_color(mesh.color, brightness, wc);
1253                            if !self.point_lights.is_empty() {
1254                                color = Self::add_tint(color, self.light_tint_at(wc));
1255                            }
1256                            let v = &geometry.vertices;
1257                            let clip = [
1258                                transform_matrix
1259                                    * Vector4::new(
1260                                        v[face[0]][0],
1261                                        v[face[0]][1],
1262                                        v[face[0]][2],
1263                                        1.0,
1264                                    ),
1265                                transform_matrix
1266                                    * Vector4::new(
1267                                        v[face[1]][0],
1268                                        v[face[1]][1],
1269                                        v[face[1]][2],
1270                                        1.0,
1271                                    ),
1272                                transform_matrix
1273                                    * Vector4::new(
1274                                        v[face[2]][0],
1275                                        v[face[2]][1],
1276                                        v[face[2]][2],
1277                                        1.0,
1278                                    ),
1279                            ];
1280                            self.emit_clipped(clip, color, &mut callback);
1281                        }
1282                    }
1283                }
1284
1285                RenderMode::Textured => {
1286                    // Requires both a texture and per-vertex UVs; silently
1287                    // skip the mesh (move to the next one) if either is
1288                    // missing, same graceful-degradation style as `Lines`
1289                    // with no `lines`/`faces` data.
1290                    let Some(texture_id) = geometry.texture_id else {
1291                        continue;
1292                    };
1293                    if geometry.uvs.is_empty() {
1294                        continue;
1295                    }
1296
1297                    // Unlike `Solid`, this doesn't route through
1298                    // `emit_clipped` (which only carries a flat `Rgb565`,
1299                    // not per-vertex UVs) -- a face with any vertex behind
1300                    // the near plane or outside the frustum is dropped
1301                    // whole, same as `GouraudLightDir`/`BlinnPhong`.
1302                    if geometry.normals.is_empty() {
1303                        for face in geometry.faces.iter() {
1304                            if let Some((points, ws)) = tf_face_w(face) {
1305                                callback(DrawPrimitive::TexturedTriangleWithDepth {
1306                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1307                                    depths: [
1308                                        points[0].z as f32,
1309                                        points[1].z as f32,
1310                                        points[2].z as f32,
1311                                    ],
1312                                    ws,
1313                                    uvs: [
1314                                        geometry.uvs[face[0]],
1315                                        geometry.uvs[face[1]],
1316                                        geometry.uvs[face[2]],
1317                                    ],
1318                                    texture_id,
1319                                });
1320                            }
1321                        }
1322                    } else {
1323                        for (face, normal) in geometry.faces.iter().zip(geometry.normals) {
1324                            let normal = Vector3::new(normal[0], normal[1], normal[2]);
1325                            let transformed_normal = mesh.model_matrix.transform_vector(&normal);
1326                            if self.is_backface(
1327                                face,
1328                                geometry.vertices,
1329                                mesh.model_matrix,
1330                                &transformed_normal,
1331                            ) {
1332                                continue;
1333                            }
1334                            if let Some((points, ws)) = tf_face_w(face) {
1335                                callback(DrawPrimitive::TexturedTriangleWithDepth {
1336                                    points: [points[0].xy(), points[1].xy(), points[2].xy()],
1337                                    depths: [
1338                                        points[0].z as f32,
1339                                        points[1].z as f32,
1340                                        points[2].z as f32,
1341                                    ],
1342                                    ws,
1343                                    uvs: [
1344                                        geometry.uvs[face[0]],
1345                                        geometry.uvs[face[1]],
1346                                        geometry.uvs[face[2]],
1347                                    ],
1348                                    texture_id,
1349                                });
1350                            }
1351                        }
1352                    }
1353                }
1354            }
1355        }
1356    }
1357
1358    pub fn record<'a, MS, const MAX: usize>(
1359        &self,
1360        meshes: MS,
1361        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1362        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1363    ) -> Result<(), crate::error::RenderError>
1364    where
1365        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1366    {
1367        self.record_impl(meshes, commands, telemetry)
1368    }
1369
1370    fn record_impl<'a, MS, const MAX: usize>(
1371        &self,
1372        meshes: MS,
1373        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1374        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1375    ) -> Result<(), crate::error::RenderError>
1376    where
1377        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1378    {
1379        use crate::command_buffer::RenderCommand;
1380        use crate::error::{BudgetKind, RenderError};
1381
1382        commands.clear();
1383        commands.push(RenderCommand::ClearDepth(u32::MAX))?;
1384        if let Some(caps) = self.caps {
1385            caps.validate_framebuffer(self.width as usize, self.height as usize)?;
1386        }
1387
1388        let mut first_error = None;
1389        let mut visible_meshes = 0usize;
1390        let mut used_texture_ids: heapless::Vec<u32, 64> = heapless::Vec::new();
1391        let mut meshes_total = 0usize;
1392
1393        for mesh in meshes {
1394            meshes_total += 1;
1395            if mesh.geometry.vertices.is_empty() {
1396                continue;
1397            }
1398            if self.should_cull_mesh(mesh) {
1399                continue;
1400            }
1401
1402            let distance = (mesh.get_position() - self.camera.position).norm();
1403            let geometry = mesh.select_lod(distance);
1404
1405            if let Some(caps) = self.caps {
1406                visible_meshes += 1;
1407                if visible_meshes > caps.max_meshes_per_frame {
1408                    return Err(RenderError::OutOfBudget(BudgetKind::MeshesPerFrame {
1409                        attempted: visible_meshes,
1410                        max: caps.max_meshes_per_frame,
1411                    }));
1412                }
1413
1414                if geometry.vertices.len() > caps.max_vertices_per_mesh {
1415                    return Err(RenderError::OutOfBudget(BudgetKind::VerticesPerMesh {
1416                        attempted: geometry.vertices.len(),
1417                        max: caps.max_vertices_per_mesh,
1418                    }));
1419                }
1420
1421                if geometry.faces.len() > caps.max_triangles_per_mesh {
1422                    return Err(RenderError::OutOfBudget(BudgetKind::TrianglesPerMesh {
1423                        attempted: geometry.faces.len(),
1424                        max: caps.max_triangles_per_mesh,
1425                    }));
1426                }
1427
1428                if let Some(texture_id) = geometry.texture_id
1429                    && !used_texture_ids.iter().any(|id| *id == texture_id)
1430                {
1431                    let attempted = used_texture_ids.len() + 1;
1432                    if attempted > caps.max_textures {
1433                        return Err(RenderError::OutOfBudget(BudgetKind::Textures {
1434                            attempted,
1435                            max: caps.max_textures,
1436                        }));
1437                    }
1438
1439                    if used_texture_ids.push(texture_id).is_err() {
1440                        return Err(RenderError::OutOfBudget(BudgetKind::Textures {
1441                            attempted,
1442                            max: caps.max_textures,
1443                        }));
1444                    }
1445                }
1446            }
1447
1448            self.render(core::iter::once(mesh), |primitive| {
1449                if first_error.is_none()
1450                    && let Err(e) = commands.push(RenderCommand::Draw(primitive))
1451                {
1452                    first_error = Some(e);
1453                }
1454            });
1455            if let Some(err) = first_error {
1456                return Err(err);
1457            }
1458        }
1459
1460        if let Some(t) = telemetry {
1461            t.meshes_total = meshes_total;
1462            t.meshes_visible = visible_meshes;
1463            t.unique_textures = used_texture_ids.len();
1464            t.draw_commands = commands
1465                .iter()
1466                .filter(|cmd| matches!(cmd, RenderCommand::Draw(_)))
1467                .count();
1468            t.fallback_used = false;
1469            t.degradation_steps_applied = 0;
1470            t.dropped_meshes = 0;
1471        }
1472
1473        Ok(())
1474    }
1475
1476    pub fn record_with_fallback<'a, MS, FS, const MAX: usize>(
1477        &self,
1478        primary: MS,
1479        fallback: FS,
1480        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1481        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1482    ) -> Result<BudgetFallbackOutcome, crate::error::RenderError>
1483    where
1484        MS: IntoIterator<Item = &'a K3dMesh<'a>>,
1485        FS: IntoIterator<Item = &'a K3dMesh<'a>>,
1486    {
1487        use crate::error::RenderError;
1488
1489        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
1490        match self.record_impl(primary, commands, Some(&mut local_telemetry)) {
1491            Ok(()) => {
1492                if let Some(t) = telemetry {
1493                    *t = local_telemetry;
1494                    t.fallback_used = false;
1495                }
1496                Ok(BudgetFallbackOutcome {
1497                    used_fallback: false,
1498                    primary_budget_error: None,
1499                })
1500            }
1501            Err(RenderError::OutOfBudget(kind)) => {
1502                let mut fallback_telemetry = crate::telemetry::RecordTelemetry::default();
1503                self.record_impl(fallback, commands, Some(&mut fallback_telemetry))?;
1504                if let Some(t) = telemetry {
1505                    *t = fallback_telemetry;
1506                    t.fallback_used = true;
1507                }
1508                Ok(BudgetFallbackOutcome {
1509                    used_fallback: true,
1510                    primary_budget_error: Some(kind),
1511                })
1512            }
1513            Err(e) => Err(e),
1514        }
1515    }
1516
1517    fn downgraded_quality_tier(tier: crate::config::QualityTier) -> crate::config::QualityTier {
1518        use crate::config::QualityTier;
1519        match tier {
1520            QualityTier::Quality => QualityTier::Balanced,
1521            QualityTier::Balanced => QualityTier::Fastest,
1522            QualityTier::Fastest => QualityTier::Fastest,
1523        }
1524    }
1525
1526    pub fn record_with_degradation<'a, const MAX: usize>(
1527        &mut self,
1528        meshes: &[&'a K3dMesh<'a>],
1529        commands: &mut crate::command_buffer::CommandBuffer<MAX>,
1530        policy: crate::config::DegradationPolicy<'_>,
1531        telemetry: Option<&mut crate::telemetry::RecordTelemetry>,
1532    ) -> Result<DegradationOutcome, crate::error::RenderError> {
1533        use crate::config::DegradationStep;
1534        use crate::error::RenderError;
1535
1536        let original_quality = self.quality_tier;
1537        let mut active_quality = self.quality_tier;
1538
1539        let mut outcome = DegradationOutcome {
1540            used_degradation: false,
1541            steps_applied: 0,
1542            dropped_meshes: 0,
1543            final_quality_tier: active_quality,
1544            primary_budget_error: None,
1545        };
1546
1547        let mut local_telemetry = crate::telemetry::RecordTelemetry::default();
1548        match self.record_impl(meshes.iter().copied(), commands, Some(&mut local_telemetry)) {
1549            Ok(()) => {
1550                if let Some(t) = telemetry {
1551                    *t = local_telemetry;
1552                }
1553                return Ok(outcome);
1554            }
1555            Err(RenderError::OutOfBudget(kind)) => {
1556                outcome.primary_budget_error = Some(kind);
1557            }
1558            Err(e) => return Err(e),
1559        }
1560
1561        for step in policy.steps {
1562            outcome.used_degradation = true;
1563            outcome.steps_applied += 1;
1564
1565            let mut selected: heapless::Vec<&K3dMesh<'_>, 512> = heapless::Vec::new();
1566            match *step {
1567                DegradationStep::RaisePriorityFloor(min_priority) => {
1568                    for mesh in meshes {
1569                        if mesh.priority >= min_priority {
1570                            let _ = selected.push(*mesh);
1571                        } else {
1572                            outcome.dropped_meshes += 1;
1573                        }
1574                    }
1575                }
1576                DegradationStep::MeshDecimationStride(stride) => {
1577                    if stride == 0 {
1578                        self.quality_tier = original_quality;
1579                        return Err(RenderError::InvalidInput(
1580                            "mesh decimation stride must be >= 1",
1581                        ));
1582                    }
1583                    for (idx, mesh) in meshes.iter().enumerate() {
1584                        if idx % stride == 0 {
1585                            let _ = selected.push(*mesh);
1586                        } else {
1587                            outcome.dropped_meshes += 1;
1588                        }
1589                    }
1590                }
1591                DegradationStep::DowngradeQuality => {
1592                    active_quality = Self::downgraded_quality_tier(active_quality);
1593                    self.quality_tier = active_quality;
1594                    for mesh in meshes {
1595                        let _ = selected.push(*mesh);
1596                    }
1597                }
1598            }
1599
1600            if selected.is_empty() {
1601                continue;
1602            }
1603
1604            let mut step_telemetry = crate::telemetry::RecordTelemetry::default();
1605            let attempt = self.record_impl(
1606                selected.iter().copied(),
1607                commands,
1608                Some(&mut step_telemetry),
1609            );
1610
1611            if let Ok(()) = attempt {
1612                outcome.final_quality_tier = self.quality_tier;
1613                if let Some(t) = telemetry {
1614                    *t = step_telemetry;
1615                    t.fallback_used = true;
1616                    t.degradation_steps_applied = outcome.steps_applied;
1617                    t.dropped_meshes = outcome.dropped_meshes;
1618                }
1619                self.quality_tier = original_quality;
1620                return Ok(outcome);
1621            }
1622        }
1623
1624        self.quality_tier = original_quality;
1625        Err(crate::error::RenderError::Recoverable {
1626            fault: crate::error::RuntimeFaultKind::Budget(outcome.primary_budget_error.unwrap_or(
1627                crate::error::BudgetKind::DrawPrimitives {
1628                    attempted: commands.len(),
1629                    max: MAX,
1630                },
1631            )),
1632            action: crate::error::RecoveryAction::SkipFrame,
1633        })
1634    }
1635
1636    pub fn execute<D, const MAX: usize>(
1637        &self,
1638        fb: &mut D,
1639        frame: &mut crate::renderer::FrameCtx<'_>,
1640        commands: &crate::command_buffer::CommandBuffer<MAX>,
1641        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
1642    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
1643    where
1644        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
1645            + embedded_graphics_core::prelude::OriginDimensions,
1646        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
1647    {
1648        if let Some(t) = telemetry {
1649            t.commands_total = commands.len();
1650            t.draw_commands = commands
1651                .iter()
1652                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
1653                .count();
1654            t.clear_color_commands = commands
1655                .iter()
1656                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
1657                .count();
1658            t.clear_depth_commands = commands
1659                .iter()
1660                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
1661                .count();
1662        }
1663        let camera_dir = self.camera.get_direction();
1664        crate::renderer::execute_commands_with_dirty_region_effects(
1665            fb,
1666            frame,
1667            commands,
1668            self.fog.as_ref(),
1669            self.dither.as_ref(),
1670            self.screen_tint,
1671            self.stipple_mode,
1672            self.palette_mode,
1673            self.sky,
1674            [camera_dir.x, camera_dir.y, camera_dir.z],
1675        )
1676    }
1677
1678    /// Like [`Self::execute`], but resolves [`RenderMode::Textured`] meshes'
1679    /// `DrawPrimitive::TexturedTriangleWithDepth`/`LightmappedTriangle`
1680    /// primitives via `texture_manager` instead of silently dropping them.
1681    ///
1682    /// `record()` doesn't need a texture manager (it only transforms
1683    /// geometry into primitives, it doesn't sample pixels), so a scene
1684    /// mixing textured and flat-colored/lit meshes still goes through one
1685    /// `record()` call -- only `execute()` needs to change to
1686    /// `execute_with_textures()` once any mesh in the batch uses
1687    /// `RenderMode::Textured`.
1688    pub fn execute_with_textures<D, const MAX: usize, const N: usize>(
1689        &self,
1690        fb: &mut D,
1691        frame: &mut crate::renderer::FrameCtx<'_>,
1692        commands: &crate::command_buffer::CommandBuffer<MAX>,
1693        texture_manager: &crate::texture::TextureManager<N>,
1694        telemetry: Option<&mut crate::telemetry::ExecuteTelemetry>,
1695    ) -> Result<Option<crate::renderer::DirtyRegion>, crate::error::RenderError>
1696    where
1697        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
1698            + embedded_graphics_core::prelude::OriginDimensions,
1699        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
1700    {
1701        if let Some(t) = telemetry {
1702            t.commands_total = commands.len();
1703            t.draw_commands = commands
1704                .iter()
1705                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::Draw(_)))
1706                .count();
1707            t.clear_color_commands = commands
1708                .iter()
1709                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearColor(_)))
1710                .count();
1711            t.clear_depth_commands = commands
1712                .iter()
1713                .filter(|cmd| matches!(cmd, crate::command_buffer::RenderCommand::ClearDepth(_)))
1714                .count();
1715        }
1716        let camera_dir = self.camera.get_direction();
1717        crate::renderer::execute_commands_with_dirty_region_effects_textured(
1718            fb,
1719            frame,
1720            commands,
1721            texture_manager,
1722            self.fog.as_ref(),
1723            self.dither.as_ref(),
1724            self.screen_tint,
1725            self.stipple_mode,
1726            self.palette_mode,
1727            self.sky,
1728            [camera_dir.x, camera_dir.y, camera_dir.z],
1729        )
1730    }
1731
1732    pub fn execute_tiled<D, const MAX: usize, const BIN_CAP: usize>(
1733        &self,
1734        fb: &mut D,
1735        frame: &mut crate::renderer::FrameCtx<'_>,
1736        commands: &crate::command_buffer::CommandBuffer<MAX>,
1737        tile: crate::tilebin::TileConfig,
1738    ) -> Result<crate::tilebin::TileBinStats, crate::error::RenderError>
1739    where
1740        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>
1741            + embedded_graphics_core::prelude::OriginDimensions,
1742        <D as embedded_graphics_core::draw_target::DrawTarget>::Error: core::fmt::Debug,
1743    {
1744        let camera_dir = self.camera.get_direction();
1745        crate::renderer::execute_commands_tiled_effects::<D, MAX, BIN_CAP>(
1746            fb,
1747            frame,
1748            commands,
1749            tile,
1750            self.fog.as_ref(),
1751            self.dither.as_ref(),
1752            self.screen_tint,
1753            self.stipple_mode,
1754            self.palette_mode,
1755            self.sky,
1756            [camera_dir.x, camera_dir.y, camera_dir.z],
1757        )
1758    }
1759}
1760
1761/// Result of a ray cast against triangle geometry.
1762#[derive(Debug, Clone, Copy)]
1763pub struct MeshRayCastHit {
1764    /// Distance along the ray to the hit point
1765    pub distance: f32,
1766    /// Hit point in world space
1767    pub point: Vector3<f32>,
1768    /// Face normal (from cross product of edges, not per-vertex normals)
1769    pub normal: Vector3<f32>,
1770    /// Index of the triangle face that was hit
1771    pub face_index: usize,
1772    /// Barycentric-interpolated UV at the hit point (or [0.0, 0.0] if no UVs present)
1773    pub uv: [f32; 2],
1774}
1775
1776/// Ray-cast against triangle geometry using Möller–Trumbore intersection.
1777///
1778/// `ray_origin` and `ray_dir` are in world space. `model_matrix` transforms mesh
1779/// vertices to world space. Returns the closest hit within `max_distance`, or `None`.
1780pub fn mesh_ray_cast(
1781    ray_origin: Vector3<f32>,
1782    ray_dir: Vector3<f32>,
1783    geometry: &mesh::Geometry<'_>,
1784    model_matrix: &Matrix4<f32>,
1785    max_distance: f32,
1786) -> Option<MeshRayCastHit> {
1787    let mut nearest: Option<MeshRayCastHit> = None;
1788    let mut min_dist = max_distance;
1789
1790    for (face_index, face) in geometry.faces.iter().enumerate() {
1791        let raw_v0 = geometry.vertices[face[0]];
1792        let raw_v1 = geometry.vertices[face[1]];
1793        let raw_v2 = geometry.vertices[face[2]];
1794
1795        // Transform vertices to world space
1796        let v0 = model_matrix
1797            .transform_point(&Point3::new(raw_v0[0], raw_v0[1], raw_v0[2]))
1798            .coords;
1799        let v1 = model_matrix
1800            .transform_point(&Point3::new(raw_v1[0], raw_v1[1], raw_v1[2]))
1801            .coords;
1802        let v2 = model_matrix
1803            .transform_point(&Point3::new(raw_v2[0], raw_v2[1], raw_v2[2]))
1804            .coords;
1805
1806        // Möller–Trumbore
1807        let edge1 = v1 - v0;
1808        let edge2 = v2 - v0;
1809        let h = ray_dir.cross(&edge2);
1810        let det = edge1.dot(&h);
1811
1812        // Parallel ray: skip
1813        if det.abs() < 1e-6 {
1814            continue;
1815        }
1816
1817        let inv_det = 1.0 / det;
1818        let s = ray_origin - v0;
1819        let bary_u = inv_det * s.dot(&h);
1820        if bary_u < 0.0 || bary_u > 1.0 {
1821            continue;
1822        }
1823
1824        let q = s.cross(&edge1);
1825        let bary_v = inv_det * ray_dir.dot(&q);
1826        if bary_v < 0.0 || bary_u + bary_v > 1.0 {
1827            continue;
1828        }
1829
1830        let t = inv_det * edge2.dot(&q);
1831        if t <= 0.0 || t >= min_dist {
1832            continue;
1833        }
1834
1835        // Face normal from edge cross product
1836        let normal = edge1.cross(&edge2).normalize();
1837
1838        // Barycentric weights: w0 = 1 - u - v, w1 = u, w2 = v
1839        let bary_w = 1.0 - bary_u - bary_v;
1840
1841        // Interpolate UV if available
1842        let uv = if geometry.uvs.len() > face[0]
1843            && geometry.uvs.len() > face[1]
1844            && geometry.uvs.len() > face[2]
1845        {
1846            let uv0 = geometry.uvs[face[0]];
1847            let uv1 = geometry.uvs[face[1]];
1848            let uv2 = geometry.uvs[face[2]];
1849            [
1850                bary_w * uv0[0] + bary_u * uv1[0] + bary_v * uv2[0],
1851                bary_w * uv0[1] + bary_u * uv1[1] + bary_v * uv2[1],
1852            ]
1853        } else {
1854            [0.0, 0.0]
1855        };
1856
1857        let point = ray_origin + ray_dir * t;
1858        min_dist = t;
1859        nearest = Some(MeshRayCastHit {
1860            distance: t,
1861            point,
1862            normal,
1863            face_index,
1864            uv,
1865        });
1866    }
1867
1868    nearest
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873    extern crate std;
1874    use super::*;
1875
1876    #[test]
1877    fn test_engine_creation() {
1878        let engine = K3dengine::new(640, 480);
1879        assert_eq!(engine.width, 640);
1880        assert_eq!(engine.height, 480);
1881        assert!((engine.camera.get_aspect_ratio() - 640.0 / 480.0).abs() < 0.001);
1882    }
1883
1884    #[test]
1885    fn test_transform_point_basic() {
1886        let engine = K3dengine::new(640, 480);
1887        // Use camera's VP matrix directly
1888        let transform_matrix = engine.camera.vp_matrix;
1889
1890        // Point in front of default camera, within view frustum
1891        // Default camera is at origin looking at origin, so we need a point in front
1892        let point = [0.0, 0.0, -5.0];
1893        let result = engine.transform_point(&point, transform_matrix);
1894
1895        if let Some(transformed) = result {
1896            // Should be within screen bounds
1897            assert!(transformed.x >= 0 && transformed.x < 640);
1898            assert!(transformed.y >= 0 && transformed.y < 480);
1899        }
1900        // If None, the point was culled which is also valid behavior
1901    }
1902
1903    #[test]
1904    fn test_transform_point_clamps_out_of_bounds() {
1905        let engine = K3dengine::new(640, 480);
1906        let model_matrix = nalgebra::Matrix4::identity();
1907
1908        // Point way outside the viewport should be clamped/rejected
1909        let point = [100.0, 100.0, -5.0];
1910        let result = engine.transform_point(&point, model_matrix);
1911        // Should return None because coordinates are clamped out
1912        assert!(result.is_none());
1913    }
1914
1915    #[test]
1916    fn test_transform_point_behind_camera() {
1917        let engine = K3dengine::new(640, 480);
1918        let transform_matrix = engine.camera.vp_matrix;
1919
1920        // Point with positive z (behind default camera orientation)
1921        let point = [0.0, 0.0, 1.0];
1922        let _result = engine.transform_point(&point, transform_matrix);
1923        // Point behind camera or outside frustum should return None
1924        // (actual behavior depends on camera setup and projection)
1925        // This test just verifies the function doesn't panic
1926    }
1927
1928    #[test]
1929    fn test_transform_point_near_plane_clipping() {
1930        let engine = K3dengine::new(640, 480);
1931        // Use the camera's real projection, not a raw identity matrix: the
1932        // near/far check now tests clip-space W (view depth), which is
1933        // only meaningful under an actual perspective projection -- an
1934        // identity "model_matrix" leaves W pinned at the homogeneous 1.0
1935        // regardless of the point's z, so it can't exercise this check.
1936        let transform_matrix = engine.camera.vp_matrix;
1937
1938        // Point too close to camera: distance 0.1, before the near=0.4 plane.
1939        let point = [0.0, 0.0, -0.1];
1940        let result = engine.transform_point(&point, transform_matrix);
1941        assert!(result.is_none());
1942    }
1943
1944    #[test]
1945    fn test_transform_point_far_plane_clipping() {
1946        let engine = K3dengine::new(640, 480);
1947        let transform_matrix = engine.camera.vp_matrix;
1948
1949        // Point too far from camera: distance 1000, beyond the far=20 plane.
1950        let point = [0.0, 0.0, -1000.0];
1951        let result = engine.transform_point(&point, transform_matrix);
1952        assert!(result.is_none());
1953    }
1954
1955    #[test]
1956    fn test_transform_point_within_near_far_not_culled() {
1957        // Regression test: `transform_point`'s near/far check used to
1958        // compare pre-divide clip.z against camera.near/far, which for the
1959        // default near=0.4/far=20 camera silently culled anything closer
1960        // than roughly 1.17 units -- even though it's well within
1961        // [near, far]. z=-0.8 falls in that dead zone.
1962        let engine = K3dengine::new(640, 480);
1963        let transform_matrix = engine.camera.vp_matrix;
1964
1965        let point = [0.0, 0.0, -0.8];
1966        let result = engine.transform_point(&point, transform_matrix);
1967        assert!(
1968            result.is_some(),
1969            "a point at distance 0.8 (within [near=0.4, far=20]) should not be culled"
1970        );
1971    }
1972
1973    #[test]
1974    fn test_transform_points_array() {
1975        let engine = K3dengine::new(640, 480);
1976        let transform_matrix = engine.camera.vp_matrix;
1977
1978        let vertices = [[0.0, 0.0, -5.0], [0.1, 0.0, -5.0], [0.0, 0.1, -5.0]];
1979        let indices = [0, 1, 2];
1980
1981        let result = engine.transform_points(&indices, &vertices, transform_matrix);
1982
1983        // If transform succeeds, verify we get 3 points
1984        if let Some(points) = result {
1985            assert_eq!(points.len(), 3);
1986        }
1987        // If None, one or more points were culled which is valid
1988    }
1989
1990    #[test]
1991    fn test_render_empty_faces_mesh() {
1992        let engine = K3dengine::new(640, 480);
1993        let vertices = [[0.0, 0.0, -5.0]]; // At least one vertex required
1994        let geometry = mesh::Geometry {
1995            vertices: &vertices,
1996            faces: &[],
1997            colors: &[],
1998            lines: &[],
1999            normals: &[],
2000            vertex_normals: &[],
2001            uvs: &[],
2002            texture_id: None,
2003        };
2004        let mesh = mesh::K3dMesh::new(geometry);
2005
2006        let mut callback_count = 0;
2007        engine.render(std::iter::once(&mesh), |_| {
2008            callback_count += 1;
2009        });
2010
2011        // Mesh with no faces/lines should trigger one point callback (default is Points mode)
2012        assert!(callback_count > 0);
2013    }
2014
2015    #[test]
2016    fn test_render_points_mode() {
2017        let engine = K3dengine::new(640, 480);
2018
2019        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0]];
2020
2021        let geometry = mesh::Geometry {
2022            vertices: &vertices,
2023            faces: &[],
2024            colors: &[],
2025            lines: &[],
2026            normals: &[],
2027            vertex_normals: &[],
2028            uvs: &[],
2029            texture_id: None,
2030        };
2031
2032        let mut mesh = mesh::K3dMesh::new(geometry);
2033        mesh.set_render_mode(mesh::RenderMode::Points);
2034
2035        let mut primitives = std::vec::Vec::new();
2036        engine.render(std::iter::once(&mesh), |prim| {
2037            primitives.push(prim);
2038        });
2039
2040        // Should render points
2041        assert!(primitives.len() > 0);
2042        for prim in primitives {
2043            assert!(matches!(prim, DrawPrimitive::ColoredPoint(_, _)));
2044        }
2045    }
2046
2047    #[test]
2048    fn test_render_lines_mode_with_faces() {
2049        let engine = K3dengine::new(640, 480);
2050
2051        let vertices = [[0.0, 0.0, -5.0], [0.5, 0.0, -5.0], [0.0, 0.5, -5.0]];
2052
2053        let faces = [[0, 1, 2]];
2054
2055        let geometry = mesh::Geometry {
2056            vertices: &vertices,
2057            faces: &faces,
2058            colors: &[],
2059            lines: &[],
2060            normals: &[],
2061            vertex_normals: &[],
2062            uvs: &[],
2063            texture_id: None,
2064        };
2065
2066        let mut mesh = mesh::K3dMesh::new(geometry);
2067        mesh.set_render_mode(mesh::RenderMode::Lines);
2068
2069        let mut primitives = std::vec::Vec::new();
2070        engine.render(std::iter::once(&mesh), |prim| {
2071            primitives.push(prim);
2072        });
2073
2074        // Should render 3 lines (edges of triangle)
2075        assert_eq!(primitives.len(), 3);
2076        for prim in primitives {
2077            assert!(matches!(prim, DrawPrimitive::Line(_, _)));
2078        }
2079    }
2080
2081    #[test]
2082    fn test_render_gouraud_light_dir() {
2083        let mut engine = K3dengine::new(640, 480);
2084        engine.camera.set_position(Point3::new(0.0, 0.0, -10.0));
2085        engine.camera.set_target(Point3::new(0.0, 0.0, 0.0));
2086
2087        let vertices = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
2088        let faces = [[0, 1, 2]];
2089        let normals = [[0.0, 0.0, -1.0]]; // face normal pointing toward camera
2090        let vertex_normals = [[0.0, 0.0, -1.0], [0.0, 0.0, -1.0], [0.0, 0.0, -1.0]];
2091
2092        let geometry = mesh::Geometry {
2093            vertices: &vertices,
2094            faces: &faces,
2095            colors: &[],
2096            lines: &[],
2097            normals: &normals,
2098            vertex_normals: &vertex_normals,
2099            uvs: &[],
2100            texture_id: None,
2101        };
2102
2103        let mut mesh = mesh::K3dMesh::new(geometry);
2104        mesh.set_render_mode(mesh::RenderMode::GouraudLightDir(Vector3::new(
2105            0.0, 0.0, 1.0,
2106        )));
2107
2108        let mut primitives = std::vec::Vec::new();
2109        engine.render(std::iter::once(&mesh), |prim| {
2110            primitives.push(prim);
2111        });
2112
2113        // Should emit GouraudTriangleWithDepth primitives
2114        assert!(!primitives.is_empty());
2115        for prim in &primitives {
2116            assert!(matches!(
2117                prim,
2118                DrawPrimitive::GouraudTriangleWithDepth { .. }
2119            ));
2120        }
2121    }
2122
2123    /// Verify that Solid-with-normals renders an interior box correctly.
2124    ///
2125    /// Places a camera at the centre of a simple box whose face normals point
2126    /// inward (toward the camera).  The Solid render path must emit at least
2127    /// one primitive — if backface culling incorrectly fires for all faces
2128    /// this test will catch it.
2129    #[test]
2130    fn test_solid_inward_normals_interior_camera() {
2131        let mut engine = K3dengine::new(320, 240);
2132        // Camera inside the box, looking north (–Z).
2133        engine
2134            .camera
2135            .set_position(nalgebra::Point3::new(0.0, 0.0, 0.0));
2136        engine
2137            .camera
2138            .set_target(nalgebra::Point3::new(0.0, 0.0, -1.0));
2139
2140        // Single north wall: z = –2, vertices form a quad centred on the axis.
2141        // Inward normal points toward the camera = +Z.
2142        #[rustfmt::skip]
2143        let vertices: &[[f32; 3]] = &[
2144            [-1.0, -1.0, -2.0],
2145            [ 1.0, -1.0, -2.0],
2146            [ 1.0,  1.0, -2.0],
2147            [-1.0,  1.0, -2.0],
2148        ];
2149        let faces: &[[usize; 3]] = &[[0, 1, 2], [0, 2, 3]];
2150        let normals: &[[f32; 3]] = &[[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]]; // inward (+Z)
2151
2152        let geometry = mesh::Geometry {
2153            vertices,
2154            faces,
2155            normals,
2156            colors: &[],
2157            lines: &[],
2158            vertex_normals: &[],
2159            uvs: &[],
2160            texture_id: None,
2161        };
2162        let mut m = mesh::K3dMesh::new(geometry);
2163        m.set_render_mode(mesh::RenderMode::Solid);
2164
2165        let mut count = 0usize;
2166        engine.render(std::iter::once(&m), |_| count += 1);
2167
2168        assert!(
2169            count > 0,
2170            "interior Solid-with-inward-normals emitted 0 primitives — culling is wrong"
2171        );
2172    }
2173}