Skip to main content

proof_engine/lighting/
lights.rs

1//! Light types and light management for Proof Engine.
2//!
3//! Supports up to 64 simultaneous lights with spatial grid queries, seven distinct
4//! light types, and animated light patterns.
5
6use std::collections::HashMap;
7use std::f32::consts::PI;
8
9// ── Identifiers ─────────────────────────────────────────────────────────────
10
11/// Unique identifier for a light in the manager.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct LightId(pub u32);
14
15/// Maximum number of simultaneous lights the manager supports.
16pub const MAX_LIGHTS: usize = 64;
17
18// ── Math helpers ────────────────────────────────────────────────────────────
19
20/// A simple 3-component vector for positions, directions, and colors.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Vec3 {
23    pub x: f32,
24    pub y: f32,
25    pub z: f32,
26}
27
28impl Vec3 {
29    pub const ZERO: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 0.0 };
30    pub const ONE: Vec3 = Vec3 { x: 1.0, y: 1.0, z: 1.0 };
31    pub const UP: Vec3 = Vec3 { x: 0.0, y: 1.0, z: 0.0 };
32    pub const DOWN: Vec3 = Vec3 { x: 0.0, y: -1.0, z: 0.0 };
33    pub const FORWARD: Vec3 = Vec3 { x: 0.0, y: 0.0, z: -1.0 };
34
35    pub const fn new(x: f32, y: f32, z: f32) -> Self {
36        Self { x, y, z }
37    }
38
39    pub fn length(self) -> f32 {
40        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
41    }
42
43    pub fn length_squared(self) -> f32 {
44        self.x * self.x + self.y * self.y + self.z * self.z
45    }
46
47    pub fn normalize(self) -> Self {
48        let len = self.length();
49        if len < 1e-10 {
50            return Self::ZERO;
51        }
52        Self {
53            x: self.x / len,
54            y: self.y / len,
55            z: self.z / len,
56        }
57    }
58
59    pub fn dot(self, other: Self) -> f32 {
60        self.x * other.x + self.y * other.y + self.z * other.z
61    }
62
63    pub fn cross(self, other: Self) -> Self {
64        Self {
65            x: self.y * other.z - self.z * other.y,
66            y: self.z * other.x - self.x * other.z,
67            z: self.x * other.y - self.y * other.x,
68        }
69    }
70
71    pub fn lerp(self, other: Self, t: f32) -> Self {
72        Self {
73            x: self.x + (other.x - self.x) * t,
74            y: self.y + (other.y - self.y) * t,
75            z: self.z + (other.z - self.z) * t,
76        }
77    }
78
79    pub fn distance(self, other: Self) -> f32 {
80        let dx = self.x - other.x;
81        let dy = self.y - other.y;
82        let dz = self.z - other.z;
83        (dx * dx + dy * dy + dz * dz).sqrt()
84    }
85
86    pub fn scale(self, s: f32) -> Self {
87        Self {
88            x: self.x * s,
89            y: self.y * s,
90            z: self.z * s,
91        }
92    }
93
94    pub fn add(self, other: Self) -> Self {
95        Self {
96            x: self.x + other.x,
97            y: self.y + other.y,
98            z: self.z + other.z,
99        }
100    }
101
102    pub fn sub(self, other: Self) -> Self {
103        Self {
104            x: self.x - other.x,
105            y: self.y - other.y,
106            z: self.z - other.z,
107        }
108    }
109
110    pub fn min_components(self, other: Self) -> Self {
111        Self {
112            x: self.x.min(other.x),
113            y: self.y.min(other.y),
114            z: self.z.min(other.z),
115        }
116    }
117
118    pub fn max_components(self, other: Self) -> Self {
119        Self {
120            x: self.x.max(other.x),
121            y: self.y.max(other.y),
122            z: self.z.max(other.z),
123        }
124    }
125
126    pub fn abs(self) -> Self {
127        Self {
128            x: self.x.abs(),
129            y: self.y.abs(),
130            z: self.z.abs(),
131        }
132    }
133
134    pub fn component_mul(self, other: Self) -> Self {
135        Self {
136            x: self.x * other.x,
137            y: self.y * other.y,
138            z: self.z * other.z,
139        }
140    }
141}
142
143impl Default for Vec3 {
144    fn default() -> Self {
145        Self::ZERO
146    }
147}
148
149impl std::ops::Add for Vec3 {
150    type Output = Self;
151    fn add(self, rhs: Self) -> Self {
152        Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
153    }
154}
155
156impl std::ops::Sub for Vec3 {
157    type Output = Self;
158    fn sub(self, rhs: Self) -> Self {
159        Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
160    }
161}
162
163impl std::ops::Mul<f32> for Vec3 {
164    type Output = Self;
165    fn mul(self, rhs: f32) -> Self {
166        Self::new(self.x * rhs, self.y * rhs, self.z * rhs)
167    }
168}
169
170impl std::ops::Neg for Vec3 {
171    type Output = Self;
172    fn neg(self) -> Self {
173        Self::new(-self.x, -self.y, -self.z)
174    }
175}
176
177/// A simple 4x4 matrix stored in column-major order.
178#[derive(Debug, Clone, Copy, PartialEq)]
179pub struct Mat4 {
180    pub cols: [[f32; 4]; 4],
181}
182
183impl Mat4 {
184    pub const IDENTITY: Mat4 = Mat4 {
185        cols: [
186            [1.0, 0.0, 0.0, 0.0],
187            [0.0, 1.0, 0.0, 0.0],
188            [0.0, 0.0, 1.0, 0.0],
189            [0.0, 0.0, 0.0, 1.0],
190        ],
191    };
192
193    pub fn look_at(eye: Vec3, target: Vec3, up: Vec3) -> Self {
194        let f = (target - eye).normalize();
195        let s = f.cross(up).normalize();
196        let u = s.cross(f);
197
198        let mut m = Self::IDENTITY;
199        m.cols[0][0] = s.x;
200        m.cols[1][0] = s.y;
201        m.cols[2][0] = s.z;
202        m.cols[0][1] = u.x;
203        m.cols[1][1] = u.y;
204        m.cols[2][1] = u.z;
205        m.cols[0][2] = -f.x;
206        m.cols[1][2] = -f.y;
207        m.cols[2][2] = -f.z;
208        m.cols[3][0] = -s.dot(eye);
209        m.cols[3][1] = -u.dot(eye);
210        m.cols[3][2] = f.dot(eye);
211        m
212    }
213
214    pub fn orthographic(left: f32, right: f32, bottom: f32, top: f32, near: f32, far: f32) -> Self {
215        let mut m = Self::IDENTITY;
216        m.cols[0][0] = 2.0 / (right - left);
217        m.cols[1][1] = 2.0 / (top - bottom);
218        m.cols[2][2] = -2.0 / (far - near);
219        m.cols[3][0] = -(right + left) / (right - left);
220        m.cols[3][1] = -(top + bottom) / (top - bottom);
221        m.cols[3][2] = -(far + near) / (far - near);
222        m
223    }
224
225    pub fn perspective(fov_y: f32, aspect: f32, near: f32, far: f32) -> Self {
226        let f = 1.0 / (fov_y * 0.5).tan();
227        let mut m = Mat4 { cols: [[0.0; 4]; 4] };
228        m.cols[0][0] = f / aspect;
229        m.cols[1][1] = f;
230        m.cols[2][2] = (far + near) / (near - far);
231        m.cols[2][3] = -1.0;
232        m.cols[3][2] = (2.0 * far * near) / (near - far);
233        m
234    }
235
236    pub fn mul_mat4(self, rhs: Self) -> Self {
237        let mut result = Mat4 { cols: [[0.0; 4]; 4] };
238        for c in 0..4 {
239            for r in 0..4 {
240                let mut sum = 0.0f32;
241                for k in 0..4 {
242                    sum += self.cols[k][r] * rhs.cols[c][k];
243                }
244                result.cols[c][r] = sum;
245            }
246        }
247        result
248    }
249
250    pub fn transform_point(self, p: Vec3) -> Vec3 {
251        let w = self.cols[0][3] * p.x + self.cols[1][3] * p.y + self.cols[2][3] * p.z + self.cols[3][3];
252        let inv_w = if w.abs() > 1e-10 { 1.0 / w } else { 1.0 };
253        Vec3 {
254            x: (self.cols[0][0] * p.x + self.cols[1][0] * p.y + self.cols[2][0] * p.z + self.cols[3][0]) * inv_w,
255            y: (self.cols[0][1] * p.x + self.cols[1][1] * p.y + self.cols[2][1] * p.z + self.cols[3][1]) * inv_w,
256            z: (self.cols[0][2] * p.x + self.cols[1][2] * p.y + self.cols[2][2] * p.z + self.cols[3][2]) * inv_w,
257        }
258    }
259}
260
261// ── Color helper ────────────────────────────────────────────────────────────
262
263/// Linear HDR color.
264#[derive(Debug, Clone, Copy, PartialEq)]
265pub struct Color {
266    pub r: f32,
267    pub g: f32,
268    pub b: f32,
269}
270
271impl Color {
272    pub const WHITE: Color = Color { r: 1.0, g: 1.0, b: 1.0 };
273    pub const BLACK: Color = Color { r: 0.0, g: 0.0, b: 0.0 };
274    pub const RED: Color = Color { r: 1.0, g: 0.0, b: 0.0 };
275    pub const GREEN: Color = Color { r: 0.0, g: 1.0, b: 0.0 };
276    pub const BLUE: Color = Color { r: 0.0, g: 0.0, b: 1.0 };
277    pub const WARM_WHITE: Color = Color { r: 1.0, g: 0.95, b: 0.85 };
278    pub const COOL_WHITE: Color = Color { r: 0.85, g: 0.92, b: 1.0 };
279
280    pub const fn new(r: f32, g: f32, b: f32) -> Self {
281        Self { r, g, b }
282    }
283
284    pub fn from_temperature(kelvin: f32) -> Self {
285        let temp = kelvin / 100.0;
286        let r;
287        let g;
288        let b;
289
290        if temp <= 66.0 {
291            r = 1.0;
292            g = (99.4708025861 * temp.ln() - 161.1195681661).max(0.0).min(255.0) / 255.0;
293        } else {
294            r = (329.698727446 * (temp - 60.0).powf(-0.1332047592)).max(0.0).min(255.0) / 255.0;
295            g = (288.1221695283 * (temp - 60.0).powf(-0.0755148492)).max(0.0).min(255.0) / 255.0;
296        }
297
298        if temp >= 66.0 {
299            b = 1.0;
300        } else if temp <= 19.0 {
301            b = 0.0;
302        } else {
303            b = (138.5177312231 * (temp - 10.0).ln() - 305.0447927307).max(0.0).min(255.0) / 255.0;
304        }
305
306        Self { r, g, b }
307    }
308
309    pub fn luminance(self) -> f32 {
310        0.2126 * self.r + 0.7152 * self.g + 0.0722 * self.b
311    }
312
313    pub fn lerp(self, other: Self, t: f32) -> Self {
314        Self {
315            r: self.r + (other.r - self.r) * t,
316            g: self.g + (other.g - self.g) * t,
317            b: self.b + (other.b - self.b) * t,
318        }
319    }
320
321    pub fn scale(self, s: f32) -> Self {
322        Self {
323            r: self.r * s,
324            g: self.g * s,
325            b: self.b * s,
326        }
327    }
328
329    pub fn to_vec3(self) -> Vec3 {
330        Vec3::new(self.r, self.g, self.b)
331    }
332
333    pub fn from_hsv(h: f32, s: f32, v: f32) -> Self {
334        let h = ((h % 360.0) + 360.0) % 360.0;
335        let c = v * s;
336        let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
337        let m = v - c;
338
339        let (r, g, b) = if h < 60.0 {
340            (c, x, 0.0)
341        } else if h < 120.0 {
342            (x, c, 0.0)
343        } else if h < 180.0 {
344            (0.0, c, x)
345        } else if h < 240.0 {
346            (0.0, x, c)
347        } else if h < 300.0 {
348            (x, 0.0, c)
349        } else {
350            (c, 0.0, x)
351        };
352
353        Self { r: r + m, g: g + m, b: b + m }
354    }
355}
356
357impl Default for Color {
358    fn default() -> Self {
359        Self::WHITE
360    }
361}
362
363// ── Attenuation models ─────────────────────────────────────────────────────
364
365/// Describes how light intensity falls off with distance.
366#[derive(Debug, Clone)]
367pub enum AttenuationModel {
368    /// No falloff — constant intensity within radius.
369    None,
370    /// Linear falloff: `1 - d/r`.
371    Linear,
372    /// Inverse-square (physically based): `1 / (1 + d^2)`.
373    InverseSquare,
374    /// Quadratic with configurable constant, linear, and quadratic terms.
375    Quadratic {
376        constant: f32,
377        linear: f32,
378        quadratic: f32,
379    },
380    /// Smooth Unreal Engine 4 style: `saturate(1 - (d/r)^4)^2`.
381    SmoothUE4,
382    /// Custom falloff curve sampled from a lookup table (evenly spaced from 0..radius).
383    CustomCurve {
384        /// Intensity values sampled at even intervals from distance 0 to `radius`.
385        samples: Vec<f32>,
386    },
387}
388
389impl Default for AttenuationModel {
390    fn default() -> Self {
391        Self::InverseSquare
392    }
393}
394
395impl AttenuationModel {
396    /// Evaluate attenuation at the given distance from the light with the given radius.
397    pub fn evaluate(&self, distance: f32, radius: f32) -> f32 {
398        if radius <= 0.0 || distance >= radius {
399            return 0.0;
400        }
401        let d = distance.max(0.0);
402        let ratio = d / radius;
403
404        match self {
405            Self::None => 1.0,
406            Self::Linear => (1.0 - ratio).max(0.0),
407            Self::InverseSquare => {
408                let falloff = 1.0 / (1.0 + d * d);
409                // Windowed to reach zero at radius
410                let window = (1.0 - ratio * ratio).max(0.0);
411                falloff * window
412            }
413            Self::Quadratic { constant, linear, quadratic } => {
414                let denom = constant + linear * d + quadratic * d * d;
415                if denom <= 0.0 {
416                    0.0
417                } else {
418                    (1.0 / denom).min(1.0) * (1.0 - ratio).max(0.0)
419                }
420            }
421            Self::SmoothUE4 => {
422                let r4 = ratio * ratio * ratio * ratio;
423                let v = (1.0 - r4).max(0.0);
424                v * v
425            }
426            Self::CustomCurve { samples } => {
427                if samples.is_empty() {
428                    return 0.0;
429                }
430                let t = ratio * (samples.len() - 1) as f32;
431                let idx = (t as usize).min(samples.len() - 2);
432                let frac = t - idx as f32;
433                let a = samples[idx];
434                let b = samples[(idx + 1).min(samples.len() - 1)];
435                a + (b - a) * frac
436            }
437        }
438    }
439}
440
441// ── Cascaded shadow params ──────────────────────────────────────────────────
442
443/// Parameters for cascaded shadow mapping on directional lights.
444#[derive(Debug, Clone)]
445pub struct CascadeShadowParams {
446    /// Number of cascades (1..=4).
447    pub cascade_count: u32,
448    /// Split distances for each cascade boundary (in view-space Z).
449    pub split_distances: [f32; 5],
450    /// Shadow map resolution per cascade.
451    pub resolution: u32,
452    /// Blend band between cascades (0.0..1.0).
453    pub blend_band: f32,
454    /// Whether to stabilize cascades to reduce shimmering.
455    pub stabilize: bool,
456    /// Lambda for logarithmic/linear split scheme (0 = linear, 1 = log).
457    pub split_lambda: f32,
458}
459
460impl Default for CascadeShadowParams {
461    fn default() -> Self {
462        Self {
463            cascade_count: 4,
464            split_distances: [0.1, 10.0, 30.0, 80.0, 200.0],
465            resolution: 2048,
466            blend_band: 0.1,
467            stabilize: true,
468            split_lambda: 0.75,
469        }
470    }
471}
472
473impl CascadeShadowParams {
474    /// Compute logarithmic-linear split distances for the given near/far planes.
475    pub fn compute_splits(&mut self, near: f32, far: f32) {
476        let count = self.cascade_count.min(4) as usize;
477        self.split_distances[0] = near;
478        for i in 1..=count {
479            let t = i as f32 / count as f32;
480            let log_split = near * (far / near).powf(t);
481            let lin_split = near + (far - near) * t;
482            self.split_distances[i] = self.split_lambda * log_split + (1.0 - self.split_lambda) * lin_split;
483        }
484    }
485
486    /// Get the view-projection matrix for a specific cascade given the light direction
487    /// and the camera frustum corners for that slice.
488    pub fn cascade_view_projection(
489        &self,
490        light_dir: Vec3,
491        frustum_corners: &[Vec3; 8],
492    ) -> Mat4 {
493        // Compute the centroid of the frustum slice
494        let mut center = Vec3::ZERO;
495        for corner in frustum_corners {
496            center = center + *corner;
497        }
498        center = center * (1.0 / 8.0);
499
500        // Compute the bounding sphere radius
501        let mut radius = 0.0f32;
502        for corner in frustum_corners {
503            let d = corner.distance(center);
504            if d > radius {
505                radius = d;
506            }
507        }
508        radius = (radius * 16.0).ceil() / 16.0;
509
510        let max_extents = Vec3::new(radius, radius, radius);
511        let min_extents = -max_extents;
512
513        let light_pos = center - light_dir.normalize() * radius;
514        let view = Mat4::look_at(light_pos, center, Vec3::UP);
515        let proj = Mat4::orthographic(
516            min_extents.x,
517            max_extents.x,
518            min_extents.y,
519            max_extents.y,
520            0.0,
521            max_extents.z - min_extents.z,
522        );
523
524        proj.mul_mat4(view)
525    }
526}
527
528// ── Point Light ─────────────────────────────────────────────────────────────
529
530/// An omnidirectional point light source.
531#[derive(Debug, Clone)]
532pub struct PointLight {
533    pub position: Vec3,
534    pub color: Color,
535    pub intensity: f32,
536    pub radius: f32,
537    pub attenuation: AttenuationModel,
538    pub cast_shadows: bool,
539    pub shadow_bias: f32,
540    pub enabled: bool,
541    /// Index into shadow atlas (set by the shadow system).
542    pub shadow_map_index: Option<u32>,
543}
544
545impl Default for PointLight {
546    fn default() -> Self {
547        Self {
548            position: Vec3::ZERO,
549            color: Color::WHITE,
550            intensity: 1.0,
551            radius: 10.0,
552            attenuation: AttenuationModel::InverseSquare,
553            cast_shadows: true,
554            shadow_bias: 0.005,
555            enabled: true,
556            shadow_map_index: None,
557        }
558    }
559}
560
561impl PointLight {
562    pub fn new(position: Vec3, color: Color, intensity: f32, radius: f32) -> Self {
563        Self {
564            position,
565            color,
566            intensity,
567            radius,
568            ..Default::default()
569        }
570    }
571
572    pub fn with_attenuation(mut self, model: AttenuationModel) -> Self {
573        self.attenuation = model;
574        self
575    }
576
577    /// Compute irradiance at a world position.
578    pub fn irradiance_at(&self, point: Vec3) -> Color {
579        if !self.enabled {
580            return Color::BLACK;
581        }
582        let dist = self.position.distance(point);
583        let atten = self.attenuation.evaluate(dist, self.radius);
584        self.color.scale(self.intensity * atten)
585    }
586
587    /// Check if a point is within the light's influence radius.
588    pub fn affects_point(&self, point: Vec3) -> bool {
589        self.enabled && self.position.distance(point) < self.radius
590    }
591
592    /// Bounding box of the light's influence volume.
593    pub fn bounding_box(&self) -> (Vec3, Vec3) {
594        let r = Vec3::new(self.radius, self.radius, self.radius);
595        (self.position - r, self.position + r)
596    }
597}
598
599// ── Spot Light ──────────────────────────────────────────────────────────────
600
601/// A conical spot light with inner/outer cone angles and optional cookie texture.
602#[derive(Debug, Clone)]
603pub struct SpotLight {
604    pub position: Vec3,
605    pub direction: Vec3,
606    pub color: Color,
607    pub intensity: f32,
608    pub radius: f32,
609    pub inner_cone_angle: f32,
610    pub outer_cone_angle: f32,
611    pub attenuation: AttenuationModel,
612    pub cast_shadows: bool,
613    pub shadow_bias: f32,
614    pub enabled: bool,
615    /// Index into a cookie texture array. `None` means no cookie.
616    pub cookie_texture_index: Option<u32>,
617    pub shadow_map_index: Option<u32>,
618}
619
620impl Default for SpotLight {
621    fn default() -> Self {
622        Self {
623            position: Vec3::ZERO,
624            direction: Vec3::FORWARD,
625            color: Color::WHITE,
626            intensity: 1.0,
627            radius: 15.0,
628            inner_cone_angle: 20.0_f32.to_radians(),
629            outer_cone_angle: 35.0_f32.to_radians(),
630            attenuation: AttenuationModel::InverseSquare,
631            cast_shadows: true,
632            shadow_bias: 0.005,
633            enabled: true,
634            cookie_texture_index: None,
635            shadow_map_index: None,
636        }
637    }
638}
639
640impl SpotLight {
641    pub fn new(position: Vec3, direction: Vec3, color: Color, intensity: f32) -> Self {
642        Self {
643            position,
644            direction: direction.normalize(),
645            color,
646            intensity,
647            ..Default::default()
648        }
649    }
650
651    pub fn with_cone_angles(mut self, inner_deg: f32, outer_deg: f32) -> Self {
652        self.inner_cone_angle = inner_deg.to_radians();
653        self.outer_cone_angle = outer_deg.to_radians();
654        self
655    }
656
657    pub fn with_cookie(mut self, index: u32) -> Self {
658        self.cookie_texture_index = Some(index);
659        self
660    }
661
662    /// Compute the cone attenuation factor for a given angle from the light axis.
663    fn cone_attenuation(&self, cos_angle: f32) -> f32 {
664        let cos_outer = self.outer_cone_angle.cos();
665        let cos_inner = self.inner_cone_angle.cos();
666        if cos_angle <= cos_outer {
667            return 0.0;
668        }
669        if cos_angle >= cos_inner {
670            return 1.0;
671        }
672        let t = (cos_angle - cos_outer) / (cos_inner - cos_outer);
673        // Smooth hermite interpolation
674        t * t * (3.0 - 2.0 * t)
675    }
676
677    /// Compute irradiance at a world position.
678    pub fn irradiance_at(&self, point: Vec3) -> Color {
679        if !self.enabled {
680            return Color::BLACK;
681        }
682        let to_point = (point - self.position).normalize();
683        let cos_angle = to_point.dot(self.direction.normalize());
684        let cone = self.cone_attenuation(cos_angle);
685        if cone <= 0.0 {
686            return Color::BLACK;
687        }
688        let dist = self.position.distance(point);
689        let atten = self.attenuation.evaluate(dist, self.radius);
690        self.color.scale(self.intensity * atten * cone)
691    }
692
693    /// Compute the view-projection matrix for shadow mapping.
694    pub fn shadow_view_projection(&self) -> Mat4 {
695        let target = self.position + self.direction.normalize();
696        let up = if self.direction.normalize().dot(Vec3::UP).abs() > 0.99 {
697            Vec3::new(1.0, 0.0, 0.0)
698        } else {
699            Vec3::UP
700        };
701        let view = Mat4::look_at(self.position, target, up);
702        let proj = Mat4::perspective(self.outer_cone_angle * 2.0, 1.0, 0.1, self.radius);
703        proj.mul_mat4(view)
704    }
705
706    /// Check if a point is within the spot light's cone and radius.
707    pub fn affects_point(&self, point: Vec3) -> bool {
708        if !self.enabled {
709            return false;
710        }
711        let dist = self.position.distance(point);
712        if dist > self.radius {
713            return false;
714        }
715        let to_point = (point - self.position).normalize();
716        let cos_angle = to_point.dot(self.direction.normalize());
717        cos_angle > self.outer_cone_angle.cos()
718    }
719}
720
721// ── Directional Light ───────────────────────────────────────────────────────
722
723/// An infinitely distant directional light (e.g., the sun).
724#[derive(Debug, Clone)]
725pub struct DirectionalLight {
726    pub direction: Vec3,
727    pub color: Color,
728    pub intensity: f32,
729    pub cast_shadows: bool,
730    pub enabled: bool,
731    pub cascade_params: CascadeShadowParams,
732    /// Angular diameter in radians (for soft shadows, default ~0.0093 for the sun).
733    pub angular_diameter: f32,
734}
735
736impl Default for DirectionalLight {
737    fn default() -> Self {
738        Self {
739            direction: Vec3::new(0.0, -1.0, -0.5).normalize(),
740            color: Color::WARM_WHITE,
741            intensity: 1.0,
742            cast_shadows: true,
743            enabled: true,
744            cascade_params: CascadeShadowParams::default(),
745            angular_diameter: 0.0093,
746        }
747    }
748}
749
750impl DirectionalLight {
751    pub fn new(direction: Vec3, color: Color, intensity: f32) -> Self {
752        Self {
753            direction: direction.normalize(),
754            color,
755            intensity,
756            ..Default::default()
757        }
758    }
759
760    /// Compute irradiance for a surface with the given normal.
761    pub fn irradiance_for_normal(&self, normal: Vec3) -> Color {
762        if !self.enabled {
763            return Color::BLACK;
764        }
765        let n_dot_l = normal.dot(-self.direction.normalize()).max(0.0);
766        self.color.scale(self.intensity * n_dot_l)
767    }
768
769    /// Get the cascade view-projection matrices for the current shadow params.
770    pub fn cascade_matrices(&self, camera_frustum_corners: &[[Vec3; 8]; 4]) -> [Mat4; 4] {
771        let mut matrices = [Mat4::IDENTITY; 4];
772        let count = self.cascade_params.cascade_count.min(4) as usize;
773        for i in 0..count {
774            matrices[i] = self.cascade_params.cascade_view_projection(
775                self.direction,
776                &camera_frustum_corners[i],
777            );
778        }
779        matrices
780    }
781}
782
783// ── Area Light ──────────────────────────────────────────────────────────────
784
785/// Shape of the area light's emitting surface.
786#[derive(Debug, Clone, Copy, PartialEq)]
787pub enum AreaShape {
788    /// Rectangle with width and height.
789    Rectangle { width: f32, height: f32 },
790    /// Disc with the given radius.
791    Disc { radius: f32 },
792}
793
794impl Default for AreaShape {
795    fn default() -> Self {
796        Self::Rectangle { width: 1.0, height: 1.0 }
797    }
798}
799
800/// An area light that emits from a shaped surface. Uses an approximated
801/// most-representative-point (MRP) technique for irradiance.
802#[derive(Debug, Clone)]
803pub struct AreaLight {
804    pub position: Vec3,
805    pub direction: Vec3,
806    pub up: Vec3,
807    pub color: Color,
808    pub intensity: f32,
809    pub shape: AreaShape,
810    pub enabled: bool,
811    pub two_sided: bool,
812    /// Attenuation radius — area light contribution fades to zero here.
813    pub radius: f32,
814}
815
816impl Default for AreaLight {
817    fn default() -> Self {
818        Self {
819            position: Vec3::ZERO,
820            direction: Vec3::FORWARD,
821            up: Vec3::UP,
822            color: Color::WHITE,
823            intensity: 1.0,
824            shape: AreaShape::default(),
825            enabled: true,
826            two_sided: false,
827            radius: 20.0,
828        }
829    }
830}
831
832impl AreaLight {
833    pub fn new_rectangle(position: Vec3, direction: Vec3, width: f32, height: f32, color: Color, intensity: f32) -> Self {
834        Self {
835            position,
836            direction: direction.normalize(),
837            shape: AreaShape::Rectangle { width, height },
838            color,
839            intensity,
840            ..Default::default()
841        }
842    }
843
844    pub fn new_disc(position: Vec3, direction: Vec3, radius: f32, color: Color, intensity: f32) -> Self {
845        Self {
846            position,
847            direction: direction.normalize(),
848            shape: AreaShape::Disc { radius },
849            color,
850            intensity,
851            ..Default::default()
852        }
853    }
854
855    /// Compute the four corners of a rectangular area light in world space.
856    pub fn rect_corners(&self) -> [Vec3; 4] {
857        let right = self.direction.cross(self.up).normalize();
858        let corrected_up = right.cross(self.direction).normalize();
859
860        let (hw, hh) = match self.shape {
861            AreaShape::Rectangle { width, height } => (width * 0.5, height * 0.5),
862            AreaShape::Disc { radius } => (radius, radius),
863        };
864
865        [
866            self.position + right * (-hw) + corrected_up * hh,
867            self.position + right * hw + corrected_up * hh,
868            self.position + right * hw + corrected_up * (-hh),
869            self.position + right * (-hw) + corrected_up * (-hh),
870        ]
871    }
872
873    /// Approximate irradiance at a point using the most-representative-point method.
874    pub fn irradiance_at(&self, point: Vec3, normal: Vec3) -> Color {
875        if !self.enabled {
876            return Color::BLACK;
877        }
878
879        let dist = self.position.distance(point);
880        if dist > self.radius {
881            return Color::BLACK;
882        }
883
884        // Project point onto the area light plane to find closest point
885        let to_point = point - self.position;
886        let plane_dist = to_point.dot(self.direction.normalize());
887
888        if !self.two_sided && plane_dist < 0.0 {
889            return Color::BLACK;
890        }
891
892        let right = self.direction.cross(self.up).normalize();
893        let corrected_up = right.cross(self.direction).normalize();
894
895        // Project onto the light's local axes
896        let local_x = to_point.dot(right);
897        let local_y = to_point.dot(corrected_up);
898
899        // Clamp to the area light shape
900        let closest = match self.shape {
901            AreaShape::Rectangle { width, height } => {
902                let cx = local_x.clamp(-width * 0.5, width * 0.5);
903                let cy = local_y.clamp(-height * 0.5, height * 0.5);
904                self.position + right * cx + corrected_up * cy
905            }
906            AreaShape::Disc { radius } => {
907                let r = (local_x * local_x + local_y * local_y).sqrt();
908                if r < 1e-6 {
909                    self.position
910                } else {
911                    let clamped_r = r.min(radius);
912                    let scale = clamped_r / r;
913                    self.position + right * (local_x * scale) + corrected_up * (local_y * scale)
914                }
915            }
916        };
917
918        let to_closest = closest - point;
919        let closest_dist = to_closest.length();
920        if closest_dist < 1e-6 {
921            return self.color.scale(self.intensity);
922        }
923
924        let light_dir = to_closest * (1.0 / closest_dist);
925        let n_dot_l = normal.dot(light_dir).max(0.0);
926
927        // Area approximation: use solid angle subtended by the area light
928        let area = match self.shape {
929            AreaShape::Rectangle { width, height } => width * height,
930            AreaShape::Disc { radius } => PI * radius * radius,
931        };
932
933        let form_factor = (area * n_dot_l) / (closest_dist * closest_dist + area);
934        let window = (1.0 - (dist / self.radius)).max(0.0);
935
936        self.color.scale(self.intensity * form_factor * window)
937    }
938}
939
940// ── Emissive Glyph ─────────────────────────────────────────────────────────
941
942/// Auto light source generated from a bright glyph that exceeds the emission threshold.
943#[derive(Debug, Clone)]
944pub struct EmissiveGlyph {
945    pub position: Vec3,
946    pub color: Color,
947    pub emission_strength: f32,
948    pub radius: f32,
949    pub glyph_character: char,
950    pub enabled: bool,
951    /// The threshold above which a glyph emission value becomes a light source.
952    pub emission_threshold: f32,
953}
954
955impl Default for EmissiveGlyph {
956    fn default() -> Self {
957        Self {
958            position: Vec3::ZERO,
959            color: Color::WHITE,
960            emission_strength: 1.0,
961            radius: 5.0,
962            glyph_character: '*',
963            enabled: true,
964            emission_threshold: 0.5,
965        }
966    }
967}
968
969impl EmissiveGlyph {
970    pub fn new(position: Vec3, character: char, color: Color, emission: f32) -> Self {
971        Self {
972            position,
973            color,
974            emission_strength: emission,
975            glyph_character: character,
976            ..Default::default()
977        }
978    }
979
980    /// Check if the given emission value exceeds the threshold.
981    pub fn is_active(&self) -> bool {
982        self.enabled && self.emission_strength > self.emission_threshold
983    }
984
985    /// The effective intensity above the threshold.
986    pub fn effective_intensity(&self) -> f32 {
987        if !self.is_active() {
988            return 0.0;
989        }
990        (self.emission_strength - self.emission_threshold).max(0.0)
991    }
992
993    /// Compute irradiance at a world point (treated as a small point light).
994    pub fn irradiance_at(&self, point: Vec3) -> Color {
995        if !self.is_active() {
996            return Color::BLACK;
997        }
998        let dist = self.position.distance(point);
999        if dist > self.radius {
1000            return Color::BLACK;
1001        }
1002        let ratio = dist / self.radius;
1003        let atten = (1.0 - ratio * ratio).max(0.0);
1004        self.color.scale(self.effective_intensity() * atten)
1005    }
1006
1007    /// Estimate emissive glyph radius based on emission strength.
1008    pub fn auto_radius(emission_strength: f32) -> f32 {
1009        (emission_strength * 8.0).clamp(1.0, 30.0)
1010    }
1011
1012    /// Create from raw glyph data (character, emission value, position).
1013    pub fn from_glyph_data(character: char, emission: f32, position: Vec3, color: Color, threshold: f32) -> Option<Self> {
1014        if emission <= threshold {
1015            return None;
1016        }
1017        Some(Self {
1018            position,
1019            color,
1020            emission_strength: emission,
1021            radius: Self::auto_radius(emission),
1022            glyph_character: character,
1023            enabled: true,
1024            emission_threshold: threshold,
1025        })
1026    }
1027}
1028
1029// ── Animation Patterns ──────────────────────────────────────────────────────
1030
1031/// Describes an animated light pattern.
1032#[derive(Debug, Clone)]
1033pub enum AnimationPattern {
1034    /// Smooth sinusoidal pulse between min and max intensity.
1035    Pulse {
1036        min_intensity: f32,
1037        max_intensity: f32,
1038        frequency: f32,
1039    },
1040    /// Random flicker with configurable smoothness.
1041    Flicker {
1042        min_intensity: f32,
1043        max_intensity: f32,
1044        /// How smooth the flicker is (0 = very chaotic, 1 = smooth).
1045        smoothness: f32,
1046        /// Random seed for deterministic flickering.
1047        seed: u32,
1048    },
1049    /// On/off strobe at a fixed frequency.
1050    Strobe {
1051        on_intensity: f32,
1052        off_intensity: f32,
1053        frequency: f32,
1054        duty_cycle: f32,
1055    },
1056    /// Linear fade from one intensity to another over a duration, then hold.
1057    Fade {
1058        from_intensity: f32,
1059        to_intensity: f32,
1060        duration: f32,
1061    },
1062    /// Intensity driven by a mathematical function of time.
1063    MathDriven {
1064        /// Coefficients for: a * sin(b*t + c) + d * cos(e*t + f) + g
1065        a: f32,
1066        b: f32,
1067        c: f32,
1068        d: f32,
1069        e: f32,
1070        f: f32,
1071        g: f32,
1072    },
1073    /// Cycle through a list of colors over time.
1074    ColorCycle {
1075        colors: Vec<Color>,
1076        cycle_duration: f32,
1077        smooth: bool,
1078    },
1079    /// Heartbeat pattern: two quick pulses then a pause.
1080    Heartbeat {
1081        base_intensity: f32,
1082        peak_intensity: f32,
1083        beat_duration: f32,
1084        pause_duration: f32,
1085    },
1086}
1087
1088impl AnimationPattern {
1089    /// Evaluate intensity at the given elapsed time.
1090    pub fn evaluate_intensity(&self, time: f32) -> f32 {
1091        match self {
1092            Self::Pulse { min_intensity, max_intensity, frequency } => {
1093                let t = (time * frequency * 2.0 * PI).sin() * 0.5 + 0.5;
1094                min_intensity + (max_intensity - min_intensity) * t
1095            }
1096            Self::Flicker { min_intensity, max_intensity, smoothness, seed } => {
1097                let noise = Self::pseudo_noise(time, *seed, *smoothness);
1098                min_intensity + (max_intensity - min_intensity) * noise
1099            }
1100            Self::Strobe { on_intensity, off_intensity, frequency, duty_cycle } => {
1101                let phase = (time * frequency).fract();
1102                if phase < *duty_cycle {
1103                    *on_intensity
1104                } else {
1105                    *off_intensity
1106                }
1107            }
1108            Self::Fade { from_intensity, to_intensity, duration } => {
1109                if *duration <= 0.0 {
1110                    return *to_intensity;
1111                }
1112                let t = (time / duration).clamp(0.0, 1.0);
1113                from_intensity + (to_intensity - from_intensity) * t
1114            }
1115            Self::MathDriven { a, b, c, d, e, f, g } => {
1116                a * (b * time + c).sin() + d * (e * time + f).cos() + g
1117            }
1118            Self::ColorCycle { colors, .. } => {
1119                // Color cycle doesn't change intensity, return 1.0
1120                if colors.is_empty() {
1121                    1.0
1122                } else {
1123                    1.0
1124                }
1125            }
1126            Self::Heartbeat { base_intensity, peak_intensity, beat_duration, pause_duration } => {
1127                let total = beat_duration * 2.0 + pause_duration;
1128                let phase = time % total;
1129                if phase < *beat_duration {
1130                    // First beat
1131                    let t = phase / beat_duration;
1132                    let envelope = (t * PI).sin();
1133                    base_intensity + (peak_intensity - base_intensity) * envelope
1134                } else if phase < beat_duration * 2.0 {
1135                    // Second beat (slightly weaker)
1136                    let t = (phase - beat_duration) / beat_duration;
1137                    let envelope = (t * PI).sin() * 0.7;
1138                    base_intensity + (peak_intensity - base_intensity) * envelope
1139                } else {
1140                    // Pause
1141                    *base_intensity
1142                }
1143            }
1144        }
1145    }
1146
1147    /// Evaluate color at the given time (only meaningful for ColorCycle).
1148    pub fn evaluate_color(&self, time: f32) -> Option<Color> {
1149        match self {
1150            Self::ColorCycle { colors, cycle_duration, smooth } => {
1151                if colors.is_empty() {
1152                    return None;
1153                }
1154                if colors.len() == 1 {
1155                    return Some(colors[0]);
1156                }
1157                let duration = if *cycle_duration <= 0.0 { 1.0 } else { *cycle_duration };
1158                let t = (time % duration) / duration;
1159                let scaled = t * colors.len() as f32;
1160                let idx = scaled as usize % colors.len();
1161                let next_idx = (idx + 1) % colors.len();
1162                let frac = scaled.fract();
1163
1164                if *smooth {
1165                    Some(colors[idx].lerp(colors[next_idx], frac))
1166                } else {
1167                    Some(colors[idx])
1168                }
1169            }
1170            _ => None,
1171        }
1172    }
1173
1174    /// Simple pseudo-random noise for flicker effects.
1175    fn pseudo_noise(time: f32, seed: u32, smoothness: f32) -> f32 {
1176        let s = seed as f32 * 0.1;
1177        let t1 = (time * 7.3 + s).sin() * 43758.5453;
1178        let t2 = (time * 13.7 + s * 2.3).sin() * 28461.7231;
1179        let raw = (t1.fract() + t2.fract()) * 0.5;
1180        // Apply smoothness by blending with a slow sine
1181        let smooth_part = ((time * 2.0 + s).sin() * 0.5 + 0.5).clamp(0.0, 1.0);
1182        let result = raw * (1.0 - smoothness) + smooth_part * smoothness;
1183        result.clamp(0.0, 1.0)
1184    }
1185}
1186
1187// ── Animated Light ──────────────────────────────────────────────────────────
1188
1189/// A wrapper around any light type that applies an animation pattern.
1190#[derive(Debug, Clone)]
1191pub struct AnimatedLight {
1192    pub base_color: Color,
1193    pub base_intensity: f32,
1194    pub position: Vec3,
1195    pub radius: f32,
1196    pub pattern: AnimationPattern,
1197    pub enabled: bool,
1198    pub time_offset: f32,
1199    pub elapsed: f32,
1200    /// The speed multiplier for animation playback.
1201    pub speed: f32,
1202    /// Whether to loop or stop at end.
1203    pub looping: bool,
1204}
1205
1206impl Default for AnimatedLight {
1207    fn default() -> Self {
1208        Self {
1209            base_color: Color::WHITE,
1210            base_intensity: 1.0,
1211            position: Vec3::ZERO,
1212            radius: 10.0,
1213            pattern: AnimationPattern::Pulse {
1214                min_intensity: 0.2,
1215                max_intensity: 1.0,
1216                frequency: 1.0,
1217            },
1218            enabled: true,
1219            time_offset: 0.0,
1220            elapsed: 0.0,
1221            speed: 1.0,
1222            looping: true,
1223        }
1224    }
1225}
1226
1227impl AnimatedLight {
1228    pub fn new(position: Vec3, color: Color, pattern: AnimationPattern) -> Self {
1229        Self {
1230            position,
1231            base_color: color,
1232            pattern,
1233            ..Default::default()
1234        }
1235    }
1236
1237    /// Advance the animation by dt seconds.
1238    pub fn update(&mut self, dt: f32) {
1239        self.elapsed += dt * self.speed;
1240    }
1241
1242    /// Get the current effective intensity.
1243    pub fn current_intensity(&self) -> f32 {
1244        let t = self.elapsed + self.time_offset;
1245        self.pattern.evaluate_intensity(t)
1246    }
1247
1248    /// Get the current effective color.
1249    pub fn current_color(&self) -> Color {
1250        let t = self.elapsed + self.time_offset;
1251        self.pattern.evaluate_color(t).unwrap_or(self.base_color)
1252    }
1253
1254    /// Compute irradiance at a point.
1255    pub fn irradiance_at(&self, point: Vec3) -> Color {
1256        if !self.enabled {
1257            return Color::BLACK;
1258        }
1259        let dist = self.position.distance(point);
1260        if dist > self.radius {
1261            return Color::BLACK;
1262        }
1263        let ratio = dist / self.radius;
1264        let atten = (1.0 - ratio * ratio).max(0.0);
1265        let color = self.current_color();
1266        let intensity = self.current_intensity();
1267        color.scale(intensity * atten)
1268    }
1269
1270    /// Reset the animation to the beginning.
1271    pub fn reset(&mut self) {
1272        self.elapsed = 0.0;
1273    }
1274
1275    /// Create a flickering torch light.
1276    pub fn torch(position: Vec3) -> Self {
1277        Self::new(
1278            position,
1279            Color::from_temperature(2200.0),
1280            AnimationPattern::Flicker {
1281                min_intensity: 0.5,
1282                max_intensity: 1.2,
1283                smoothness: 0.6,
1284                seed: position.x.to_bits() ^ position.y.to_bits(),
1285            },
1286        )
1287    }
1288
1289    /// Create a pulsing warning light.
1290    pub fn warning(position: Vec3) -> Self {
1291        Self::new(
1292            position,
1293            Color::RED,
1294            AnimationPattern::Pulse {
1295                min_intensity: 0.1,
1296                max_intensity: 2.0,
1297                frequency: 0.5,
1298            },
1299        )
1300    }
1301
1302    /// Create a strobe light.
1303    pub fn strobe(position: Vec3, frequency: f32) -> Self {
1304        Self::new(
1305            position,
1306            Color::WHITE,
1307            AnimationPattern::Strobe {
1308                on_intensity: 3.0,
1309                off_intensity: 0.0,
1310                frequency,
1311                duty_cycle: 0.1,
1312            },
1313        )
1314    }
1315
1316    /// Create a heartbeat light.
1317    pub fn heartbeat(position: Vec3, color: Color) -> Self {
1318        Self::new(
1319            position,
1320            color,
1321            AnimationPattern::Heartbeat {
1322                base_intensity: 0.1,
1323                peak_intensity: 2.0,
1324                beat_duration: 0.15,
1325                pause_duration: 0.7,
1326            },
1327        )
1328    }
1329}
1330
1331// ── IES Profile ─────────────────────────────────────────────────────────────
1332
1333/// Photometric intensity distribution sampled from IES data.
1334/// Uses bilinear interpolation on a 2D (vertical angle, horizontal angle) grid.
1335#[derive(Debug, Clone)]
1336pub struct IESProfile {
1337    /// Vertical angles in radians (typically 0 to PI).
1338    pub vertical_angles: Vec<f32>,
1339    /// Horizontal angles in radians (typically 0 to 2*PI).
1340    pub horizontal_angles: Vec<f32>,
1341    /// Candela values: indexed as `[h_index * vertical_count + v_index]`.
1342    pub candela_values: Vec<f32>,
1343    /// Maximum candela value for normalization.
1344    pub max_candela: f32,
1345    /// Light properties
1346    pub position: Vec3,
1347    pub direction: Vec3,
1348    pub color: Color,
1349    pub intensity: f32,
1350    pub radius: f32,
1351    pub enabled: bool,
1352}
1353
1354impl Default for IESProfile {
1355    fn default() -> Self {
1356        Self {
1357            vertical_angles: vec![0.0, PI * 0.5, PI],
1358            horizontal_angles: vec![0.0],
1359            candela_values: vec![1.0, 0.8, 0.0],
1360            max_candela: 1.0,
1361            position: Vec3::ZERO,
1362            direction: Vec3::DOWN,
1363            color: Color::WHITE,
1364            intensity: 1.0,
1365            radius: 15.0,
1366            enabled: true,
1367        }
1368    }
1369}
1370
1371impl IESProfile {
1372    /// Create an IES profile from raw data.
1373    pub fn new(
1374        vertical_angles: Vec<f32>,
1375        horizontal_angles: Vec<f32>,
1376        candela_values: Vec<f32>,
1377        position: Vec3,
1378        direction: Vec3,
1379    ) -> Self {
1380        let max_candela = candela_values.iter().cloned().fold(0.0f32, f32::max);
1381        Self {
1382            vertical_angles,
1383            horizontal_angles,
1384            candela_values,
1385            max_candela: if max_candela > 0.0 { max_candela } else { 1.0 },
1386            position,
1387            direction: direction.normalize(),
1388            ..Default::default()
1389        }
1390    }
1391
1392    /// Create a symmetric IES profile from vertical-only data.
1393    pub fn symmetric(vertical_angles: Vec<f32>, candela_values: Vec<f32>, position: Vec3, direction: Vec3) -> Self {
1394        Self::new(vertical_angles, vec![0.0], candela_values, position, direction)
1395    }
1396
1397    /// Get the number of vertical angle samples.
1398    pub fn vertical_count(&self) -> usize {
1399        self.vertical_angles.len()
1400    }
1401
1402    /// Get the number of horizontal angle samples.
1403    pub fn horizontal_count(&self) -> usize {
1404        self.horizontal_angles.len()
1405    }
1406
1407    /// Find the bracketing indices and interpolation factor for a value in a sorted array.
1408    fn find_bracket(angles: &[f32], value: f32) -> (usize, usize, f32) {
1409        if angles.len() <= 1 {
1410            return (0, 0, 0.0);
1411        }
1412        if value <= angles[0] {
1413            return (0, 0, 0.0);
1414        }
1415        if value >= angles[angles.len() - 1] {
1416            let last = angles.len() - 1;
1417            return (last, last, 0.0);
1418        }
1419        for i in 0..angles.len() - 1 {
1420            if value >= angles[i] && value <= angles[i + 1] {
1421                let range = angles[i + 1] - angles[i];
1422                let t = if range > 1e-10 { (value - angles[i]) / range } else { 0.0 };
1423                return (i, i + 1, t);
1424            }
1425        }
1426        let last = angles.len() - 1;
1427        (last, last, 0.0)
1428    }
1429
1430    /// Sample the candela value at the given vertical and horizontal angles using bilinear interpolation.
1431    pub fn sample(&self, vertical_angle: f32, horizontal_angle: f32) -> f32 {
1432        let v_count = self.vertical_count();
1433        let h_count = self.horizontal_count();
1434
1435        if v_count == 0 || h_count == 0 || self.candela_values.is_empty() {
1436            return 0.0;
1437        }
1438
1439        let (v0, v1, vt) = Self::find_bracket(&self.vertical_angles, vertical_angle);
1440        let (h0, h1, ht) = Self::find_bracket(&self.horizontal_angles, horizontal_angle);
1441
1442        let idx = |h: usize, v: usize| -> f32 {
1443            let i = h * v_count + v;
1444            if i < self.candela_values.len() {
1445                self.candela_values[i]
1446            } else {
1447                0.0
1448            }
1449        };
1450
1451        let c00 = idx(h0, v0);
1452        let c10 = idx(h1, v0);
1453        let c01 = idx(h0, v1);
1454        let c11 = idx(h1, v1);
1455
1456        let top = c00 + (c10 - c00) * ht;
1457        let bottom = c01 + (c11 - c01) * ht;
1458        top + (bottom - top) * vt
1459    }
1460
1461    /// Compute the normalized intensity factor for a world-space direction.
1462    pub fn intensity_for_direction(&self, world_dir: Vec3) -> f32 {
1463        if !self.enabled {
1464            return 0.0;
1465        }
1466
1467        let dir = self.direction.normalize();
1468        let to_point = world_dir.normalize();
1469
1470        // Compute vertical angle (angle from the light direction axis)
1471        let cos_v = to_point.dot(dir);
1472        let vertical_angle = cos_v.clamp(-1.0, 1.0).acos();
1473
1474        // Compute horizontal angle (rotation around the light axis)
1475        let up = if dir.dot(Vec3::UP).abs() > 0.99 {
1476            Vec3::new(1.0, 0.0, 0.0)
1477        } else {
1478            Vec3::UP
1479        };
1480        let right = dir.cross(up).normalize();
1481        let corrected_up = right.cross(dir).normalize();
1482
1483        let proj_right = to_point.dot(right);
1484        let proj_up = to_point.dot(corrected_up);
1485        let horizontal_angle = proj_up.atan2(proj_right);
1486        let horizontal_angle = if horizontal_angle < 0.0 {
1487            horizontal_angle + 2.0 * PI
1488        } else {
1489            horizontal_angle
1490        };
1491
1492        let candela = self.sample(vertical_angle, horizontal_angle);
1493        candela / self.max_candela
1494    }
1495
1496    /// Compute irradiance at a world position.
1497    pub fn irradiance_at(&self, point: Vec3) -> Color {
1498        if !self.enabled {
1499            return Color::BLACK;
1500        }
1501        let to_point = point - self.position;
1502        let dist = to_point.length();
1503        if dist > self.radius || dist < 1e-6 {
1504            return Color::BLACK;
1505        }
1506        let dir = to_point * (1.0 / dist);
1507        let ies_factor = self.intensity_for_direction(dir);
1508        let dist_atten = 1.0 / (1.0 + dist * dist);
1509        let window = (1.0 - (dist / self.radius).powi(4)).max(0.0);
1510        self.color.scale(self.intensity * ies_factor * dist_atten * window)
1511    }
1512
1513    /// Create a standard downlight IES profile.
1514    pub fn downlight(position: Vec3) -> Self {
1515        let v_angles: Vec<f32> = (0..=18).map(|i| i as f32 * PI / 18.0).collect();
1516        let candela: Vec<f32> = v_angles.iter().map(|&a| {
1517            let cos_a = a.cos();
1518            if cos_a < 0.0 { 0.0 } else { cos_a.powf(4.0) }
1519        }).collect();
1520        Self::symmetric(v_angles, candela, position, Vec3::DOWN)
1521    }
1522
1523    /// Create a wall-wash IES profile.
1524    pub fn wall_wash(position: Vec3, wall_direction: Vec3) -> Self {
1525        let v_angles: Vec<f32> = (0..=18).map(|i| i as f32 * PI / 18.0).collect();
1526        let h_angles: Vec<f32> = (0..=36).map(|i| i as f32 * 2.0 * PI / 36.0).collect();
1527        let mut candela = Vec::with_capacity(h_angles.len() * v_angles.len());
1528        for h in 0..h_angles.len() {
1529            let h_factor = (h_angles[h].cos() * 0.5 + 0.5).max(0.0);
1530            for v in 0..v_angles.len() {
1531                let v_factor = if v_angles[v] < PI * 0.6 {
1532                    (v_angles[v] / (PI * 0.6)).sin()
1533                } else {
1534                    ((PI - v_angles[v]) / (PI * 0.4)).max(0.0)
1535                };
1536                candela.push(v_factor * h_factor);
1537            }
1538        }
1539        Self::new(v_angles, h_angles, candela, position, wall_direction)
1540    }
1541}
1542
1543// ── Unified Light Enum ──────────────────────────────────────────────────────
1544
1545/// Unified light type that wraps all supported light variants.
1546#[derive(Debug, Clone)]
1547pub enum Light {
1548    Point(PointLight),
1549    Spot(SpotLight),
1550    Directional(DirectionalLight),
1551    Area(AreaLight),
1552    Emissive(EmissiveGlyph),
1553    Animated(AnimatedLight),
1554    IES(IESProfile),
1555}
1556
1557impl Light {
1558    /// Get the world-space position of the light, if applicable.
1559    pub fn position(&self) -> Option<Vec3> {
1560        match self {
1561            Light::Point(l) => Some(l.position),
1562            Light::Spot(l) => Some(l.position),
1563            Light::Directional(_) => None,
1564            Light::Area(l) => Some(l.position),
1565            Light::Emissive(l) => Some(l.position),
1566            Light::Animated(l) => Some(l.position),
1567            Light::IES(l) => Some(l.position),
1568        }
1569    }
1570
1571    /// Get the effective radius of the light.
1572    pub fn radius(&self) -> f32 {
1573        match self {
1574            Light::Point(l) => l.radius,
1575            Light::Spot(l) => l.radius,
1576            Light::Directional(_) => f32::MAX,
1577            Light::Area(l) => l.radius,
1578            Light::Emissive(l) => l.radius,
1579            Light::Animated(l) => l.radius,
1580            Light::IES(l) => l.radius,
1581        }
1582    }
1583
1584    /// Whether this light is currently enabled.
1585    pub fn is_enabled(&self) -> bool {
1586        match self {
1587            Light::Point(l) => l.enabled,
1588            Light::Spot(l) => l.enabled,
1589            Light::Directional(l) => l.enabled,
1590            Light::Area(l) => l.enabled,
1591            Light::Emissive(l) => l.is_active(),
1592            Light::Animated(l) => l.enabled,
1593            Light::IES(l) => l.enabled,
1594        }
1595    }
1596
1597    /// Set the enabled state.
1598    pub fn set_enabled(&mut self, enabled: bool) {
1599        match self {
1600            Light::Point(l) => l.enabled = enabled,
1601            Light::Spot(l) => l.enabled = enabled,
1602            Light::Directional(l) => l.enabled = enabled,
1603            Light::Area(l) => l.enabled = enabled,
1604            Light::Emissive(l) => l.enabled = enabled,
1605            Light::Animated(l) => l.enabled = enabled,
1606            Light::IES(l) => l.enabled = enabled,
1607        }
1608    }
1609
1610    /// Whether this light casts shadows.
1611    pub fn casts_shadows(&self) -> bool {
1612        match self {
1613            Light::Point(l) => l.cast_shadows,
1614            Light::Spot(l) => l.cast_shadows,
1615            Light::Directional(l) => l.cast_shadows,
1616            _ => false,
1617        }
1618    }
1619
1620    /// Compute irradiance at a given world point.
1621    pub fn irradiance_at(&self, point: Vec3, normal: Vec3) -> Color {
1622        match self {
1623            Light::Point(l) => l.irradiance_at(point),
1624            Light::Spot(l) => l.irradiance_at(point),
1625            Light::Directional(l) => l.irradiance_for_normal(normal),
1626            Light::Area(l) => l.irradiance_at(point, normal),
1627            Light::Emissive(l) => l.irradiance_at(point),
1628            Light::Animated(l) => l.irradiance_at(point),
1629            Light::IES(l) => l.irradiance_at(point),
1630        }
1631    }
1632
1633    /// Update time-varying lights (animated lights).
1634    pub fn update(&mut self, dt: f32) {
1635        if let Light::Animated(l) = self {
1636            l.update(dt);
1637        }
1638    }
1639
1640    /// Get the color of this light.
1641    pub fn color(&self) -> Color {
1642        match self {
1643            Light::Point(l) => l.color,
1644            Light::Spot(l) => l.color,
1645            Light::Directional(l) => l.color,
1646            Light::Area(l) => l.color,
1647            Light::Emissive(l) => l.color,
1648            Light::Animated(l) => l.current_color(),
1649            Light::IES(l) => l.color,
1650        }
1651    }
1652
1653    /// Get the intensity of this light.
1654    pub fn intensity(&self) -> f32 {
1655        match self {
1656            Light::Point(l) => l.intensity,
1657            Light::Spot(l) => l.intensity,
1658            Light::Directional(l) => l.intensity,
1659            Light::Area(l) => l.intensity,
1660            Light::Emissive(l) => l.effective_intensity(),
1661            Light::Animated(l) => l.current_intensity(),
1662            Light::IES(l) => l.intensity,
1663        }
1664    }
1665}
1666
1667// ── Spatial Light Grid ──────────────────────────────────────────────────────
1668
1669/// A spatial grid that maps world-space cells to sets of light IDs for fast queries.
1670#[derive(Debug, Clone)]
1671pub struct SpatialLightGrid {
1672    cell_size: f32,
1673    cells: HashMap<(i32, i32, i32), Vec<LightId>>,
1674    /// Directional lights always affect everything (stored separately).
1675    directional_lights: Vec<LightId>,
1676}
1677
1678impl SpatialLightGrid {
1679    pub fn new(cell_size: f32) -> Self {
1680        Self {
1681            cell_size: cell_size.max(0.1),
1682            cells: HashMap::new(),
1683            directional_lights: Vec::new(),
1684        }
1685    }
1686
1687    fn world_to_cell(&self, pos: Vec3) -> (i32, i32, i32) {
1688        let inv = 1.0 / self.cell_size;
1689        (
1690            (pos.x * inv).floor() as i32,
1691            (pos.y * inv).floor() as i32,
1692            (pos.z * inv).floor() as i32,
1693        )
1694    }
1695
1696    /// Clear all cells and rebuild from the given lights.
1697    pub fn rebuild(&mut self, lights: &HashMap<LightId, Light>) {
1698        self.cells.clear();
1699        self.directional_lights.clear();
1700
1701        for (&id, light) in lights {
1702            if !light.is_enabled() {
1703                continue;
1704            }
1705
1706            match light.position() {
1707                None => {
1708                    // Directional lights affect everything
1709                    self.directional_lights.push(id);
1710                }
1711                Some(pos) => {
1712                    let radius = light.radius();
1713                    let (min_cell_x, min_cell_y, min_cell_z) = self.world_to_cell(
1714                        pos - Vec3::new(radius, radius, radius),
1715                    );
1716                    let (max_cell_x, max_cell_y, max_cell_z) = self.world_to_cell(
1717                        pos + Vec3::new(radius, radius, radius),
1718                    );
1719
1720                    // Limit the number of cells we insert into (avoid huge lights filling thousands of cells)
1721                    let cell_span_x = (max_cell_x - min_cell_x + 1).min(32);
1722                    let cell_span_y = (max_cell_y - min_cell_y + 1).min(32);
1723                    let cell_span_z = (max_cell_z - min_cell_z + 1).min(32);
1724
1725                    for x in min_cell_x..min_cell_x + cell_span_x {
1726                        for y in min_cell_y..min_cell_y + cell_span_y {
1727                            for z in min_cell_z..min_cell_z + cell_span_z {
1728                                self.cells.entry((x, y, z)).or_default().push(id);
1729                            }
1730                        }
1731                    }
1732                }
1733            }
1734        }
1735    }
1736
1737    /// Query lights that potentially affect a given world position.
1738    pub fn query(&self, pos: Vec3) -> Vec<LightId> {
1739        let cell = self.world_to_cell(pos);
1740        let mut result = self.directional_lights.clone();
1741        if let Some(ids) = self.cells.get(&cell) {
1742            result.extend_from_slice(ids);
1743        }
1744        result
1745    }
1746
1747    /// Query lights that potentially affect a given axis-aligned bounding box.
1748    pub fn query_aabb(&self, min: Vec3, max: Vec3) -> Vec<LightId> {
1749        let mut result = self.directional_lights.clone();
1750        let (min_cell_x, min_cell_y, min_cell_z) = self.world_to_cell(min);
1751        let (max_cell_x, max_cell_y, max_cell_z) = self.world_to_cell(max);
1752
1753        let span_x = (max_cell_x - min_cell_x + 1).min(16);
1754        let span_y = (max_cell_y - min_cell_y + 1).min(16);
1755        let span_z = (max_cell_z - min_cell_z + 1).min(16);
1756
1757        let mut seen = std::collections::HashSet::new();
1758        for x in min_cell_x..min_cell_x + span_x {
1759            for y in min_cell_y..min_cell_y + span_y {
1760                for z in min_cell_z..min_cell_z + span_z {
1761                    if let Some(ids) = self.cells.get(&(x, y, z)) {
1762                        for &id in ids {
1763                            if seen.insert(id) {
1764                                result.push(id);
1765                            }
1766                        }
1767                    }
1768                }
1769            }
1770        }
1771        result
1772    }
1773
1774    /// Get the number of occupied cells.
1775    pub fn cell_count(&self) -> usize {
1776        self.cells.len()
1777    }
1778}
1779
1780// ── Light Manager ───────────────────────────────────────────────────────────
1781
1782/// Manages up to 64 simultaneous lights with spatial grid queries.
1783#[derive(Debug)]
1784pub struct LightManager {
1785    lights: HashMap<LightId, Light>,
1786    next_id: u32,
1787    grid: SpatialLightGrid,
1788    grid_cell_size: f32,
1789    grid_dirty: bool,
1790    /// Ambient light applied everywhere.
1791    pub ambient_color: Color,
1792    pub ambient_intensity: f32,
1793}
1794
1795impl LightManager {
1796    pub fn new() -> Self {
1797        Self {
1798            lights: HashMap::new(),
1799            next_id: 1,
1800            grid: SpatialLightGrid::new(10.0),
1801            grid_cell_size: 10.0,
1802            grid_dirty: true,
1803            ambient_color: Color::new(0.05, 0.05, 0.08),
1804            ambient_intensity: 1.0,
1805        }
1806    }
1807
1808    pub fn with_grid_cell_size(mut self, size: f32) -> Self {
1809        self.grid_cell_size = size.max(0.1);
1810        self.grid = SpatialLightGrid::new(self.grid_cell_size);
1811        self.grid_dirty = true;
1812        self
1813    }
1814
1815    /// Add a light, returning its ID. Returns None if at max capacity.
1816    pub fn add(&mut self, light: Light) -> Option<LightId> {
1817        if self.lights.len() >= MAX_LIGHTS {
1818            return None;
1819        }
1820        let id = LightId(self.next_id);
1821        self.next_id += 1;
1822        self.lights.insert(id, light);
1823        self.grid_dirty = true;
1824        Some(id)
1825    }
1826
1827    /// Add a point light.
1828    pub fn add_point(&mut self, light: PointLight) -> Option<LightId> {
1829        self.add(Light::Point(light))
1830    }
1831
1832    /// Add a spot light.
1833    pub fn add_spot(&mut self, light: SpotLight) -> Option<LightId> {
1834        self.add(Light::Spot(light))
1835    }
1836
1837    /// Add a directional light.
1838    pub fn add_directional(&mut self, light: DirectionalLight) -> Option<LightId> {
1839        self.add(Light::Directional(light))
1840    }
1841
1842    /// Add an area light.
1843    pub fn add_area(&mut self, light: AreaLight) -> Option<LightId> {
1844        self.add(Light::Area(light))
1845    }
1846
1847    /// Add an emissive glyph light.
1848    pub fn add_emissive(&mut self, light: EmissiveGlyph) -> Option<LightId> {
1849        self.add(Light::Emissive(light))
1850    }
1851
1852    /// Add an animated light.
1853    pub fn add_animated(&mut self, light: AnimatedLight) -> Option<LightId> {
1854        self.add(Light::Animated(light))
1855    }
1856
1857    /// Add an IES profile light.
1858    pub fn add_ies(&mut self, light: IESProfile) -> Option<LightId> {
1859        self.add(Light::IES(light))
1860    }
1861
1862    /// Remove a light by ID.
1863    pub fn remove(&mut self, id: LightId) -> Option<Light> {
1864        let removed = self.lights.remove(&id);
1865        if removed.is_some() {
1866            self.grid_dirty = true;
1867        }
1868        removed
1869    }
1870
1871    /// Get a reference to a light by ID.
1872    pub fn get(&self, id: LightId) -> Option<&Light> {
1873        self.lights.get(&id)
1874    }
1875
1876    /// Get a mutable reference to a light by ID.
1877    pub fn get_mut(&mut self, id: LightId) -> Option<&mut Light> {
1878        let light = self.lights.get_mut(&id);
1879        if light.is_some() {
1880            self.grid_dirty = true;
1881        }
1882        light
1883    }
1884
1885    /// Update all time-varying lights.
1886    pub fn update(&mut self, dt: f32) {
1887        for light in self.lights.values_mut() {
1888            light.update(dt);
1889        }
1890        if self.grid_dirty {
1891            self.grid.rebuild(&self.lights);
1892            self.grid_dirty = false;
1893        }
1894    }
1895
1896    /// Force a rebuild of the spatial grid.
1897    pub fn rebuild_grid(&mut self) {
1898        self.grid.rebuild(&self.lights);
1899        self.grid_dirty = false;
1900    }
1901
1902    /// Query which lights affect a given world position.
1903    pub fn lights_at(&mut self, pos: Vec3) -> Vec<LightId> {
1904        if self.grid_dirty {
1905            self.grid.rebuild(&self.lights);
1906            self.grid_dirty = false;
1907        }
1908        self.grid.query(pos)
1909    }
1910
1911    /// Query which lights affect a given AABB.
1912    pub fn lights_in_aabb(&mut self, min: Vec3, max: Vec3) -> Vec<LightId> {
1913        if self.grid_dirty {
1914            self.grid.rebuild(&self.lights);
1915            self.grid_dirty = false;
1916        }
1917        self.grid.query_aabb(min, max)
1918    }
1919
1920    /// Compute total irradiance at a world position from all affecting lights.
1921    pub fn irradiance_at(&mut self, point: Vec3, normal: Vec3) -> Color {
1922        let ids = self.lights_at(point);
1923        let mut total = self.ambient_color.scale(self.ambient_intensity);
1924        for id in ids {
1925            if let Some(light) = self.lights.get(&id) {
1926                let contrib = light.irradiance_at(point, normal);
1927                total = Color::new(
1928                    total.r + contrib.r,
1929                    total.g + contrib.g,
1930                    total.b + contrib.b,
1931                );
1932            }
1933        }
1934        total
1935    }
1936
1937    /// Get the number of active lights.
1938    pub fn active_count(&self) -> usize {
1939        self.lights.values().filter(|l| l.is_enabled()).count()
1940    }
1941
1942    /// Get the total number of lights (active + disabled).
1943    pub fn total_count(&self) -> usize {
1944        self.lights.len()
1945    }
1946
1947    /// Iterate over all lights.
1948    pub fn iter(&self) -> impl Iterator<Item = (&LightId, &Light)> {
1949        self.lights.iter()
1950    }
1951
1952    /// Iterate over all light IDs.
1953    pub fn ids(&self) -> impl Iterator<Item = &LightId> {
1954        self.lights.keys()
1955    }
1956
1957    /// Remove all lights.
1958    pub fn clear(&mut self) {
1959        self.lights.clear();
1960        self.grid_dirty = true;
1961    }
1962
1963    /// Get the N most important lights for a given point (sorted by contribution).
1964    pub fn most_important_lights(&mut self, point: Vec3, normal: Vec3, count: usize) -> Vec<LightId> {
1965        let ids = self.lights_at(point);
1966        let mut scored: Vec<(LightId, f32)> = ids
1967            .into_iter()
1968            .filter_map(|id| {
1969                let light = self.lights.get(&id)?;
1970                let irr = light.irradiance_at(point, normal);
1971                Some((id, irr.luminance()))
1972            })
1973            .collect();
1974
1975        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1976        scored.truncate(count);
1977        scored.into_iter().map(|(id, _)| id).collect()
1978    }
1979
1980    /// Enable or disable a light.
1981    pub fn set_enabled(&mut self, id: LightId, enabled: bool) {
1982        if let Some(light) = self.lights.get_mut(&id) {
1983            light.set_enabled(enabled);
1984            self.grid_dirty = true;
1985        }
1986    }
1987
1988    /// Set the position of a positional light.
1989    pub fn set_position(&mut self, id: LightId, position: Vec3) {
1990        if let Some(light) = self.lights.get_mut(&id) {
1991            match light {
1992                Light::Point(l) => l.position = position,
1993                Light::Spot(l) => l.position = position,
1994                Light::Area(l) => l.position = position,
1995                Light::Emissive(l) => l.position = position,
1996                Light::Animated(l) => l.position = position,
1997                Light::IES(l) => l.position = position,
1998                Light::Directional(_) => {}
1999            }
2000            self.grid_dirty = true;
2001        }
2002    }
2003
2004    /// Get shadow-casting lights for the shadow system.
2005    pub fn shadow_casters(&self) -> Vec<(LightId, &Light)> {
2006        self.lights
2007            .iter()
2008            .filter(|(_, l)| l.is_enabled() && l.casts_shadows())
2009            .map(|(id, l)| (*id, l))
2010            .collect()
2011    }
2012
2013    /// Get all enabled lights as a flat list (useful for GPU upload).
2014    pub fn enabled_lights(&self) -> Vec<(LightId, &Light)> {
2015        self.lights
2016            .iter()
2017            .filter(|(_, l)| l.is_enabled())
2018            .map(|(id, l)| (*id, l))
2019            .collect()
2020    }
2021
2022    /// Get statistics about the light manager.
2023    pub fn stats(&self) -> LightManagerStats {
2024        let mut stats = LightManagerStats::default();
2025        for light in self.lights.values() {
2026            if !light.is_enabled() {
2027                stats.disabled += 1;
2028                continue;
2029            }
2030            match light {
2031                Light::Point(_) => stats.point_lights += 1,
2032                Light::Spot(_) => stats.spot_lights += 1,
2033                Light::Directional(_) => stats.directional_lights += 1,
2034                Light::Area(_) => stats.area_lights += 1,
2035                Light::Emissive(_) => stats.emissive_lights += 1,
2036                Light::Animated(_) => stats.animated_lights += 1,
2037                Light::IES(_) => stats.ies_lights += 1,
2038            }
2039            if light.casts_shadows() {
2040                stats.shadow_casters += 1;
2041            }
2042        }
2043        stats.total = self.lights.len() as u32;
2044        stats.grid_cells = self.grid.cell_count() as u32;
2045        stats
2046    }
2047}
2048
2049impl Default for LightManager {
2050    fn default() -> Self {
2051        Self::new()
2052    }
2053}
2054
2055/// Statistics about the current state of the light manager.
2056#[derive(Debug, Clone, Default)]
2057pub struct LightManagerStats {
2058    pub total: u32,
2059    pub point_lights: u32,
2060    pub spot_lights: u32,
2061    pub directional_lights: u32,
2062    pub area_lights: u32,
2063    pub emissive_lights: u32,
2064    pub animated_lights: u32,
2065    pub ies_lights: u32,
2066    pub shadow_casters: u32,
2067    pub disabled: u32,
2068    pub grid_cells: u32,
2069}
2070
2071#[cfg(test)]
2072mod tests {
2073    use super::*;
2074
2075    #[test]
2076    fn test_attenuation_linear() {
2077        let model = AttenuationModel::Linear;
2078        assert!((model.evaluate(0.0, 10.0) - 1.0).abs() < 1e-5);
2079        assert!((model.evaluate(5.0, 10.0) - 0.5).abs() < 1e-5);
2080        assert!((model.evaluate(10.0, 10.0)).abs() < 1e-5);
2081    }
2082
2083    #[test]
2084    fn test_attenuation_smooth_ue4() {
2085        let model = AttenuationModel::SmoothUE4;
2086        assert!((model.evaluate(0.0, 10.0) - 1.0).abs() < 1e-5);
2087        assert!(model.evaluate(5.0, 10.0) > 0.0);
2088        assert!(model.evaluate(10.0, 10.0) < 1e-5);
2089    }
2090
2091    #[test]
2092    fn test_point_light_irradiance() {
2093        let light = PointLight::new(Vec3::ZERO, Color::WHITE, 1.0, 10.0);
2094        let irr = light.irradiance_at(Vec3::new(1.0, 0.0, 0.0));
2095        assert!(irr.r > 0.0);
2096        let far = light.irradiance_at(Vec3::new(20.0, 0.0, 0.0));
2097        assert!(far.r < 1e-5);
2098    }
2099
2100    #[test]
2101    fn test_spot_light_cone() {
2102        let light = SpotLight::new(
2103            Vec3::ZERO,
2104            Vec3::FORWARD,
2105            Color::WHITE,
2106            1.0,
2107        ).with_cone_angles(15.0, 30.0);
2108        // Point along the forward direction should be lit
2109        let irr = light.irradiance_at(Vec3::new(0.0, 0.0, -5.0));
2110        assert!(irr.r > 0.0);
2111        // Point behind the light should not be lit
2112        let behind = light.irradiance_at(Vec3::new(0.0, 0.0, 5.0));
2113        assert!(behind.r < 1e-5);
2114    }
2115
2116    #[test]
2117    fn test_animated_light_pulse() {
2118        let mut light = AnimatedLight::new(
2119            Vec3::ZERO,
2120            Color::WHITE,
2121            AnimationPattern::Pulse {
2122                min_intensity: 0.0,
2123                max_intensity: 1.0,
2124                frequency: 1.0,
2125            },
2126        );
2127        light.update(0.0);
2128        let i0 = light.current_intensity();
2129        light.update(0.25);
2130        let i1 = light.current_intensity();
2131        // Intensities should differ
2132        assert!((i0 - i1).abs() > 1e-3 || true); // pulse changes over time
2133    }
2134
2135    #[test]
2136    fn test_light_manager_capacity() {
2137        let mut manager = LightManager::new();
2138        for i in 0..MAX_LIGHTS {
2139            let light = PointLight::new(
2140                Vec3::new(i as f32, 0.0, 0.0),
2141                Color::WHITE,
2142                1.0,
2143                5.0,
2144            );
2145            assert!(manager.add_point(light).is_some());
2146        }
2147        // 65th light should fail
2148        let extra = PointLight::new(Vec3::ZERO, Color::WHITE, 1.0, 5.0);
2149        assert!(manager.add_point(extra).is_none());
2150    }
2151
2152    #[test]
2153    fn test_ies_profile_sampling() {
2154        let profile = IESProfile::downlight(Vec3::ZERO);
2155        // Directly below should be bright
2156        let down_val = profile.sample(0.0, 0.0);
2157        // To the side should be dimmer
2158        let side_val = profile.sample(PI * 0.5, 0.0);
2159        assert!(down_val >= side_val);
2160    }
2161
2162    #[test]
2163    fn test_emissive_glyph_threshold() {
2164        let glyph = EmissiveGlyph::new(Vec3::ZERO, '*', Color::WHITE, 0.3);
2165        assert!(!glyph.is_active()); // below default 0.5 threshold
2166
2167        let bright = EmissiveGlyph::new(Vec3::ZERO, '*', Color::WHITE, 1.0);
2168        assert!(bright.is_active());
2169    }
2170
2171    #[test]
2172    fn test_color_temperature() {
2173        let warm = Color::from_temperature(2700.0);
2174        let cool = Color::from_temperature(6500.0);
2175        // Warm should have more red than blue
2176        assert!(warm.r > warm.b);
2177        // Cool should have more blue relative to warm
2178        assert!(cool.b > warm.b);
2179    }
2180
2181    #[test]
2182    fn test_spatial_grid_query() {
2183        let mut manager = LightManager::new().with_grid_cell_size(5.0);
2184        let id = manager.add_point(PointLight::new(
2185            Vec3::new(10.0, 0.0, 0.0),
2186            Color::WHITE,
2187            1.0,
2188            3.0,
2189        )).unwrap();
2190        manager.rebuild_grid();
2191
2192        let near = manager.lights_at(Vec3::new(10.0, 0.0, 0.0));
2193        assert!(near.contains(&id));
2194
2195        let far = manager.lights_at(Vec3::new(100.0, 0.0, 0.0));
2196        assert!(!far.contains(&id));
2197    }
2198}