eulumdat_photweb/
mesh.rs

1//! Mesh generation for 3D photometric visualizations
2
3use crate::PhotometricWeb;
4
5// ============================================================================
6// Color utilities (platform-independent)
7// ============================================================================
8
9/// Color mode for 3D mesh visualization
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ColorMode {
12    /// Heatmap based on intensity (blue -> cyan -> green -> yellow -> red)
13    #[default]
14    Heatmap,
15    /// Rainbow colors based on C-plane angle
16    CPlaneRainbow,
17    /// Solid color (default blue)
18    Solid,
19}
20
21/// RGBA color (0.0 - 1.0)
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct Color {
24    pub r: f32,
25    pub g: f32,
26    pub b: f32,
27    pub a: f32,
28}
29
30impl Color {
31    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
32        Self { r, g, b, a }
33    }
34
35    /// Create color from heatmap value (0.0-1.0)
36    /// Blue -> Cyan -> Green -> Yellow -> Red
37    pub fn from_heatmap(intensity: f32) -> Self {
38        let v = intensity.clamp(0.0, 1.0);
39        let (r, g, b) = if v < 0.25 {
40            let t = v / 0.25;
41            (0.0, t, 1.0)
42        } else if v < 0.5 {
43            let t = (v - 0.25) / 0.25;
44            (0.0, 1.0, 1.0 - t)
45        } else if v < 0.75 {
46            let t = (v - 0.5) / 0.25;
47            (t, 1.0, 0.0)
48        } else {
49            let t = (v - 0.75) / 0.25;
50            (1.0, 1.0 - t, 0.0)
51        };
52        Self::new(r, g, b, 0.9)
53    }
54
55    /// Create rainbow color from angle (0-360 degrees)
56    pub fn from_c_plane_angle(c_angle: f32) -> Self {
57        let hue = c_angle / 360.0;
58        let (r, g, b) = hsl_to_rgb(hue, 0.7, 0.5);
59        Self::new(r, g, b, 0.9)
60    }
61
62    /// Default solid color (semi-transparent blue)
63    pub fn solid_default() -> Self {
64        Self::new(0.3, 0.5, 0.9, 0.9)
65    }
66}
67
68/// Convert HSL to RGB (all values 0.0-1.0)
69pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
70    if s == 0.0 {
71        return (l, l, l);
72    }
73
74    let q = if l < 0.5 {
75        l * (1.0 + s)
76    } else {
77        l + s - l * s
78    };
79    let p = 2.0 * l - q;
80
81    fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
82        if t < 0.0 {
83            t += 1.0;
84        }
85        if t > 1.0 {
86            t -= 1.0;
87        }
88        if t < 1.0 / 6.0 {
89            return p + (q - p) * 6.0 * t;
90        }
91        if t < 1.0 / 2.0 {
92            return q;
93        }
94        if t < 2.0 / 3.0 {
95            return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
96        }
97        p
98    }
99
100    (
101        hue_to_rgb(p, q, h + 1.0 / 3.0),
102        hue_to_rgb(p, q, h),
103        hue_to_rgb(p, q, h - 1.0 / 3.0),
104    )
105}
106
107/// A 3D vertex with position and normal.
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct Vertex {
110    /// X coordinate
111    pub x: f32,
112    /// Y coordinate
113    pub y: f32,
114    /// Z coordinate
115    pub z: f32,
116    /// Normal X component
117    pub nx: f32,
118    /// Normal Y component
119    pub ny: f32,
120    /// Normal Z component
121    pub nz: f32,
122}
123
124impl Vertex {
125    /// Create a new vertex with position only (normal will be computed later).
126    pub fn new(x: f32, y: f32, z: f32) -> Self {
127        Self {
128            x,
129            y,
130            z,
131            nx: 0.0,
132            ny: 0.0,
133            nz: 0.0,
134        }
135    }
136
137    /// Create a vertex with position and normal.
138    pub fn with_normal(x: f32, y: f32, z: f32, nx: f32, ny: f32, nz: f32) -> Self {
139        Self {
140            x,
141            y,
142            z,
143            nx,
144            ny,
145            nz,
146        }
147    }
148}
149
150/// A 3D mesh representing the LDC (Luminous Distribution Curve) solid.
151///
152/// This is the "photometric solid" - a 3D surface where distance from
153/// center equals intensity at that angle.
154#[derive(Debug, Clone)]
155pub struct LdcMesh {
156    /// Vertex positions and normals
157    pub vertices: Vec<Vertex>,
158    /// Triangle indices (3 per triangle)
159    pub indices: Vec<u32>,
160    /// Number of C-plane divisions
161    pub c_divisions: usize,
162    /// Number of gamma divisions
163    pub g_divisions: usize,
164}
165
166impl LdcMesh {
167    /// Generate an LDC solid mesh from a PhotometricWeb.
168    ///
169    /// # Arguments
170    /// * `web` - The photometric web to generate from
171    /// * `c_step` - Angle step for C-planes in degrees (e.g., 5.0 for smooth, 15.0 for fast)
172    /// * `g_step` - Angle step for gamma in degrees
173    /// * `scale` - Scale factor for the mesh (1.0 = normalized intensity as radius)
174    ///
175    /// # Coordinate System
176    /// - Y axis points up (nadir at -Y, zenith at +Y)
177    /// - X-Z plane is horizontal
178    /// - C=0° is along +Z axis, C=90° is along +X axis
179    pub fn from_photweb(web: &PhotometricWeb, c_step: f64, g_step: f64, scale: f32) -> Self {
180        let mut vertices = Vec::new();
181        let mut indices = Vec::new();
182
183        // Calculate grid dimensions
184        let c_count = (360.0 / c_step).ceil() as usize + 1;
185        let g_count = (180.0 / g_step).ceil() as usize + 1;
186
187        // Generate vertices
188        for gi in 0..g_count {
189            let g_angle = (gi as f64 * g_step).min(180.0);
190            let g_rad = g_angle.to_radians();
191
192            for ci in 0..c_count {
193                let c_angle = (ci as f64 * c_step).min(360.0);
194                let c_rad = c_angle.to_radians();
195
196                // Get normalized intensity as radius
197                let radius = web.sample_normalized(c_angle, g_angle) as f32 * scale;
198
199                // Spherical to Cartesian conversion
200                // gamma = 0 is nadir (-Y), gamma = 90 is horizontal, gamma = 180 is zenith (+Y)
201                let sin_g = g_rad.sin() as f32;
202                let cos_g = g_rad.cos() as f32;
203                let sin_c = c_rad.sin() as f32;
204                let cos_c = c_rad.cos() as f32;
205
206                let x = radius * sin_g * sin_c;
207                let y = -radius * cos_g; // Negative because gamma=0 is down
208                let z = radius * sin_g * cos_c;
209
210                // Normal points outward (same direction as position for a sphere-like surface)
211                let len = (x * x + y * y + z * z).sqrt();
212                let (nx, ny, nz) = if len > 0.0001 {
213                    (x / len, y / len, z / len)
214                } else {
215                    (0.0, -1.0, 0.0) // Default normal pointing down for degenerate case
216                };
217
218                vertices.push(Vertex::with_normal(x, y, z, nx, ny, nz));
219            }
220        }
221
222        // Generate triangle indices
223        // Connect vertices in a grid pattern
224        for gi in 0..g_count - 1 {
225            for ci in 0..c_count - 1 {
226                let i00 = (gi * c_count + ci) as u32;
227                let i01 = (gi * c_count + ci + 1) as u32;
228                let i10 = ((gi + 1) * c_count + ci) as u32;
229                let i11 = ((gi + 1) * c_count + ci + 1) as u32;
230
231                // Two triangles per quad
232                // Triangle 1: i00, i10, i01
233                indices.push(i00);
234                indices.push(i10);
235                indices.push(i01);
236
237                // Triangle 2: i01, i10, i11
238                indices.push(i01);
239                indices.push(i10);
240                indices.push(i11);
241            }
242        }
243
244        Self {
245            vertices,
246            indices,
247            c_divisions: c_count,
248            g_divisions: g_count,
249        }
250    }
251
252    /// Get vertex positions as a flat array [x0, y0, z0, x1, y1, z1, ...].
253    ///
254    /// Useful for graphics APIs that expect interleaved or separate position data.
255    pub fn positions_flat(&self) -> Vec<f32> {
256        self.vertices.iter().flat_map(|v| [v.x, v.y, v.z]).collect()
257    }
258
259    /// Get vertex normals as a flat array [nx0, ny0, nz0, nx1, ny1, nz1, ...].
260    pub fn normals_flat(&self) -> Vec<f32> {
261        self.vertices
262            .iter()
263            .flat_map(|v| [v.nx, v.ny, v.nz])
264            .collect()
265    }
266
267    /// Get the number of triangles in the mesh.
268    pub fn triangle_count(&self) -> usize {
269        self.indices.len() / 3
270    }
271
272    /// Get the number of vertices in the mesh.
273    pub fn vertex_count(&self) -> usize {
274        self.vertices.len()
275    }
276
277    /// Generate per-vertex colors based on mode.
278    ///
279    /// Uses the photometric web to sample intensity at each vertex's angle.
280    pub fn generate_colors(
281        &self,
282        web: &PhotometricWeb,
283        c_step: f64,
284        g_step: f64,
285        mode: ColorMode,
286    ) -> Vec<Color> {
287        let mut colors = Vec::with_capacity(self.vertex_count());
288
289        for gi in 0..self.g_divisions {
290            let g_angle = (gi as f64 * g_step).min(180.0);
291            for ci in 0..self.c_divisions {
292                let c_angle = (ci as f64 * c_step).min(360.0);
293
294                let color = match mode {
295                    ColorMode::Heatmap => {
296                        let intensity = web.sample_normalized(c_angle, g_angle) as f32;
297                        Color::from_heatmap(intensity)
298                    }
299                    ColorMode::CPlaneRainbow => Color::from_c_plane_angle(c_angle as f32),
300                    ColorMode::Solid => Color::solid_default(),
301                };
302                colors.push(color);
303            }
304        }
305        colors
306    }
307
308    /// Get colors as a flat RGBA array [r0, g0, b0, a0, r1, g1, b1, a1, ...]
309    pub fn colors_flat(colors: &[Color]) -> Vec<f32> {
310        colors.iter().flat_map(|c| [c.r, c.g, c.b, c.a]).collect()
311    }
312}
313
314/// A colored 3D mesh with positions, normals, colors, and indices.
315///
316/// This is a convenience wrapper that combines `LdcMesh` with per-vertex colors.
317#[derive(Debug, Clone)]
318pub struct ColoredLdcMesh {
319    /// The base mesh (positions, normals, indices)
320    pub mesh: LdcMesh,
321    /// Per-vertex colors
322    pub colors: Vec<Color>,
323    /// The color mode used to generate colors
324    pub color_mode: ColorMode,
325}
326
327impl ColoredLdcMesh {
328    /// Generate a colored LDC mesh from a PhotometricWeb.
329    ///
330    /// # Arguments
331    /// * `web` - The photometric web to generate from
332    /// * `c_step` - Angle step for C-planes in degrees
333    /// * `g_step` - Angle step for gamma in degrees
334    /// * `scale` - Scale factor for the mesh
335    /// * `color_mode` - How to color the vertices
336    pub fn from_photweb(
337        web: &PhotometricWeb,
338        c_step: f64,
339        g_step: f64,
340        scale: f32,
341        color_mode: ColorMode,
342    ) -> Self {
343        let mesh = LdcMesh::from_photweb(web, c_step, g_step, scale);
344        let colors = mesh.generate_colors(web, c_step, g_step, color_mode);
345        Self {
346            mesh,
347            colors,
348            color_mode,
349        }
350    }
351
352    /// Get vertex positions as a flat array.
353    pub fn positions_flat(&self) -> Vec<f32> {
354        self.mesh.positions_flat()
355    }
356
357    /// Get vertex normals as a flat array.
358    pub fn normals_flat(&self) -> Vec<f32> {
359        self.mesh.normals_flat()
360    }
361
362    /// Get vertex colors as a flat RGBA array.
363    pub fn colors_flat(&self) -> Vec<f32> {
364        LdcMesh::colors_flat(&self.colors)
365    }
366
367    /// Get triangle indices.
368    pub fn indices(&self) -> &[u32] {
369        &self.mesh.indices
370    }
371
372    /// Get vertex count.
373    pub fn vertex_count(&self) -> usize {
374        self.mesh.vertex_count()
375    }
376
377    /// Get index count.
378    pub fn index_count(&self) -> usize {
379        self.mesh.indices.len()
380    }
381}
382
383impl PhotometricWeb {
384    /// Generate LDC solid mesh vertices.
385    ///
386    /// Convenience method that creates an LdcMesh.
387    pub fn generate_ldc_mesh(&self, c_step: f64, g_step: f64, scale: f32) -> LdcMesh {
388        LdcMesh::from_photweb(self, c_step, g_step, scale)
389    }
390
391    /// Generate a colored LDC solid mesh.
392    ///
393    /// Convenience method that creates a ColoredLdcMesh with positions, normals, colors, and indices.
394    pub fn generate_colored_ldc_mesh(
395        &self,
396        c_step: f64,
397        g_step: f64,
398        scale: f32,
399        color_mode: ColorMode,
400    ) -> ColoredLdcMesh {
401        ColoredLdcMesh::from_photweb(self, c_step, g_step, scale, color_mode)
402    }
403
404    /// Generate just the vertex positions for the LDC solid.
405    ///
406    /// Returns a vector of (x, y, z) tuples.
407    pub fn generate_ldc_vertices(
408        &self,
409        c_step: f64,
410        g_step: f64,
411        scale: f32,
412    ) -> Vec<(f32, f32, f32)> {
413        let mesh = self.generate_ldc_mesh(c_step, g_step, scale);
414        mesh.vertices.iter().map(|v| (v.x, v.y, v.z)).collect()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use eulumdat::Symmetry;
422
423    fn create_uniform_web() -> PhotometricWeb {
424        // Uniform intensity in all directions = perfect sphere
425        PhotometricWeb::new(
426            vec![0.0, 90.0, 180.0, 270.0],
427            vec![0.0, 45.0, 90.0, 135.0, 180.0],
428            vec![
429                vec![100.0, 100.0, 100.0, 100.0, 100.0],
430                vec![100.0, 100.0, 100.0, 100.0, 100.0],
431                vec![100.0, 100.0, 100.0, 100.0, 100.0],
432                vec![100.0, 100.0, 100.0, 100.0, 100.0],
433            ],
434            Symmetry::None,
435        )
436    }
437
438    #[test]
439    fn test_ldc_mesh_generation() {
440        let web = create_uniform_web();
441        let mesh = web.generate_ldc_mesh(45.0, 45.0, 1.0);
442
443        // Should have vertices and indices
444        assert!(mesh.vertex_count() > 0);
445        assert!(mesh.triangle_count() > 0);
446
447        // Indices should be valid
448        for &idx in &mesh.indices {
449            assert!((idx as usize) < mesh.vertex_count());
450        }
451    }
452
453    #[test]
454    fn test_uniform_sphere_radii() {
455        let web = create_uniform_web();
456        let mesh = web.generate_ldc_mesh(30.0, 30.0, 1.0);
457
458        // For uniform intensity, all vertices should be at approximately same distance from origin
459        for v in &mesh.vertices {
460            let r = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
461            // Allow some tolerance for edge cases (poles)
462            if r > 0.01 {
463                assert!((r - 1.0).abs() < 0.01, "Expected radius ~1.0, got {}", r);
464            }
465        }
466    }
467
468    #[test]
469    fn test_nadir_zenith_positions() {
470        let web = create_uniform_web();
471        let mesh = web.generate_ldc_mesh(90.0, 90.0, 1.0);
472
473        // Find vertex at gamma=0 (nadir) - should be at (0, -1, 0) for normalized
474        let nadir = mesh.vertices.iter().find(|v| v.y < -0.9);
475        assert!(nadir.is_some(), "Should have nadir vertex");
476
477        // Find vertex at gamma=180 (zenith) - should be at (0, +1, 0) for normalized
478        let zenith = mesh.vertices.iter().find(|v| v.y > 0.9);
479        assert!(zenith.is_some(), "Should have zenith vertex");
480    }
481
482    #[test]
483    fn test_flat_arrays() {
484        let web = create_uniform_web();
485        let mesh = web.generate_ldc_mesh(90.0, 90.0, 1.0);
486
487        let positions = mesh.positions_flat();
488        let normals = mesh.normals_flat();
489
490        assert_eq!(positions.len(), mesh.vertex_count() * 3);
491        assert_eq!(normals.len(), mesh.vertex_count() * 3);
492    }
493
494    #[test]
495    fn test_colored_mesh() {
496        let web = create_uniform_web();
497        let colored = web.generate_colored_ldc_mesh(45.0, 45.0, 1.0, ColorMode::Heatmap);
498
499        // Should have positions, normals, and colors
500        assert!(colored.vertex_count() > 0);
501        assert_eq!(colored.colors.len(), colored.vertex_count());
502
503        // Flat arrays should have correct lengths
504        let positions = colored.positions_flat();
505        let normals = colored.normals_flat();
506        let colors = colored.colors_flat();
507
508        assert_eq!(positions.len(), colored.vertex_count() * 3);
509        assert_eq!(normals.len(), colored.vertex_count() * 3);
510        assert_eq!(colors.len(), colored.vertex_count() * 4); // RGBA
511    }
512
513    #[test]
514    fn test_heatmap_colors() {
515        // Low intensity = blue
516        let blue = Color::from_heatmap(0.0);
517        assert!(blue.b > blue.r && blue.b > blue.g);
518
519        // High intensity = red
520        let red = Color::from_heatmap(1.0);
521        assert!(red.r > red.g && red.r > red.b);
522
523        // Middle = green-ish
524        let mid = Color::from_heatmap(0.5);
525        assert!(mid.g > 0.5);
526    }
527
528    #[test]
529    fn test_c_plane_colors() {
530        // C=0° and C=360° should give same color (within tolerance)
531        let c0 = Color::from_c_plane_angle(0.0);
532        let c360 = Color::from_c_plane_angle(360.0);
533        assert!((c0.r - c360.r).abs() < 0.01);
534        assert!((c0.g - c360.g).abs() < 0.01);
535        assert!((c0.b - c360.b).abs() < 0.01);
536    }
537}