Skip to main content

concinnity_core/components/
geometry.rs

1// src/components/geometry.rs
2//
3// Model matrices and normals computed from asset data. These live here rather
4// than with the schema types because those stay serde-only data:
5// anything that computes over an authored struct belongs on this side of the
6// line. Exposed as extension traits so call sites keep method syntax
7// (`prop.model_matrix()`).
8
9use crate::components::{GlassPanel, InstancedProp, RectAreaLight, SpotLight};
10use crate::math::{cos, sqrt};
11
12/// Widest half-angle a spot cone may open to. Past this the cone degenerates
13/// toward a hemisphere and the clustered sphere bound stops being useful.
14pub const SPOT_MAX_ANGLE_DEG: f32 = 89.9;
15
16// `v` scaled to unit length, or `fallback` when it is too short to have a
17// direction. The one degenerate-direction policy behind every authored
18// normal / direction field below.
19fn normalize_or(v: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
20    let len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
21    if len < 1e-6 {
22        fallback
23    } else {
24        [v[0] / len, v[1] / len, v[2] / len]
25    }
26}
27
28/// Per-instance model matrices for an [InstancedProp].
29pub trait InstancedPropGeometry {
30    /// Column-major model matrix for the i-th instance, or `None` when the
31    /// index is past the instance list.
32    fn instance_model_matrix(&self, idx: usize) -> Option<[[f32; 4]; 4]>;
33}
34
35impl InstancedPropGeometry for InstancedProp {
36    /// Build a column-major model matrix for the i-th instance.
37    /// Order matches `Prop::model_matrix`: scale, then YXZ rotation, then translation.
38    fn instance_model_matrix(&self, idx: usize) -> Option<[[f32; 4]; 4]> {
39        let xform = self.instances.get(idx)?;
40        Some(crate::gfx::transform::trs_matrix(
41            xform.position,
42            xform.rotation_deg,
43            xform.scale,
44        ))
45    }
46}
47
48/// Cone direction and angular falloff cosines for a [SpotLight].
49pub trait SpotLightGeometry {
50    /// Unit-length cone axis.
51    fn unit_direction(&self) -> [f32; 3];
52    /// Cosine of the inner half-angle: the widest angle still at full
53    /// brightness.
54    fn cos_inner(&self) -> f32;
55    /// Cosine of the outer half-angle: the angle at which the cone is black.
56    fn cos_outer(&self) -> f32;
57}
58
59impl SpotLightGeometry for SpotLight {
60    /// Unit-length cone axis, falling back to straight down when the authored
61    /// `direction` is degenerate.
62    fn unit_direction(&self) -> [f32; 3] {
63        normalize_or(self.direction, [0.0, -1.0, 0.0])
64    }
65
66    /// Cosine of the inner half-angle: the widest angle still at full brightness.
67    fn cos_inner(&self) -> f32 {
68        cos(self.inner_angle.clamp(0.0, self.outer_angle).to_radians())
69    }
70
71    /// Cosine of the outer half-angle: the angle at which the cone reaches black.
72    fn cos_outer(&self) -> f32 {
73        cos(self.outer_angle.clamp(0.0, SPOT_MAX_ANGLE_DEG).to_radians())
74    }
75}
76
77/// Unit-length facing normal for a [GlassPanel].
78pub trait GlassPanelGeometry {
79    /// Unit-length facing direction.
80    fn unit_normal(&self) -> [f32; 3];
81}
82
83impl GlassPanelGeometry for GlassPanel {
84    /// Unit-length facing direction, falling back to `+Z` when the authored
85    /// `normal` is degenerate. The build-time quad generator and the runtime
86    /// shader both rely on a usable normal.
87    fn unit_normal(&self) -> [f32; 3] {
88        normalize_or(self.normal, [0.0, 0.0, 1.0])
89    }
90}
91
92/// Unit-length emission normal for a [RectAreaLight].
93pub trait RectAreaLightGeometry {
94    /// Unit-length emission direction.
95    fn unit_normal(&self) -> [f32; 3];
96}
97
98impl RectAreaLightGeometry for RectAreaLight {
99    /// Unit-length emission direction, falling back to straight down when the
100    /// authored `normal` is degenerate (the panel default emits downward).
101    fn unit_normal(&self) -> [f32; 3] {
102        normalize_or(self.normal, [0.0, -1.0, 0.0])
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn spot(direction: [f32; 3], inner: f32, outer: f32) -> SpotLight {
111        SpotLight {
112            direction,
113            inner_angle: inner,
114            outer_angle: outer,
115            ..SpotLight::default()
116        }
117    }
118
119    #[test]
120    fn spot_direction_normalises() {
121        let d = spot([0.0, -4.0, 0.0], 10.0, 20.0).unit_direction();
122        assert_eq!(d, [0.0, -1.0, 0.0]);
123    }
124
125    #[test]
126    fn degenerate_spot_direction_falls_back_to_down() {
127        assert_eq!(
128            spot([0.0; 3], 10.0, 20.0).unit_direction(),
129            [0.0, -1.0, 0.0]
130        );
131    }
132
133    #[test]
134    fn rect_normal_normalises_and_falls_back_to_down() {
135        let lit = RectAreaLight {
136            normal: [0.0, 0.0, 3.0],
137            ..RectAreaLight::default()
138        };
139        assert_eq!(lit.unit_normal(), [0.0, 0.0, 1.0]);
140        let degenerate = RectAreaLight {
141            normal: [0.0; 3],
142            ..RectAreaLight::default()
143        };
144        assert_eq!(degenerate.unit_normal(), [0.0, -1.0, 0.0]);
145    }
146
147    // The shader divides by (cos_inner - cos_outer), so the inner cone must never
148    // open wider than the outer one.
149    #[test]
150    fn spot_inner_cosine_never_falls_below_the_outer() {
151        for (inner, outer) in [(10.0, 20.0), (45.0, 20.0), (0.0, 0.0), (-5.0, 30.0)] {
152            let s = spot([0.0, -1.0, 0.0], inner, outer);
153            assert!(
154                s.cos_inner() >= s.cos_outer() - 1e-6,
155                "inner {inner} outer {outer}"
156            );
157        }
158    }
159
160    #[test]
161    fn spot_cosines_match_the_authored_angles() {
162        let s = spot([0.0, -1.0, 0.0], 15.0, 30.0);
163        assert!((s.cos_inner() - 15.0f32.to_radians().cos()).abs() < 1e-6);
164        assert!((s.cos_outer() - 30.0f32.to_radians().cos()).abs() < 1e-6);
165    }
166
167    // A hemisphere-wide cone would make the clustered sphere bound useless.
168    #[test]
169    fn spot_outer_angle_capped() {
170        let s = spot([0.0, -1.0, 0.0], 0.0, 180.0);
171        assert!((s.cos_outer() - SPOT_MAX_ANGLE_DEG.to_radians().cos()).abs() < 1e-6);
172    }
173}