eulumdat_photweb/
mesh.rs

1//! Mesh generation for 3D photometric visualizations
2
3use crate::PhotometricWeb;
4
5/// A 3D vertex with position and normal.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct Vertex {
8    /// X coordinate
9    pub x: f32,
10    /// Y coordinate
11    pub y: f32,
12    /// Z coordinate
13    pub z: f32,
14    /// Normal X component
15    pub nx: f32,
16    /// Normal Y component
17    pub ny: f32,
18    /// Normal Z component
19    pub nz: f32,
20}
21
22impl Vertex {
23    /// Create a new vertex with position only (normal will be computed later).
24    pub fn new(x: f32, y: f32, z: f32) -> Self {
25        Self {
26            x,
27            y,
28            z,
29            nx: 0.0,
30            ny: 0.0,
31            nz: 0.0,
32        }
33    }
34
35    /// Create a vertex with position and normal.
36    pub fn with_normal(x: f32, y: f32, z: f32, nx: f32, ny: f32, nz: f32) -> Self {
37        Self {
38            x,
39            y,
40            z,
41            nx,
42            ny,
43            nz,
44        }
45    }
46}
47
48/// A 3D mesh representing the LDC (Luminous Distribution Curve) solid.
49///
50/// This is the "photometric solid" - a 3D surface where distance from
51/// center equals intensity at that angle.
52#[derive(Debug, Clone)]
53pub struct LdcMesh {
54    /// Vertex positions and normals
55    pub vertices: Vec<Vertex>,
56    /// Triangle indices (3 per triangle)
57    pub indices: Vec<u32>,
58    /// Number of C-plane divisions
59    pub c_divisions: usize,
60    /// Number of gamma divisions
61    pub g_divisions: usize,
62}
63
64impl LdcMesh {
65    /// Generate an LDC solid mesh from a PhotometricWeb.
66    ///
67    /// # Arguments
68    /// * `web` - The photometric web to generate from
69    /// * `c_step` - Angle step for C-planes in degrees (e.g., 5.0 for smooth, 15.0 for fast)
70    /// * `g_step` - Angle step for gamma in degrees
71    /// * `scale` - Scale factor for the mesh (1.0 = normalized intensity as radius)
72    ///
73    /// # Coordinate System
74    /// - Y axis points up (nadir at -Y, zenith at +Y)
75    /// - X-Z plane is horizontal
76    /// - C=0° is along +Z axis, C=90° is along +X axis
77    pub fn from_photweb(web: &PhotometricWeb, c_step: f64, g_step: f64, scale: f32) -> Self {
78        let mut vertices = Vec::new();
79        let mut indices = Vec::new();
80
81        // Calculate grid dimensions
82        let c_count = (360.0 / c_step).ceil() as usize + 1;
83        let g_count = (180.0 / g_step).ceil() as usize + 1;
84
85        // Generate vertices
86        for gi in 0..g_count {
87            let g_angle = (gi as f64 * g_step).min(180.0);
88            let g_rad = g_angle.to_radians();
89
90            for ci in 0..c_count {
91                let c_angle = (ci as f64 * c_step).min(360.0);
92                let c_rad = c_angle.to_radians();
93
94                // Get normalized intensity as radius
95                let radius = web.sample_normalized(c_angle, g_angle) as f32 * scale;
96
97                // Spherical to Cartesian conversion
98                // gamma = 0 is nadir (-Y), gamma = 90 is horizontal, gamma = 180 is zenith (+Y)
99                let sin_g = g_rad.sin() as f32;
100                let cos_g = g_rad.cos() as f32;
101                let sin_c = c_rad.sin() as f32;
102                let cos_c = c_rad.cos() as f32;
103
104                let x = radius * sin_g * sin_c;
105                let y = -radius * cos_g; // Negative because gamma=0 is down
106                let z = radius * sin_g * cos_c;
107
108                // Normal points outward (same direction as position for a sphere-like surface)
109                let len = (x * x + y * y + z * z).sqrt();
110                let (nx, ny, nz) = if len > 0.0001 {
111                    (x / len, y / len, z / len)
112                } else {
113                    (0.0, -1.0, 0.0) // Default normal pointing down for degenerate case
114                };
115
116                vertices.push(Vertex::with_normal(x, y, z, nx, ny, nz));
117            }
118        }
119
120        // Generate triangle indices
121        // Connect vertices in a grid pattern
122        for gi in 0..g_count - 1 {
123            for ci in 0..c_count - 1 {
124                let i00 = (gi * c_count + ci) as u32;
125                let i01 = (gi * c_count + ci + 1) as u32;
126                let i10 = ((gi + 1) * c_count + ci) as u32;
127                let i11 = ((gi + 1) * c_count + ci + 1) as u32;
128
129                // Two triangles per quad
130                // Triangle 1: i00, i10, i01
131                indices.push(i00);
132                indices.push(i10);
133                indices.push(i01);
134
135                // Triangle 2: i01, i10, i11
136                indices.push(i01);
137                indices.push(i10);
138                indices.push(i11);
139            }
140        }
141
142        Self {
143            vertices,
144            indices,
145            c_divisions: c_count,
146            g_divisions: g_count,
147        }
148    }
149
150    /// Get vertex positions as a flat array [x0, y0, z0, x1, y1, z1, ...].
151    ///
152    /// Useful for graphics APIs that expect interleaved or separate position data.
153    pub fn positions_flat(&self) -> Vec<f32> {
154        self.vertices.iter().flat_map(|v| [v.x, v.y, v.z]).collect()
155    }
156
157    /// Get vertex normals as a flat array [nx0, ny0, nz0, nx1, ny1, nz1, ...].
158    pub fn normals_flat(&self) -> Vec<f32> {
159        self.vertices
160            .iter()
161            .flat_map(|v| [v.nx, v.ny, v.nz])
162            .collect()
163    }
164
165    /// Get the number of triangles in the mesh.
166    pub fn triangle_count(&self) -> usize {
167        self.indices.len() / 3
168    }
169
170    /// Get the number of vertices in the mesh.
171    pub fn vertex_count(&self) -> usize {
172        self.vertices.len()
173    }
174}
175
176impl PhotometricWeb {
177    /// Generate LDC solid mesh vertices.
178    ///
179    /// Convenience method that creates an LdcMesh.
180    pub fn generate_ldc_mesh(&self, c_step: f64, g_step: f64, scale: f32) -> LdcMesh {
181        LdcMesh::from_photweb(self, c_step, g_step, scale)
182    }
183
184    /// Generate just the vertex positions for the LDC solid.
185    ///
186    /// Returns a vector of (x, y, z) tuples.
187    pub fn generate_ldc_vertices(
188        &self,
189        c_step: f64,
190        g_step: f64,
191        scale: f32,
192    ) -> Vec<(f32, f32, f32)> {
193        let mesh = self.generate_ldc_mesh(c_step, g_step, scale);
194        mesh.vertices.iter().map(|v| (v.x, v.y, v.z)).collect()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use eulumdat::Symmetry;
202
203    fn create_uniform_web() -> PhotometricWeb {
204        // Uniform intensity in all directions = perfect sphere
205        PhotometricWeb::new(
206            vec![0.0, 90.0, 180.0, 270.0],
207            vec![0.0, 45.0, 90.0, 135.0, 180.0],
208            vec![
209                vec![100.0, 100.0, 100.0, 100.0, 100.0],
210                vec![100.0, 100.0, 100.0, 100.0, 100.0],
211                vec![100.0, 100.0, 100.0, 100.0, 100.0],
212                vec![100.0, 100.0, 100.0, 100.0, 100.0],
213            ],
214            Symmetry::None,
215        )
216    }
217
218    #[test]
219    fn test_ldc_mesh_generation() {
220        let web = create_uniform_web();
221        let mesh = web.generate_ldc_mesh(45.0, 45.0, 1.0);
222
223        // Should have vertices and indices
224        assert!(mesh.vertex_count() > 0);
225        assert!(mesh.triangle_count() > 0);
226
227        // Indices should be valid
228        for &idx in &mesh.indices {
229            assert!((idx as usize) < mesh.vertex_count());
230        }
231    }
232
233    #[test]
234    fn test_uniform_sphere_radii() {
235        let web = create_uniform_web();
236        let mesh = web.generate_ldc_mesh(30.0, 30.0, 1.0);
237
238        // For uniform intensity, all vertices should be at approximately same distance from origin
239        for v in &mesh.vertices {
240            let r = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
241            // Allow some tolerance for edge cases (poles)
242            if r > 0.01 {
243                assert!((r - 1.0).abs() < 0.01, "Expected radius ~1.0, got {}", r);
244            }
245        }
246    }
247
248    #[test]
249    fn test_nadir_zenith_positions() {
250        let web = create_uniform_web();
251        let mesh = web.generate_ldc_mesh(90.0, 90.0, 1.0);
252
253        // Find vertex at gamma=0 (nadir) - should be at (0, -1, 0) for normalized
254        let nadir = mesh.vertices.iter().find(|v| v.y < -0.9);
255        assert!(nadir.is_some(), "Should have nadir vertex");
256
257        // Find vertex at gamma=180 (zenith) - should be at (0, +1, 0) for normalized
258        let zenith = mesh.vertices.iter().find(|v| v.y > 0.9);
259        assert!(zenith.is_some(), "Should have zenith vertex");
260    }
261
262    #[test]
263    fn test_flat_arrays() {
264        let web = create_uniform_web();
265        let mesh = web.generate_ldc_mesh(90.0, 90.0, 1.0);
266
267        let positions = mesh.positions_flat();
268        let normals = mesh.normals_flat();
269
270        assert_eq!(positions.len(), mesh.vertex_count() * 3);
271        assert_eq!(normals.len(), mesh.vertex_count() * 3);
272    }
273}