Skip to main content

eulumdat_bevy/viewer/
scenes.rs

1//! Scene geometry generation for the viewer.
2//!
3//! Provides pre-built demo scenes: Room, Road, Parking, Outdoor.
4
5use super::ViewerSettings;
6use bevy::light::NotShadowCaster;
7use bevy::prelude::*;
8
9/// Road lighting arrangement types per EN 13201.
10#[derive(Clone, Copy, PartialEq, Eq)]
11pub enum RoadArrangement {
12    /// Single side - poles on one side only (narrow roads)
13    SingleSide,
14    /// Staggered - alternating sides (medium roads)
15    Staggered,
16    /// Opposite - poles on both sides, aligned (wide roads)
17    Opposite,
18}
19
20/// Calculate the optimal arrangement based on road width and mounting height.
21/// EN 13201 guidelines:
22/// - W/H < 1.0: Single side
23/// - 1.0 <= W/H < 1.5: Staggered
24/// - W/H >= 1.5: Opposite (both sides)
25///
26/// Note: Outer-side arrangements are preferred over central median because:
27/// - They illuminate sidewalks as well as the road
28/// - Allow different luminaire types for road vs pedestrian areas
29/// - More practical for maintenance
30fn determine_road_arrangement(settings: &ViewerSettings) -> RoadArrangement {
31    let road_width = settings.num_lanes as f32 * settings.lane_width;
32    let ratio = road_width / settings.mounting_height;
33
34    if ratio < 1.0 {
35        RoadArrangement::SingleSide
36    } else if ratio < 1.5 {
37        RoadArrangement::Staggered
38    } else {
39        // For wider roads, opposite arrangement on both sides
40        // This illuminates both sidewalks and provides good road coverage
41        RoadArrangement::Opposite
42    }
43}
44
45/// Plugin for scene geometry.
46pub struct ScenePlugin;
47
48impl Plugin for ScenePlugin {
49    fn build(&self, app: &mut App) {
50        app.init_resource::<ViewerSettings>();
51        app.add_systems(
52            Startup,
53            setup_scene.run_if(resource_exists::<ViewerSettings>),
54        )
55        .add_systems(
56            Update,
57            rebuild_scene_on_change.run_if(resource_exists::<ViewerSettings>),
58        );
59    }
60}
61
62/// Scene type for demo scenes.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum SceneType {
65    /// Indoor room scene (4×5×2.8m)
66    #[default]
67    Room,
68    /// Street lighting scene (10×30m road)
69    Road,
70    /// Parking lot scene (20×30m lot)
71    Parking,
72    /// Outdoor/garden scene (10×15m)
73    Outdoor,
74}
75
76impl SceneType {
77    /// Get default dimensions for this scene type.
78    /// Returns (width, length, height, mount_height)
79    /// Note: For Road scene, width is calculated from lane_width * num_lanes + 2*sidewalk_width
80    pub fn default_dimensions(&self) -> (f32, f32, f32, f32) {
81        match self {
82            SceneType::Room => (4.0, 5.0, 2.8, 2.5),
83            // Road: 2 lanes × 3.5m + 2 sidewalks × 2m = 11m wide, 100m long
84            SceneType::Road => (11.0, 100.0, 0.0, 8.0),
85            SceneType::Parking => (20.0, 30.0, 0.0, 6.0),
86            SceneType::Outdoor => (10.0, 15.0, 0.0, 3.0),
87        }
88    }
89}
90
91/// Marker component for scene geometry entities.
92#[derive(Component)]
93pub struct SceneGeometry;
94
95fn setup_scene(
96    mut commands: Commands,
97    mut meshes: ResMut<Assets<Mesh>>,
98    mut materials: ResMut<Assets<StandardMaterial>>,
99    settings: Res<ViewerSettings>,
100) {
101    build_scene(&mut commands, &mut meshes, &mut materials, &settings);
102}
103
104fn rebuild_scene_on_change(
105    mut commands: Commands,
106    mut meshes: ResMut<Assets<Mesh>>,
107    mut materials: ResMut<Assets<StandardMaterial>>,
108    settings: Res<ViewerSettings>,
109    query: Query<Entity, With<SceneGeometry>>,
110) {
111    if !settings.is_changed() {
112        return;
113    }
114
115    // Remove old scene geometry
116    for entity in query.iter() {
117        commands.entity(entity).despawn();
118    }
119
120    // Build new scene
121    build_scene(&mut commands, &mut meshes, &mut materials, &settings);
122}
123
124fn build_scene(
125    commands: &mut Commands,
126    meshes: &mut ResMut<Assets<Mesh>>,
127    materials: &mut ResMut<Assets<StandardMaterial>>,
128    settings: &ViewerSettings,
129) {
130    match settings.scene_type {
131        SceneType::Room => build_room(commands, meshes, materials, settings),
132        SceneType::Road => build_road(commands, meshes, materials, settings),
133        SceneType::Parking => build_parking(commands, meshes, materials, settings),
134        SceneType::Outdoor => build_outdoor(commands, meshes, materials, settings),
135    }
136
137    // Add ambient light - keep low so luminaire effect is visible
138    // In Bevy 0.18, AmbientLight is now a component, use GlobalAmbientLight as resource
139    commands.insert_resource(bevy::light::GlobalAmbientLight {
140        color: Color::srgb(0.9, 0.9, 1.0),
141        brightness: 50.0, // Low ambient to see lighting differences
142        affects_lightmapped_meshes: true,
143    });
144}
145
146fn build_room(
147    commands: &mut Commands,
148    meshes: &mut ResMut<Assets<Mesh>>,
149    materials: &mut ResMut<Assets<StandardMaterial>>,
150    settings: &ViewerSettings,
151) {
152    let w = settings.room_width;
153    let l = settings.room_length;
154    let h = settings.room_height;
155
156    // Floor
157    let floor_material = materials.add(StandardMaterial {
158        base_color: Color::srgb(0.85, 0.85, 0.85),
159        perceptual_roughness: 0.8,
160        ..default()
161    });
162
163    commands.spawn((
164        Mesh3d(meshes.add(Plane3d::default().mesh().size(w, l))),
165        MeshMaterial3d(floor_material.clone()),
166        Transform::from_xyz(w / 2.0, 0.0, l / 2.0),
167        SceneGeometry,
168    ));
169
170    // Ceiling
171    let ceiling_material = materials.add(StandardMaterial {
172        base_color: Color::srgb(0.95, 0.95, 0.95),
173        perceptual_roughness: 0.9,
174        ..default()
175    });
176
177    commands.spawn((
178        Mesh3d(meshes.add(Plane3d::default().mesh().size(w, l))),
179        MeshMaterial3d(ceiling_material),
180        Transform::from_xyz(w / 2.0, h, l / 2.0)
181            .with_rotation(Quat::from_rotation_x(std::f32::consts::PI)),
182        SceneGeometry,
183    ));
184
185    // Walls
186    let wall_material = materials.add(StandardMaterial {
187        base_color: Color::srgb(0.95, 0.95, 0.95),
188        perceptual_roughness: 0.9,
189        ..default()
190    });
191
192    // Back wall (z=0)
193    commands.spawn((
194        Mesh3d(meshes.add(Plane3d::default().mesh().size(w, h))),
195        MeshMaterial3d(wall_material.clone()),
196        Transform::from_xyz(w / 2.0, h / 2.0, 0.0)
197            .with_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
198        SceneGeometry,
199    ));
200
201    // Front wall (z=l)
202    commands.spawn((
203        Mesh3d(meshes.add(Plane3d::default().mesh().size(w, h))),
204        MeshMaterial3d(wall_material.clone()),
205        Transform::from_xyz(w / 2.0, h / 2.0, l)
206            .with_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2)),
207        SceneGeometry,
208    ));
209
210    // Left wall (x=0)
211    commands.spawn((
212        Mesh3d(meshes.add(Plane3d::default().mesh().size(l, h))),
213        MeshMaterial3d(wall_material.clone()),
214        Transform::from_xyz(0.0, h / 2.0, l / 2.0)
215            .with_rotation(Quat::from_rotation_z(-std::f32::consts::FRAC_PI_2)),
216        SceneGeometry,
217    ));
218
219    // Right wall (x=w)
220    commands.spawn((
221        Mesh3d(meshes.add(Plane3d::default().mesh().size(l, h))),
222        MeshMaterial3d(wall_material),
223        Transform::from_xyz(w, h / 2.0, l / 2.0)
224            .with_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)),
225        SceneGeometry,
226    ));
227
228    // Pendulum/suspension cable (if pendulum_length > 0)
229    spawn_pendulum_cable(commands, meshes, materials, settings, w / 2.0, l / 2.0);
230}
231
232fn build_road(
233    commands: &mut Commands,
234    meshes: &mut ResMut<Assets<Mesh>>,
235    materials: &mut ResMut<Assets<StandardMaterial>>,
236    settings: &ViewerSettings,
237) {
238    // Calculate dimensions from settings
239    let lane_w = settings.lane_width;
240    let num_lanes = settings.num_lanes;
241    let sidewalk_w = settings.sidewalk_width;
242    let road_width = num_lanes as f32 * lane_w; // Just the lanes
243    let total_width = road_width + 2.0 * sidewalk_w; // Including sidewalks
244    let road_length = settings.room_length;
245    let pole_spacing = settings.effective_pole_spacing();
246
247    // Determine arrangement based on road/height ratio
248    let arrangement = determine_road_arrangement(settings);
249
250    // Materials
251    let road_material = materials.add(StandardMaterial {
252        base_color: Color::srgb(0.15, 0.15, 0.15),
253        perceptual_roughness: 0.9,
254        ..default()
255    });
256
257    let sidewalk_material = materials.add(StandardMaterial {
258        base_color: Color::srgb(0.6, 0.6, 0.6),
259        perceptual_roughness: 0.8,
260        ..default()
261    });
262
263    let marking_material = materials.add(StandardMaterial {
264        base_color: Color::WHITE,
265        emissive: LinearRgba::new(0.3, 0.3, 0.3, 1.0),
266        ..default()
267    });
268
269    let yellow_marking = materials.add(StandardMaterial {
270        base_color: Color::srgb(1.0, 0.85, 0.0),
271        emissive: LinearRgba::new(0.3, 0.25, 0.0, 1.0),
272        ..default()
273    });
274
275    // Asphalt road surface
276    commands.spawn((
277        Mesh3d(meshes.add(Plane3d::default().mesh().size(road_width, road_length))),
278        MeshMaterial3d(road_material),
279        Transform::from_xyz(sidewalk_w + road_width / 2.0, 0.0, road_length / 2.0),
280        SceneGeometry,
281    ));
282
283    // Left sidewalk
284    commands.spawn((
285        Mesh3d(meshes.add(Cuboid::new(sidewalk_w, 0.15, road_length))),
286        MeshMaterial3d(sidewalk_material.clone()),
287        Transform::from_xyz(sidewalk_w / 2.0, 0.075, road_length / 2.0),
288        SceneGeometry,
289    ));
290
291    // Right sidewalk
292    commands.spawn((
293        Mesh3d(meshes.add(Cuboid::new(sidewalk_w, 0.15, road_length))),
294        MeshMaterial3d(sidewalk_material.clone()),
295        Transform::from_xyz(total_width - sidewalk_w / 2.0, 0.075, road_length / 2.0),
296        SceneGeometry,
297    ));
298
299    // Center line (yellow, double line for two-way traffic)
300    let center_x = sidewalk_w + road_width / 2.0;
301    let mut z = 1.0;
302    while z < road_length - 1.0 {
303        // Double yellow line
304        commands.spawn((
305            Mesh3d(meshes.add(Cuboid::new(0.12, 0.02, 3.0))),
306            MeshMaterial3d(yellow_marking.clone()),
307            Transform::from_xyz(center_x - 0.15, 0.01, z + 1.5),
308            SceneGeometry,
309        ));
310        commands.spawn((
311            Mesh3d(meshes.add(Cuboid::new(0.12, 0.02, 3.0))),
312            MeshMaterial3d(yellow_marking.clone()),
313            Transform::from_xyz(center_x + 0.15, 0.01, z + 1.5),
314            SceneGeometry,
315        ));
316        z += 4.0;
317    }
318
319    // Lane edge lines (white dashed)
320    for lane_idx in 0..num_lanes {
321        if lane_idx == 0 {
322            // Left edge - solid white line
323            let edge_x = sidewalk_w + 0.15;
324            commands.spawn((
325                Mesh3d(meshes.add(Cuboid::new(0.15, 0.02, road_length - 2.0))),
326                MeshMaterial3d(marking_material.clone()),
327                Transform::from_xyz(edge_x, 0.01, road_length / 2.0),
328                SceneGeometry,
329            ));
330        }
331        if lane_idx == num_lanes - 1 {
332            // Right edge - solid white line
333            let edge_x = sidewalk_w + road_width - 0.15;
334            commands.spawn((
335                Mesh3d(meshes.add(Cuboid::new(0.15, 0.02, road_length - 2.0))),
336                MeshMaterial3d(marking_material.clone()),
337                Transform::from_xyz(edge_x, 0.01, road_length / 2.0),
338                SceneGeometry,
339            ));
340        }
341    }
342
343    // Spawn poles based on arrangement - always on outer sides (sidewalks)
344    // This provides illumination for both road and pedestrian areas
345    let num_poles = ((road_length / pole_spacing).floor() as i32).max(1);
346    let actual_spacing = road_length / (num_poles as f32 + 1.0);
347
348    // Middle pole spacing for center illumination on wide roads
349    let middle_pole_spacing = 50.0;
350
351    match arrangement {
352        RoadArrangement::SingleSide => {
353            // Poles on right side only - arm extends toward road
354            for i in 1..=num_poles {
355                let z = i as f32 * actual_spacing;
356                spawn_pole(
357                    commands,
358                    meshes,
359                    materials,
360                    Vec3::new(total_width - sidewalk_w / 2.0, 0.0, z),
361                    settings.mounting_height,
362                );
363            }
364        }
365        RoadArrangement::Staggered => {
366            // Alternating sides - better uniformity for medium roads
367            for i in 1..=num_poles {
368                let z = i as f32 * actual_spacing;
369                let x = if i % 2 == 0 {
370                    sidewalk_w / 2.0 // Left sidewalk
371                } else {
372                    total_width - sidewalk_w / 2.0 // Right sidewalk
373                };
374                spawn_pole(
375                    commands,
376                    meshes,
377                    materials,
378                    Vec3::new(x, 0.0, z),
379                    settings.mounting_height,
380                );
381            }
382        }
383        RoadArrangement::Opposite => {
384            // Both sides, aligned - best for wide roads
385            // Each luminaire illuminates its adjacent sidewalk + half the road
386            for i in 1..=num_poles {
387                let z = i as f32 * actual_spacing;
388                spawn_pole(
389                    commands,
390                    meshes,
391                    materials,
392                    Vec3::new(sidewalk_w / 2.0, 0.0, z),
393                    settings.mounting_height,
394                );
395                spawn_pole(
396                    commands,
397                    meshes,
398                    materials,
399                    Vec3::new(total_width - sidewalk_w / 2.0, 0.0, z),
400                    settings.mounting_height,
401                );
402            }
403
404            // Add middle poles every 50m for better center illumination on wide roads
405            if road_width > 6.0 {
406                let num_middle_poles = ((road_length / middle_pole_spacing).floor() as i32).max(0);
407                for i in 1..=num_middle_poles {
408                    let z = i as f32 * middle_pole_spacing;
409                    spawn_dual_arm_pole(
410                        commands,
411                        meshes,
412                        materials,
413                        Vec3::new(center_x, 0.0, z),
414                        settings.mounting_height,
415                    );
416                }
417            }
418        }
419    }
420}
421
422/// Spawn a dual-arm pole for center median (used for middle poles on wide roads).
423fn spawn_dual_arm_pole(
424    commands: &mut Commands,
425    meshes: &mut ResMut<Assets<Mesh>>,
426    materials: &mut ResMut<Assets<StandardMaterial>>,
427    base_position: Vec3,
428    height: f32,
429) {
430    let pole_material = materials.add(StandardMaterial {
431        base_color: Color::srgb(0.3, 0.3, 0.35),
432        metallic: 0.8,
433        perceptual_roughness: 0.4,
434        ..default()
435    });
436
437    // Vertical pole
438    commands.spawn((
439        Mesh3d(meshes.add(Cylinder::new(0.1, height))),
440        MeshMaterial3d(pole_material.clone()),
441        Transform::from_xyz(base_position.x, height / 2.0, base_position.z),
442        SceneGeometry,
443    ));
444
445    // Left arm
446    let arm_length = 2.0;
447    commands.spawn((
448        Mesh3d(meshes.add(Cylinder::new(0.05, arm_length))),
449        MeshMaterial3d(pole_material.clone()),
450        Transform::from_xyz(
451            base_position.x - arm_length / 2.0,
452            height - 0.25,
453            base_position.z,
454        )
455        .with_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)),
456        SceneGeometry,
457    ));
458
459    // Right arm
460    commands.spawn((
461        Mesh3d(meshes.add(Cylinder::new(0.05, arm_length))),
462        MeshMaterial3d(pole_material),
463        Transform::from_xyz(
464            base_position.x + arm_length / 2.0,
465            height - 0.25,
466            base_position.z,
467        )
468        .with_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)),
469        SceneGeometry,
470    ));
471}
472
473fn build_parking(
474    commands: &mut Commands,
475    meshes: &mut ResMut<Assets<Mesh>>,
476    materials: &mut ResMut<Assets<StandardMaterial>>,
477    settings: &ViewerSettings,
478) {
479    let w = settings.room_width;
480    let l = settings.room_length;
481
482    // Parking lot surface
483    let lot_material = materials.add(StandardMaterial {
484        base_color: Color::srgb(0.2, 0.2, 0.2),
485        perceptual_roughness: 0.85,
486        ..default()
487    });
488
489    commands.spawn((
490        Mesh3d(meshes.add(Plane3d::default().mesh().size(w, l))),
491        MeshMaterial3d(lot_material),
492        Transform::from_xyz(w / 2.0, 0.0, l / 2.0),
493        SceneGeometry,
494    ));
495
496    // Parking lines
497    let line_material = materials.add(StandardMaterial {
498        base_color: Color::WHITE,
499        emissive: LinearRgba::new(0.2, 0.2, 0.2, 1.0),
500        ..default()
501    });
502
503    let space_width = 2.5;
504    let space_length = 5.0;
505
506    let mut row = 3.0;
507    while row < l - 3.0 {
508        let mut col = space_width;
509        while col < w - 1.0 {
510            commands.spawn((
511                Mesh3d(meshes.add(Cuboid::new(0.1, 0.02, space_length))),
512                MeshMaterial3d(line_material.clone()),
513                Transform::from_xyz(col, 0.01, row),
514                SceneGeometry,
515            ));
516            col += space_width;
517        }
518        row += space_length + 1.0;
519    }
520
521    // Light pole
522    spawn_pole(
523        commands,
524        meshes,
525        materials,
526        Vec3::new(w / 2.0, 0.0, l / 2.0),
527        settings.mounting_height,
528    );
529}
530
531fn build_outdoor(
532    commands: &mut Commands,
533    meshes: &mut ResMut<Assets<Mesh>>,
534    materials: &mut ResMut<Assets<StandardMaterial>>,
535    settings: &ViewerSettings,
536) {
537    let w = settings.room_width;
538    let l = settings.room_length;
539
540    // Grass
541    let grass_material = materials.add(StandardMaterial {
542        base_color: Color::srgb(0.15, 0.3, 0.1),
543        perceptual_roughness: 0.95,
544        ..default()
545    });
546
547    commands.spawn((
548        Mesh3d(meshes.add(Plane3d::default().mesh().size(w, l))),
549        MeshMaterial3d(grass_material),
550        Transform::from_xyz(w / 2.0, 0.0, l / 2.0),
551        SceneGeometry,
552    ));
553
554    // Garden path
555    let path_material = materials.add(StandardMaterial {
556        base_color: Color::srgb(0.5, 0.5, 0.5),
557        perceptual_roughness: 0.8,
558        ..default()
559    });
560
561    commands.spawn((
562        Mesh3d(meshes.add(Cuboid::new(1.2, 0.02, l - 2.0))),
563        MeshMaterial3d(path_material),
564        Transform::from_xyz(w / 2.0, 0.01, l / 2.0),
565        SceneGeometry,
566    ));
567
568    // Bushes
569    let bush_material = materials.add(StandardMaterial {
570        base_color: Color::srgb(0.1, 0.25, 0.05),
571        perceptual_roughness: 0.95,
572        ..default()
573    });
574
575    for (x, y, z) in [
576        (2.0, 0.4, 3.0),
577        (w - 2.0, 0.3, l - 4.0),
578        (1.5, 0.35, l - 2.0),
579    ] {
580        commands.spawn((
581            Mesh3d(meshes.add(Sphere::new(y))),
582            MeshMaterial3d(bush_material.clone()),
583            Transform::from_xyz(x, y, z),
584            SceneGeometry,
585        ));
586    }
587
588    // Light pole
589    spawn_pole(
590        commands,
591        meshes,
592        materials,
593        Vec3::new(w / 2.0, 0.0, l / 2.0),
594        settings.mounting_height,
595    );
596}
597
598fn spawn_pole(
599    commands: &mut Commands,
600    meshes: &mut ResMut<Assets<Mesh>>,
601    materials: &mut ResMut<Assets<StandardMaterial>>,
602    position: Vec3,
603    height: f32,
604) {
605    let pole_material = materials.add(StandardMaterial {
606        base_color: Color::srgb(0.4, 0.4, 0.4),
607        metallic: 0.6,
608        perceptual_roughness: 0.4,
609        ..default()
610    });
611
612    // Pole - don't cast shadows to avoid blocking the lamp
613    commands.spawn((
614        Mesh3d(meshes.add(Cylinder::new(0.08, height - 0.3))),
615        MeshMaterial3d(pole_material.clone()),
616        Transform::from_xyz(position.x, height / 2.0, position.z),
617        SceneGeometry,
618        NotShadowCaster,
619    ));
620
621    // Arm - short stub, luminaire hangs separately below
622    commands.spawn((
623        Mesh3d(meshes.add(Cylinder::new(0.05, 0.3))),
624        MeshMaterial3d(pole_material),
625        Transform::from_xyz(position.x - 0.05, height - 0.2, position.z)
626            .with_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)),
627        SceneGeometry,
628        NotShadowCaster,
629    ));
630}
631
632/// Spawn a pendulum/suspension cable for ceiling-mounted luminaires.
633/// Only spawns if pendulum_length > 0.
634fn spawn_pendulum_cable(
635    commands: &mut Commands,
636    meshes: &mut ResMut<Assets<Mesh>>,
637    materials: &mut ResMut<Assets<StandardMaterial>>,
638    settings: &ViewerSettings,
639    x: f32,
640    z: f32,
641) {
642    if settings.pendulum_length <= 0.0 {
643        return;
644    }
645
646    let cable_material = materials.add(StandardMaterial {
647        base_color: Color::srgb(0.2, 0.2, 0.2),
648        metallic: 0.3,
649        perceptual_roughness: 0.6,
650        ..default()
651    });
652
653    // Cable hangs from ceiling (room_height) down by pendulum_length
654    let cable_top = settings.room_height;
655    let cable_bottom = settings.room_height - settings.pendulum_length;
656    let cable_center_y = (cable_top + cable_bottom) / 2.0;
657
658    commands.spawn((
659        Mesh3d(meshes.add(Cylinder::new(0.01, settings.pendulum_length))),
660        MeshMaterial3d(cable_material),
661        Transform::from_xyz(x, cable_center_y, z),
662        SceneGeometry,
663        NotShadowCaster,
664    ));
665}