eulumdat_bevy/
lighting.rs

1//! Photometric lighting based on Eulumdat data
2//!
3//! Since Bevy doesn't have native IES/photometric light support,
4//! we simulate it using spot lights or by calculating illuminance on surfaces.
5
6use crate::SceneSettings;
7use bevy::pbr::NotShadowCaster;
8use bevy::prelude::*;
9use eulumdat::Eulumdat;
10
11pub struct PhotometricLightPlugin;
12
13impl Plugin for PhotometricLightPlugin {
14    fn build(&self, app: &mut App) {
15        // Initialize default SceneSettings if not already present
16        app.init_resource::<SceneSettings>();
17        app.add_systems(
18            Startup,
19            setup_lights.run_if(resource_exists::<SceneSettings>),
20        )
21        .add_systems(
22            Update,
23            update_lights.run_if(resource_exists::<SceneSettings>),
24        );
25    }
26}
27
28#[derive(Component)]
29pub struct PhotometricLight;
30
31#[derive(Component)]
32pub struct LuminaireModel;
33
34#[derive(Component)]
35pub struct PhotometricSolid;
36
37fn setup_lights(
38    mut commands: Commands,
39    settings: Res<SceneSettings>,
40    mut meshes: ResMut<Assets<Mesh>>,
41    mut materials: ResMut<Assets<StandardMaterial>>,
42) {
43    spawn_lights(&mut commands, &settings, &mut meshes, &mut materials);
44}
45
46fn update_lights(
47    mut commands: Commands,
48    settings: Res<SceneSettings>,
49    mut meshes: ResMut<Assets<Mesh>>,
50    mut materials: ResMut<Assets<StandardMaterial>>,
51    lights: Query<Entity, With<PhotometricLight>>,
52    luminaires: Query<Entity, With<LuminaireModel>>,
53    solids: Query<Entity, With<PhotometricSolid>>,
54) {
55    if !settings.is_changed() {
56        return;
57    }
58
59    // Remove existing lights and models
60    for entity in lights.iter() {
61        commands.entity(entity).despawn_recursive();
62    }
63    for entity in luminaires.iter() {
64        commands.entity(entity).despawn_recursive();
65    }
66    for entity in solids.iter() {
67        commands.entity(entity).despawn_recursive();
68    }
69
70    spawn_lights(&mut commands, &settings, &mut meshes, &mut materials);
71}
72
73fn spawn_lights(
74    commands: &mut Commands,
75    settings: &SceneSettings,
76    meshes: &mut ResMut<Assets<Mesh>>,
77    materials: &mut ResMut<Assets<StandardMaterial>>,
78) {
79    use crate::scene::SceneType;
80
81    // Get luminaire height for positioning calculations
82    let lum_height = settings
83        .ldt_data
84        .as_ref()
85        .map(|ldt| (ldt.height / 1000.0).max(0.1) as f32)
86        .unwrap_or(0.2);
87
88    // Calculate light position based on scene type
89    // For pole scenes (Road, Parking, Outdoor), position at end of arm
90    // For Room, position at center ceiling
91    // Arm is at mounting_height - 0.2, arm radius 0.05, so arm bottom at mounting_height - 0.25
92    // Luminaire top should be below arm bottom with small gap
93    let arm_bottom = settings.mounting_height - 0.25;
94    let luminaire_center_y = arm_bottom - 0.05 - lum_height / 2.0; // 5cm gap below arm
95
96    let light_pos = match settings.scene_type {
97        SceneType::Room => Vec3::new(
98            settings.room_width / 2.0,
99            settings.mounting_height - lum_height / 2.0, // Center of luminaire, hanging from ceiling
100            settings.room_length / 2.0,
101        ),
102        SceneType::Road => {
103            // Pole on right sidewalk (w - 0.7), arm extends 0.2m left
104            // Lamp hangs directly below arm tip
105            Vec3::new(
106                settings.room_width - 0.7 - 0.2, // Arm tip position
107                luminaire_center_y,
108                settings.room_length / 2.0,
109            )
110        }
111        SceneType::Parking | SceneType::Outdoor => {
112            // Pole at center, arm extends 0.2m left
113            Vec3::new(
114                settings.room_width / 2.0 - 0.2, // Arm tip position
115                luminaire_center_y,
116                settings.room_length / 2.0,
117            )
118        }
119    };
120
121    // Get color temperature, CRI, and luminous flux from LDT data
122    let (color_temp, cri, total_flux, lor) = settings
123        .ldt_data
124        .as_ref()
125        .map(|ldt| {
126            let lamp = ldt.lamp_sets.first();
127            let color_temp = lamp
128                .map(|l| parse_color_temperature(&l.color_appearance))
129                .unwrap_or(4000.0);
130            let cri = lamp
131                .map(|l| parse_cri_from_group(&l.color_rendering_group))
132                .unwrap_or(80.0);
133            let total_flux = ldt.total_luminous_flux() as f32;
134            let lor = (ldt.light_output_ratio / 100.0) as f32; // Convert % to fraction
135            (color_temp, cri, total_flux, lor)
136        })
137        .unwrap_or((4000.0, 80.0, 1000.0, 1.0));
138
139    // Calculate actual luminaire output
140    let luminaire_flux = total_flux * lor;
141
142    let light_color = kelvin_to_rgb(color_temp);
143    // Apply CRI-based saturation adjustment (low CRI = more desaturated)
144    let light_color = apply_cri_adjustment(light_color, cri);
145
146    // Get downward flux fraction to determine light direction
147    let downward_fraction = settings
148        .ldt_data
149        .as_ref()
150        .map(|ldt| {
151            let frac = (ldt.downward_flux_fraction / 100.0) as f32;
152            #[cfg(target_arch = "wasm32")]
153            web_sys::console::log_1(
154                &format!(
155                    "[Lighting] LDT downward_flux_fraction: {}% -> {}, upward: {}",
156                    ldt.downward_flux_fraction,
157                    frac,
158                    1.0 - frac
159                )
160                .into(),
161            );
162            frac
163        })
164        .unwrap_or(1.0);
165    let upward_fraction = 1.0 - downward_fraction;
166
167    #[cfg(target_arch = "wasm32")]
168    web_sys::console::log_1(
169        &format!(
170            "[Lighting] Final fractions - down: {}, up: {}, has_ldt: {}",
171            downward_fraction,
172            upward_fraction,
173            settings.ldt_data.is_some()
174        )
175        .into(),
176    );
177
178    // Bevy uses lumens for intensity (roughly)
179    // Scale factor to make it visible in the scene
180    let intensity_scale = 50.0; // Adjust this to taste
181
182    // Main point light for ambient fill
183    commands.spawn((
184        PointLight {
185            color: light_color,
186            intensity: luminaire_flux * intensity_scale * 0.3, // 30% as ambient fill
187            radius: 0.05,
188            range: 50.0,
189            shadows_enabled: false,
190            ..default()
191        },
192        Transform::from_translation(light_pos),
193        PhotometricLight,
194    ));
195
196    // Add directional lights based on flux distribution
197    if let Some(ldt) = &settings.ldt_data {
198        let beam_angle = calculate_beam_angle(ldt);
199
200        // Downward spot light (if there's downward flux)
201        if downward_fraction > 0.1 {
202            // Get luminaire height to position light BELOW the luminaire box
203            let lum_height = (ldt.height / 1000.0).max(0.05) as f32;
204            // Light source is below the luminaire (light exits from bottom)
205            let spot_pos = light_pos - Vec3::Y * (lum_height + 0.05);
206            let floor_target = Vec3::new(spot_pos.x, 0.0, spot_pos.z);
207
208            commands.spawn((
209                SpotLight {
210                    color: light_color,
211                    intensity: luminaire_flux * intensity_scale * downward_fraction,
212                    range: settings.mounting_height * 4.0,
213                    radius: 0.05,
214                    inner_angle: beam_angle * 0.5,
215                    outer_angle: beam_angle * 1.5,
216                    shadows_enabled: settings.show_shadows,
217                    ..default()
218                },
219                Transform::from_translation(spot_pos).looking_at(floor_target, Vec3::X),
220                PhotometricLight,
221            ));
222        }
223
224        // Upward spot light (for uplights like floor_uplight)
225        if upward_fraction > 0.1 {
226            let ceiling_target = Vec3::new(light_pos.x, settings.room_height, light_pos.z);
227            commands.spawn((
228                SpotLight {
229                    color: light_color,
230                    intensity: luminaire_flux * intensity_scale * upward_fraction,
231                    range: settings.room_height * 2.0,
232                    radius: 0.05,
233                    inner_angle: beam_angle * 0.5,
234                    outer_angle: beam_angle * 1.5,
235                    shadows_enabled: settings.show_shadows,
236                    ..default()
237                },
238                Transform::from_translation(light_pos).looking_at(ceiling_target, Vec3::X),
239                PhotometricLight,
240            ));
241        }
242    }
243
244    // Luminaire model
245    if settings.show_luminaire {
246        spawn_luminaire_model(
247            commands,
248            meshes,
249            materials,
250            settings,
251            light_pos,
252            light_color,
253        );
254    }
255
256    // Photometric solid
257    if settings.show_photometric_solid {
258        if let Some(ldt) = &settings.ldt_data {
259            spawn_photometric_solid(commands, meshes, materials, ldt, light_pos);
260        }
261    }
262}
263
264fn spawn_luminaire_model(
265    commands: &mut Commands,
266    meshes: &mut ResMut<Assets<Mesh>>,
267    materials: &mut ResMut<Assets<StandardMaterial>>,
268    settings: &SceneSettings,
269    position: Vec3,
270    light_color: Color,
271) {
272    use crate::scene::SceneType;
273
274    let (width, length, height) = settings
275        .ldt_data
276        .as_ref()
277        .map(|ldt| {
278            (
279                ldt.width / 1000.0,  // mm to m
280                ldt.length / 1000.0, // mm to m (or diameter if width=0)
281                (ldt.height / 1000.0).max(0.05),
282            )
283        })
284        .unwrap_or((0.2, 0.2, 0.05));
285
286    let linear = light_color.to_linear();
287    let luminaire_material = materials.add(StandardMaterial {
288        base_color: Color::srgb(0.3, 0.3, 0.3),
289        emissive: LinearRgba::new(linear.red * 2.0, linear.green * 2.0, linear.blue * 2.0, 1.0),
290        metallic: 0.8,
291        perceptual_roughness: 0.3,
292        ..default()
293    });
294
295    // Determine if cylindrical (width = 0 means length is diameter)
296    let is_cylindrical = width < 0.01;
297
298    let (mesh, rotation): (Mesh, Quat) = if is_cylindrical {
299        // Cylindrical luminaire: length is diameter, height is the "depth"
300        // Bevy Cylinder: axis along Y, circular faces top/bottom
301        // For street lamps: rotate so axis is horizontal and PARALLEL to road (Z axis)
302        // Then circular face points down toward street
303        let diameter = length.max(0.1) as f32;
304        let radius = diameter / 2.0;
305        let rotation = match settings.scene_type {
306            SceneType::Road | SceneType::Parking | SceneType::Outdoor => {
307                // Rotate 90° around Z: cylinder axis goes from Y (vertical) to X (perpendicular to road)
308                // Length extends toward/away from road, circular ends face along road direction
309                Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)
310            }
311            SceneType::Room => Quat::IDENTITY,
312        };
313        (Cylinder::new(radius, height as f32).into(), rotation)
314    } else {
315        // Rectangular luminaire
316        let rotation = match settings.scene_type {
317            SceneType::Road | SceneType::Parking | SceneType::Outdoor => {
318                // Rotate 90° so length aligns with road
319                Quat::from_rotation_y(std::f32::consts::FRAC_PI_2)
320            }
321            SceneType::Room => Quat::IDENTITY,
322        };
323        (
324            Cuboid::new(width.max(0.1) as f32, height as f32, length.max(0.1) as f32).into(),
325            rotation,
326        )
327    };
328
329    // Position is the luminaire center - no additional offset needed
330    // The light source will be spawned BELOW this
331    commands.spawn((
332        Mesh3d(meshes.add(mesh)),
333        MeshMaterial3d(luminaire_material),
334        Transform::from_translation(position).with_rotation(rotation),
335        LuminaireModel,
336        NotShadowCaster,
337    ));
338}
339
340fn spawn_photometric_solid(
341    commands: &mut Commands,
342    meshes: &mut ResMut<Assets<Mesh>>,
343    materials: &mut ResMut<Assets<StandardMaterial>>,
344    ldt: &Eulumdat,
345    position: Vec3,
346) {
347    // Generate mesh vertices from photometric data
348    let c_step = 10.0_f64;
349    let g_step = 5.0_f64;
350    let num_c = (360.0 / c_step) as usize;
351    let num_g = (180.0 / g_step) as usize + 1;
352    let scale = 0.3_f32;
353
354    let max_intensity = ldt.max_intensity();
355    if max_intensity <= 0.0 {
356        return;
357    }
358
359    let mut positions = Vec::new();
360    let mut normals = Vec::new();
361    let mut colors = Vec::new();
362    let mut indices = Vec::new();
363
364    for ci in 0..num_c {
365        let c_angle = ci as f64 * c_step;
366        let c_rad = c_angle.to_radians() as f32;
367
368        for gi in 0..num_g {
369            let g_angle = gi as f64 * g_step;
370            let normalized = ldt.sample(c_angle, g_angle) / max_intensity;
371            let r = normalized as f32 * scale;
372            let g_rad = g_angle.to_radians() as f32;
373
374            // Spherical to Cartesian (Y-down for gamma=0)
375            let x = r * g_rad.sin() * c_rad.cos();
376            let z = r * g_rad.sin() * c_rad.sin();
377            let y = -r * g_rad.cos();
378
379            positions.push([x, y, z]);
380            normals.push([x, y, z]); // Approximate normals
381
382            // Heatmap color
383            let (cr, cg, cb) = heatmap_color(normalized);
384            colors.push([cr, cg, cb, 0.7]);
385        }
386    }
387
388    // Generate indices for triangles
389    for c in 0..num_c {
390        let next_c = (c + 1) % num_c;
391        for g in 0..(num_g - 1) {
392            let v0 = (c * num_g + g) as u32;
393            let v1 = (next_c * num_g + g) as u32;
394            let v2 = (next_c * num_g + (g + 1)) as u32;
395            let v3 = (c * num_g + (g + 1)) as u32;
396
397            indices.push(v0);
398            indices.push(v1);
399            indices.push(v2);
400            indices.push(v0);
401            indices.push(v2);
402            indices.push(v3);
403        }
404    }
405
406    let mut mesh = Mesh::new(
407        bevy::render::mesh::PrimitiveTopology::TriangleList,
408        bevy::render::render_asset::RenderAssetUsages::default(),
409    );
410    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
411    mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
412    mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
413    mesh.insert_indices(bevy::render::mesh::Indices::U32(indices));
414
415    let solid_material = materials.add(StandardMaterial {
416        base_color: Color::WHITE,
417        alpha_mode: AlphaMode::Blend,
418        double_sided: true,
419        cull_mode: None,
420        ..default()
421    });
422
423    commands.spawn((
424        Mesh3d(meshes.add(mesh)),
425        MeshMaterial3d(solid_material),
426        Transform::from_translation(position - Vec3::Y * 0.1),
427        PhotometricSolid,
428    ));
429}
430
431/// Parse color temperature from lamp's color appearance string
432fn parse_color_temperature(appearance: &str) -> f32 {
433    // Try to extract a 4-digit number (typical CCT range 1800-10000)
434    let mut digits = String::new();
435    for ch in appearance.chars() {
436        if ch.is_ascii_digit() {
437            digits.push(ch);
438            if digits.len() == 4 {
439                if let Ok(kelvin) = digits.parse::<f32>() {
440                    if (1000.0..=20000.0).contains(&kelvin) {
441                        return kelvin;
442                    }
443                }
444            }
445        } else {
446            digits.clear();
447        }
448    }
449    4000.0 // Default neutral white
450}
451
452/// Convert color temperature (Kelvin) to RGB
453/// Using Tanner Helland's algorithm
454fn kelvin_to_rgb(kelvin: f32) -> Color {
455    let temp = kelvin / 100.0;
456
457    // Red
458    let r = if temp <= 66.0 {
459        255.0
460    } else {
461        let x = temp - 60.0;
462        (329.698_73 * x.powf(-0.133_204_76)).clamp(0.0, 255.0)
463    };
464
465    // Green
466    let g = if temp <= 66.0 {
467        (99.470_8 * temp.ln() - 161.119_57).clamp(0.0, 255.0)
468    } else {
469        let x = temp - 60.0;
470        (288.122_16 * x.powf(-0.075_514_846)).clamp(0.0, 255.0)
471    };
472
473    // Blue
474    let b = if temp >= 66.0 {
475        255.0
476    } else if temp <= 19.0 {
477        0.0
478    } else {
479        let x = temp - 10.0;
480        (138.517_73 * x.ln() - 305.044_8).clamp(0.0, 255.0)
481    };
482
483    Color::srgb(r / 255.0, g / 255.0, b / 255.0)
484}
485
486/// Calculate approximate beam angle from photometric data
487fn calculate_beam_angle(ldt: &Eulumdat) -> f32 {
488    let max_intensity = ldt.max_intensity();
489    if max_intensity <= 0.0 {
490        return std::f32::consts::FRAC_PI_4; // 45 degrees default
491    }
492
493    let half_max = max_intensity * 0.5;
494
495    // Find the gamma angle where intensity drops to 50% (beam angle definition)
496    for g in 0..90 {
497        let intensity = ldt.sample(0.0, g as f64);
498        if intensity < half_max {
499            return (g as f32).to_radians();
500        }
501    }
502
503    std::f32::consts::FRAC_PI_2 // 90 degrees if not found
504}
505
506/// Heatmap color for visualization
507fn heatmap_color(value: f64) -> (f32, f32, f32) {
508    let v = value.clamp(0.0, 1.0) as f32;
509
510    if v < 0.25 {
511        let t = v / 0.25;
512        (0.0, t, 1.0) // Blue to Cyan
513    } else if v < 0.5 {
514        let t = (v - 0.25) / 0.25;
515        (0.0, 1.0, 1.0 - t) // Cyan to Green
516    } else if v < 0.75 {
517        let t = (v - 0.5) / 0.25;
518        (t, 1.0, 0.0) // Green to Yellow
519    } else {
520        let t = (v - 0.75) / 0.25;
521        (1.0, 1.0 - t, 0.0) // Yellow to Red
522    }
523}
524
525/// Parse CRI (Color Rendering Index) from color rendering group string
526/// Groups: 1A (≥90), 1B (80-89), 2A (70-79), 2B (60-69), 3 (40-59), 4 (<40)
527fn parse_cri_from_group(group: &str) -> f32 {
528    let group = group.trim().to_uppercase();
529    match group.as_str() {
530        "1A" | "1" => 95.0,
531        "1B" => 85.0,
532        "2A" | "2" => 75.0,
533        "2B" => 65.0,
534        "3" => 50.0,
535        "4" => 30.0,
536        _ => {
537            // Try to parse as a number
538            group.parse::<f32>().unwrap_or(80.0)
539        }
540    }
541}
542
543/// Apply CRI-based color adjustment
544/// Low CRI lights render colors less accurately, simulated by desaturation
545fn apply_cri_adjustment(color: Color, cri: f32) -> Color {
546    // CRI 100 = full saturation, CRI 0 = grayscale
547    // We use a gentler curve: CRI 90+ = full sat, CRI 50 = ~70% sat
548    let saturation_factor = if cri >= 90.0 {
549        1.0
550    } else {
551        // Linear interpolation from CRI 50 (0.7) to CRI 90 (1.0)
552        let t = ((cri - 50.0) / 40.0).clamp(0.0, 1.0);
553        0.7 + 0.3 * t
554    };
555
556    // Convert to linear RGB, desaturate, convert back
557    let linear = color.to_linear();
558    let luminance = 0.2126 * linear.red + 0.7152 * linear.green + 0.0722 * linear.blue;
559
560    let r = luminance + (linear.red - luminance) * saturation_factor;
561    let g = luminance + (linear.green - luminance) * saturation_factor;
562    let b = luminance + (linear.blue - luminance) * saturation_factor;
563
564    Color::linear_rgb(r, g, b)
565}