1use 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 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 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 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 let arm_bottom = settings.mounting_height - 0.25;
94 let luminaire_center_y = arm_bottom - 0.05 - lum_height / 2.0; 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, settings.room_length / 2.0,
101 ),
102 SceneType::Road => {
103 Vec3::new(
106 settings.room_width - 0.7 - 0.2, luminaire_center_y,
108 settings.room_length / 2.0,
109 )
110 }
111 SceneType::Parking | SceneType::Outdoor => {
112 Vec3::new(
114 settings.room_width / 2.0 - 0.2, luminaire_center_y,
116 settings.room_length / 2.0,
117 )
118 }
119 };
120
121 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; (color_temp, cri, total_flux, lor)
136 })
137 .unwrap_or((4000.0, 80.0, 1000.0, 1.0));
138
139 let luminaire_flux = total_flux * lor;
141
142 let light_color = kelvin_to_rgb(color_temp);
143 let light_color = apply_cri_adjustment(light_color, cri);
145
146 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 let intensity_scale = 50.0; commands.spawn((
184 PointLight {
185 color: light_color,
186 intensity: luminaire_flux * intensity_scale * 0.3, 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 if let Some(ldt) = &settings.ldt_data {
198 let beam_angle = calculate_beam_angle(ldt);
199
200 if downward_fraction > 0.1 {
202 let lum_height = (ldt.height / 1000.0).max(0.05) as f32;
204 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 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 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 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, ldt.length / 1000.0, (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 let is_cylindrical = width < 0.01;
297
298 let (mesh, rotation): (Mesh, Quat) = if is_cylindrical {
299 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 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 let rotation = match settings.scene_type {
317 SceneType::Road | SceneType::Parking | SceneType::Outdoor => {
318 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 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 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 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]); let (cr, cg, cb) = heatmap_color(normalized);
384 colors.push([cr, cg, cb, 0.7]);
385 }
386 }
387
388 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
431fn parse_color_temperature(appearance: &str) -> f32 {
433 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 }
451
452fn kelvin_to_rgb(kelvin: f32) -> Color {
455 let temp = kelvin / 100.0;
456
457 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 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 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
486fn 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; }
492
493 let half_max = max_intensity * 0.5;
494
495 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 }
505
506fn 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) } else if v < 0.5 {
514 let t = (v - 0.25) / 0.25;
515 (0.0, 1.0, 1.0 - t) } else if v < 0.75 {
517 let t = (v - 0.5) / 0.25;
518 (t, 1.0, 0.0) } else {
520 let t = (v - 0.75) / 0.25;
521 (1.0, 1.0 - t, 0.0) }
523}
524
525fn 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 group.parse::<f32>().unwrap_or(80.0)
539 }
540 }
541}
542
543fn apply_cri_adjustment(color: Color, cri: f32) -> Color {
546 let saturation_factor = if cri >= 90.0 {
549 1.0
550 } else {
551 let t = ((cri - 50.0) / 40.0).clamp(0.0, 1.0);
553 0.7 + 0.3 * t
554 };
555
556 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}