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