Week 17 of 2024

Development log of Otterhide

8 items
  1. Use the snow globe as ground for RTS camera
  2. Fix some defects in snow globe and district models
  3. WIP: Click on a building to see it's info
  4. Show addresses for selected buildings
  5. Show names and sex of selected buildings residents
  6. Implement persons selection
  7. Showing selected entities account for missing data
  8. Implement a searchlight following the pointer

Use the snow globe as ground for RTS camera

On by Tad Lispy

For this I had to modify the code in the bevy_rts_camera crate. Until my PR is (hopefully) merged, we use my fork of this crate. See https://github.com/Plonq/bevy_rts_camera/pull/11.

Thanks to this change we can remove the silly "underground ground" mesh.

The district grounds are also marked as ground, so the camera can properly fly over them.

In the process I also discovered the World::run_system_once method, that allows to run a system without registering it. Very handy. I want to use it more.

To make everything work I changed some names in Blender files, most notably all paths are now called "Walkway" with usual Blender suffix (.001 etc).

There was a small and unrelated bug in parcels, where they names wouldn't use the counter variable.

index 5d70a4c..b464f2c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1218,9 +1218,8 @@ dependencies = [
=
=[[package]]
=name = "bevy_rts_camera"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7e1784c11a86508988e3db12a52109a3fe3436c13386bbbc8e60de5646e1033"
+version = "0.5.0"
+source = "git+https://github.com/tad-lispy/bevy_rts_camera?rev=ec30542#ec30542016993f8a47f9551bd1d205edbe4db6f8"
=dependencies = [
= "bevy",
= "bevy_mod_raycast",
index 0fc0899..1529e3c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ bevy_args = "=1.3.0"
=bevy_egui = "0.25.0"
=bevy_mod_picking = { version = "0.18.2", features = ["backend_egui"] }
=bevy_mod_raycast = "0.17.0"
-bevy_rts_camera = "0.4.0"
+bevy_rts_camera = { git = "https://github.com/tad-lispy/bevy_rts_camera", version = "0.5.0", rev = "ec30542" }
=clap = { version = "4.5.4", features = ["derive"] }
=derive_more = "0.99.17"
=gloo-file = "0.3.0"
index 7fa3d8d..9dba233 100644
Binary files a/art/districts.blend and b/art/districts.blend differ
index 5ad1971..842d1cc 100644
Binary files a/art/snowglobe.blend and b/art/snowglobe.blend differ
index 2f39e4c..fde6635 100644
Binary files a/assets/districts.glb and b/assets/districts.glb differ
index d1dee4a..8d61ccd 100644
Binary files a/assets/snowglobe.glb and b/assets/snowglobe.glb differ
index ea04b16..20eee90 100644
--- a/src/districts.rs
+++ b/src/districts.rs
@@ -10,11 +10,12 @@ use crate::parcels::{setup_parcels, Parcel};
=use crate::population::Person;
=use crate::roads::{RoadPlan, RoadsSystems};
=use crate::simulation::SimulationParameters;
-use bevy::ecs::system::SystemId;
+use bevy::ecs::system::{RunSystemOnce, SystemId};
=use bevy::gltf::Gltf;
=use bevy::math::bounding::Aabb3d;
=use bevy::prelude::*;
=use bevy::utils::{HashMap, HashSet};
+use bevy_rts_camera::Ground;
=use itertools::Itertools;
=use rand::seq::{IteratorRandom, SliceRandom};
=use rand::thread_rng;
@@ -120,6 +121,8 @@ fn setup_districts(
=
=        let scene = scenes_assets.get_mut(scene_handle.clone()).unwrap();
=
+        scene.world.run_system_once(mark_ground);
+        // TODO: Use run_system_once call pattern to setup parcels
=        setup_parcels(scene);
=
=        scenes.0.insert(variant, scene_handle.clone());
@@ -133,6 +136,16 @@ fn setup_districts(
=    info!("Districts processed: \n{variants}");
=}
=
+fn mark_ground(objects: Query<(Entity, &Name)>, mut commands: Commands) {
+    for (entity, name) in objects.iter() {
+        let name = &name.as_str();
+        if name.starts_with("District ground") || name.starts_with("Walkway") {
+            info!("Marking entity {entity:?} (named `{name}`) as Ground");
+            commands.entity(entity).insert(Ground);
+        }
+    }
+}
+
=#[derive(Bundle)]
=struct DistrictBundle {
=    marker: District,
index 7c869a0..375546c 100644
--- a/src/ground.rs
+++ b/src/ground.rs
@@ -1,14 +1,13 @@
=use crate::history::Snapshot;
=use crate::major_state::{MajorState, PreloadedAssets};
-use crate::simulation::{self, SimulationParameters};
-use bevy::ecs::system::SystemId;
+use crate::simulation;
+use bevy::ecs::system::{RunSystemOnce, SystemId};
=use bevy::gltf::Gltf;
=use bevy::math::bounding::{Aabb3d, IntersectsVolume};
=use bevy::prelude::*;
=use bevy_rts_camera::Ground;
=use itertools::Itertools;
=use serde::{Deserialize, Serialize};
-use std::f32::consts::FRAC_PI_2;
=
=pub struct GroundPlugin;
=
@@ -16,6 +15,7 @@ impl Plugin for GroundPlugin {
=    fn build(&self, app: &mut App) {
=        app.add_systems(Startup, register_ground_systems)
=            .register_type::<Tree>()
+            .register_type::<Ground>()
=            .add_systems(Startup, setup_assets)
=            .add_systems(
=                OnEnter(MajorState::GettingReady),
@@ -88,9 +88,6 @@ fn setup_ground(
=    gltf_assets: Res<Assets<Gltf>>,
=    mut scenes_assets: ResMut<Assets<Scene>>,
=    mut commands: Commands,
-    mut meshes: ResMut<Assets<Mesh>>,
-    mut materials: ResMut<Assets<StandardMaterial>>,
-    settings: Res<SimulationParameters>,
=) {
=    debug!("Processing snowglobe scene");
=
@@ -98,6 +95,8 @@ fn setup_ground(
=    let scene_handle = snowglobe_gltf.scenes.first().unwrap();
=    let scene = scenes_assets.get_mut(scene_handle.clone()).unwrap();
=
+    scene.world.run_system_once(mark_ground);
+
=    let trees = scene
=        .world
=        .query::<(Entity, &Name)>()
@@ -136,27 +135,16 @@ fn setup_ground(
=        SnowGlobe,
=        Name::new("Snow globe"),
=    ));
+}
=
-    // TODO: Use the ground entity from the snow globe model
-    commands.spawn((
-        PbrBundle {
-            mesh: meshes.add(Circle::new(settings.land_radius)),
-            material: materials.add(StandardMaterial {
-                base_color: Color::hsl(150.0, 0.3, 0.3),
-                perceptual_roughness: 0.8,
-                ..default()
-            }),
-            // NOTE: Setting ground visibility to hidden breaks the camera work : (
-            //       So instead I set it to be 4m under the visual ground. It's
-            //       a hack that propagates to camera.rs code.
-            transform: Transform::from_rotation(Quat::from_rotation_x(-FRAC_PI_2))
-                .with_translation(-4.0 * Vec3::Y),
-            // visibility: Visibility::Hidden,
-            ..default()
-        },
-        Ground,
-        Name::new("Ground"),
-    ));
+fn mark_ground(objects: Query<(Entity, &Name)>, mut commands: Commands) {
+    for (entity, name) in objects.iter() {
+        let name = &name.as_str();
+        if name.starts_with("Ground") {
+            info!("Marking entity {entity:?} (named `{name}`) as Ground");
+            commands.entity(entity).insert(Ground);
+        }
+    }
=}
=
=#[derive(Component, Debug)]
index 27b1d04..d469c1d 100644
--- a/src/parcels.rs
+++ b/src/parcels.rs
@@ -78,7 +78,7 @@ pub fn setup_parcels(scene: &mut Scene) {
=                number,
=                marker: Parcel,
=            })
-            .insert(Name::new("Parcel {parcel_count}"));
+            .insert(Name::new(format!("Parcel {parcels_count:04}")));
=
=        if let Some(entity) = scene.world.get_entity_mut(entity) {
=            entity.despawn_recursive();

Fix some defects in snow globe and district models

On by Tad Lispy

Misaligned geometry and materials.

index 9dba233..4074f2c 100644
Binary files a/art/districts.blend and b/art/districts.blend differ
index 842d1cc..9705235 100644
Binary files a/art/snowglobe.blend and b/art/snowglobe.blend differ
index fde6635..df7aefe 100644
Binary files a/assets/districts.glb and b/assets/districts.glb differ
index 8d61ccd..5803c0b 100644
Binary files a/assets/snowglobe.glb and b/assets/snowglobe.glb differ

WIP: Click on a building to see it's info

On by Tad Lispy

Under the hood, buildings can now be selected (have a Selected component). Each selected building will have a corresponding Egui window open. Closing a window removes the Selected component.

Selection is done via bevy_mod_picking. It comes with a built-in selection plugin, but I decided not to use it, as I want entities that do not have a mesh representation to be also selectable, for example persons. Also maybe detach Selected from entities, to make selection persist across time-travel and let not yet or not anymore existing buildings and persons to be selected. So I want more control over this subsystem.

index 25a3f92..5a541da 100644
--- a/src/buildings.rs
+++ b/src/buildings.rs
@@ -10,6 +10,7 @@ use bevy::ecs::system::SystemId;
=use bevy::gltf::Gltf;
=use bevy::prelude::*;
=use bevy::utils::{HashMap, HashSet, Uuid};
+use bevy_mod_picking::PickableBundle;
=use derive_more::Display;
=use derive_more::From;
=use itertools::Itertools;
@@ -455,7 +456,10 @@ fn construct_building(
=    let HousingCapacity(capacity) = description.capacity.clone();
=    let residents = description.residents.clone().unwrap_or_default();
=
-    let mut entity = commands.spawn(BuildingBundle::new(description, &scenes));
+    let mut entity = commands.spawn((
+        BuildingBundle::new(description, &scenes),
+        PickableBundle::default(),
+    ));
=
=    if residents.len() > capacity as usize {
=        entity.insert(IsOvercrowded);
index 3bddefb..888a6c2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,5 +14,6 @@ pub mod parcels;
=pub mod population;
=pub mod roads;
=pub mod ron_export;
+pub mod selecting;
=pub mod simulation;
=pub mod sun;
index d7b9207..c6ecb4d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ use bevy::utils::Uuid;
=use bevy_egui::EguiPlugin;
=#[cfg(debug_assertions)]
=use bevy_inspector_egui::quick::WorldInspectorPlugin;
+use bevy_mod_picking::DefaultPickingPlugins;
=use otterhide::buildings::BuildingsPlugin;
=use otterhide::camera::CameraPlugin;
=use otterhide::configuration::{Configuration, ConfigurationPlugin};
@@ -16,6 +17,7 @@ use otterhide::major_state::MajorStatePlugin;
=use otterhide::parcels::ParcelsPlugin;
=use otterhide::population::PopulationPlugin;
=use otterhide::roads::RoadsPlugin;
+use otterhide::selecting::SelectingPlugin;
=use otterhide::simulation::SimulationLoadingPlugin;
=use otterhide::sun::SunPlugin;
=
@@ -31,6 +33,7 @@ fn main() {
=            ..default()
=        }))
=        .register_type::<Uuid>()
+        .add_plugins(DefaultPickingPlugins)
=        .add_plugins(ConfigurationPlugin)
=        .add_plugins(MajorStatePlugin)
=        .add_plugins(SimulationLoadingPlugin)
@@ -46,6 +49,7 @@ fn main() {
=        .add_plugins(DebugPlugin)
=        .add_plugins(HudPlugin)
=        .add_plugins(PopulationPlugin)
+        .add_plugins(SelectingPlugin)
=        .add_systems(Startup, greet)
=        .run()
=}
new file mode 100644
index 0000000..8925f9a
--- /dev/null
+++ b/src/selecting.rs
@@ -0,0 +1,48 @@
+use std::ops::Not;
+
+use bevy::prelude::*;
+use bevy_egui::{egui, EguiContexts};
+use bevy_mod_picking::events::{Click, Pointer};
+
+pub struct SelectingPlugin;
+
+impl Plugin for SelectingPlugin {
+    fn build(&self, app: &mut App) {
+        app.register_type::<Selected>()
+            .add_systems(Update, handle_clicks)
+            .add_systems(Update, draw_selected_windows);
+    }
+}
+
+/// Marker for anything that is selected
+#[derive(Component, Debug, Reflect, Clone)]
+#[reflect(Component)]
+pub struct Selected;
+
+fn handle_clicks(mut clicks: EventReader<Pointer<Click>>, mut commands: Commands) {
+    for click in clicks.read() {
+        commands.entity(click.target).insert(Selected);
+    }
+}
+
+fn draw_selected_windows(
+    mut contexts: EguiContexts,
+    selected: Query<Entity, With<Selected>>,
+    mut commands: Commands,
+) {
+    for entity in selected.iter() {
+        let mut open = true;
+        egui::Window::new(entity.to_bits().to_string())
+            .open(&mut open)
+            .show(contexts.ctx_mut(), |ui| {
+                ui.label("Here goes the data about the selected thing");
+                if ui.button("Close").clicked() {
+                    commands.entity(entity).remove::<Selected>();
+                }
+            });
+
+        if open.not() {
+            commands.entity(entity).remove::<Selected>();
+        }
+    }
+}

Show addresses for selected buildings

On by Tad Lispy

Also list residents ids on a way to display their names with ability to select persons by clicking on a name.

To make it work I turned Selected from Component (on each selected building) to a resource holding a set of BuildingId values. Eventually I want to make it possible to select persons, probably by making SelectedId an enum that can hold either a BuildingId or a PersonId.

To be able to easily store a BuildingId when it's mesh is clicked I made it implement the Copy trait. Otherwise there was crazy amount of cloning required. Now there is a lot of unrelated code that can drop .clone() on BuildingId, but I didn't want to do it in this commit to avoid making it too noisy.

index 5a541da..588c05e 100644
--- a/src/buildings.rs
+++ b/src/buildings.rs
@@ -6,11 +6,12 @@ use crate::major_state::PreloadedAssets;
=use crate::parcels::{Parcel, ParcelNumber};
=use crate::population::MovedOut;
=use crate::population::{MovedIn, Person, PersonId};
+use crate::selecting::Selected;
=use bevy::ecs::system::SystemId;
=use bevy::gltf::Gltf;
=use bevy::prelude::*;
=use bevy::utils::{HashMap, HashSet, Uuid};
-use bevy_mod_picking::PickableBundle;
+use bevy_mod_picking::prelude::*;
=use derive_more::Display;
=use derive_more::From;
=use itertools::Itertools;
@@ -303,7 +304,18 @@ impl BuildingBundle {
=}
=
=#[derive(
-    Component, Hash, PartialEq, Eq, Clone, Debug, From, Display, Reflect, Serialize, Deserialize,
+    Component,
+    Hash,
+    PartialEq,
+    Eq,
+    Clone,
+    Copy,
+    Debug,
+    From,
+    Display,
+    Reflect,
+    Serialize,
+    Deserialize,
=)]
=pub struct BuildingId(pub Uuid);
=
@@ -452,13 +464,16 @@ fn construct_building(
=    mut register: ResMut<BuildingsRegister>,
=) {
=    debug!("Constructing {description:#?}",);
-    let id = description.id.clone();
+    let id = description.id;
=    let HousingCapacity(capacity) = description.capacity.clone();
=    let residents = description.residents.clone().unwrap_or_default();
=
=    let mut entity = commands.spawn((
=        BuildingBundle::new(description, &scenes),
-        PickableBundle::default(),
+        On::<Pointer<Click>>::run(move |mut selected: ResMut<Selected>| {
+            info!("Selecting {id:?}");
+            selected.ids.insert(id);
+        }),
=    ));
=
=    if residents.len() > capacity as usize {
index 8925f9a..11bb4c6 100644
--- a/src/selecting.rs
+++ b/src/selecting.rs
@@ -1,48 +1,49 @@
-use std::ops::Not;
-
+use crate::buildings::{BuildingId, BuildingsRegister, Residents};
=use bevy::prelude::*;
+use bevy::utils::HashSet;
=use bevy_egui::{egui, EguiContexts};
-use bevy_mod_picking::events::{Click, Pointer};
=
=pub struct SelectingPlugin;
=
=impl Plugin for SelectingPlugin {
=    fn build(&self, app: &mut App) {
-        app.register_type::<Selected>()
-            .add_systems(Update, handle_clicks)
+        app.init_resource::<Selected>()
=            .add_systems(Update, draw_selected_windows);
=    }
=}
=
-/// Marker for anything that is selected
-#[derive(Component, Debug, Reflect, Clone)]
-#[reflect(Component)]
-pub struct Selected;
+type SelectedId = BuildingId;
=
-fn handle_clicks(mut clicks: EventReader<Pointer<Click>>, mut commands: Commands) {
-    for click in clicks.read() {
-        commands.entity(click.target).insert(Selected);
-    }
+/// Marker for anything that is selected
+#[derive(Resource, Debug, Clone, Default)]
+pub struct Selected {
+    pub ids: HashSet<SelectedId>,
=}
=
=fn draw_selected_windows(
=    mut contexts: EguiContexts,
-    selected: Query<Entity, With<Selected>>,
-    mut commands: Commands,
+    mut selected: ResMut<Selected>,
+    buildings_register: Res<BuildingsRegister>,
+    buildings: Query<(&Name, &Residents), With<BuildingId>>,
=) {
-    for entity in selected.iter() {
+    selected.ids.retain(|id| {
=        let mut open = true;
-        egui::Window::new(entity.to_bits().to_string())
+        let Some(entity) = buildings_register.0.get(id) else {
+            return true;
+        };
+
+        let Ok((address, residents)) = buildings.get(*entity) else {
+            error!("Can't find entity {entity:?} for {id:?} among buildings");
+            return true;
+        };
+        egui::Window::new(address.to_string())
=            .open(&mut open)
=            .show(contexts.ctx_mut(), |ui| {
-                ui.label("Here goes the data about the selected thing");
-                if ui.button("Close").clicked() {
-                    commands.entity(entity).remove::<Selected>();
+                for resident_id in residents.0.iter() {
+                    ui.label(resident_id.to_string());
=                }
=            });
=
-        if open.not() {
-            commands.entity(entity).remove::<Selected>();
-        }
-    }
+        open
+    });
=}

Show names and sex of selected buildings residents

On by Tad Lispy

index 588c05e..4fb45d3 100644
--- a/src/buildings.rs
+++ b/src/buildings.rs
@@ -471,7 +471,6 @@ fn construct_building(
=    let mut entity = commands.spawn((
=        BuildingBundle::new(description, &scenes),
=        On::<Pointer<Click>>::run(move |mut selected: ResMut<Selected>| {
-            info!("Selecting {id:?}");
=            selected.ids.insert(id);
=        }),
=    ));
index 11bb4c6..b421290 100644
--- a/src/selecting.rs
+++ b/src/selecting.rs
@@ -1,4 +1,5 @@
=use crate::buildings::{BuildingId, BuildingsRegister, Residents};
+use crate::population::{PersonId, PersonsRegister, Sex};
=use bevy::prelude::*;
=use bevy::utils::HashSet;
=use bevy_egui::{egui, EguiContexts};
@@ -25,25 +26,34 @@ fn draw_selected_windows(
=    mut selected: ResMut<Selected>,
=    buildings_register: Res<BuildingsRegister>,
=    buildings: Query<(&Name, &Residents), With<BuildingId>>,
+    persons_register: Res<PersonsRegister>,
+    persons: Query<(&Name, &Sex), With<PersonId>>,
=) {
=    selected.ids.retain(|id| {
-        let mut open = true;
-        let Some(entity) = buildings_register.0.get(id) else {
+        let mut window_is_open = true;
+
+        let Some(building_entity) = buildings_register.0.get(id) else {
=            return true;
=        };
=
-        let Ok((address, residents)) = buildings.get(*entity) else {
-            error!("Can't find entity {entity:?} for {id:?} among buildings");
+        let Ok((address, residents)) = buildings.get(*building_entity) else {
+            error!("Can't find entity {building_entity:?} for {id:?} among buildings");
=            return true;
=        };
=        egui::Window::new(address.to_string())
-            .open(&mut open)
+            .open(&mut window_is_open)
=            .show(contexts.ctx_mut(), |ui| {
=                for resident_id in residents.0.iter() {
-                    ui.label(resident_id.to_string());
+                    let Some(resident_entity) = persons_register.0.get(resident_id) else {
+                        continue;
+                    };
+                    let Ok((name, sex)) = persons.get(*resident_entity) else {
+                        continue;
+                    };
+                    ui.label(format!("{sex} {name}"));
=                }
=            });
=
-        open
+        window_is_open
=    });
=}

Implement persons selection

On by Tad Lispy

Since persons don't have a visual representation yet, it is only possible by first selecting a building and then clicking on a link to one of the residents. Each person's window also has a link to their residence.

A window showing

index 4fb45d3..8253f29 100644
--- a/src/buildings.rs
+++ b/src/buildings.rs
@@ -471,7 +471,7 @@ fn construct_building(
=    let mut entity = commands.spawn((
=        BuildingBundle::new(description, &scenes),
=        On::<Pointer<Click>>::run(move |mut selected: ResMut<Selected>| {
-            selected.ids.insert(id);
+            selected.ids.insert(id.into());
=        }),
=    ));
=
index 25c2fd7..2ab87d3 100644
--- a/src/population.rs
+++ b/src/population.rs
@@ -394,7 +394,18 @@ impl From<PersonDescription> for PersonBundle {
=pub struct Person;
=
=#[derive(
-    Component, Debug, Clone, Hash, PartialEq, Eq, Reflect, From, Display, Serialize, Deserialize,
+    Component,
+    Debug,
+    Clone,
+    Copy,
+    Hash,
+    PartialEq,
+    Eq,
+    Reflect,
+    From,
+    Display,
+    Serialize,
+    Deserialize,
=)]
=pub struct PersonId(pub Uuid);
=
index b421290..9727f1c 100644
--- a/src/selecting.rs
+++ b/src/selecting.rs
@@ -1,19 +1,26 @@
=use crate::buildings::{BuildingId, BuildingsRegister, Residents};
-use crate::population::{PersonId, PersonsRegister, Sex};
+use crate::population::{PersonId, PersonsRegister, Residence, Sex};
+use bevy::ecs::system::SystemId;
=use bevy::prelude::*;
=use bevy::utils::HashSet;
=use bevy_egui::{egui, EguiContexts};
+use derive_more::From;
=
=pub struct SelectingPlugin;
=
=impl Plugin for SelectingPlugin {
=    fn build(&self, app: &mut App) {
=        app.init_resource::<Selected>()
+            .add_systems(Startup, register_selecting_systems)
=            .add_systems(Update, draw_selected_windows);
=    }
=}
=
-type SelectedId = BuildingId;
+#[derive(Clone, Copy, Debug, From, Hash, PartialEq, Eq)]
+pub enum SelectedId {
+    Building(BuildingId),
+    Person(PersonId),
+}
=
=/// Marker for anything that is selected
=#[derive(Resource, Debug, Clone, Default)]
@@ -21,38 +28,94 @@ pub struct Selected {
=    pub ids: HashSet<SelectedId>,
=}
=
+#[derive(Resource, Debug)]
+pub struct SelectingSystems {
+    pub select: SystemId<SelectedId>,
+}
+
+fn register_selecting_systems(world: &mut World) {
+    let select = world.register_system(select);
+
+    world.insert_resource(SelectingSystems { select });
+}
+
+fn select(In(id): In<SelectedId>, mut selected: ResMut<Selected>) {
+    selected.ids.insert(id);
+}
+
=fn draw_selected_windows(
=    mut contexts: EguiContexts,
=    mut selected: ResMut<Selected>,
+    mut commands: Commands,
+    systems: Res<SelectingSystems>,
=    buildings_register: Res<BuildingsRegister>,
=    buildings: Query<(&Name, &Residents), With<BuildingId>>,
=    persons_register: Res<PersonsRegister>,
-    persons: Query<(&Name, &Sex), With<PersonId>>,
+    persons: Query<(&Name, &Sex, &Residence), With<PersonId>>,
=) {
=    selected.ids.retain(|id| {
=        let mut window_is_open = true;
=
-        let Some(building_entity) = buildings_register.0.get(id) else {
-            return true;
-        };
-
-        let Ok((address, residents)) = buildings.get(*building_entity) else {
-            error!("Can't find entity {building_entity:?} for {id:?} among buildings");
-            return true;
-        };
-        egui::Window::new(address.to_string())
-            .open(&mut window_is_open)
-            .show(contexts.ctx_mut(), |ui| {
-                for resident_id in residents.0.iter() {
-                    let Some(resident_entity) = persons_register.0.get(resident_id) else {
-                        continue;
-                    };
-                    let Ok((name, sex)) = persons.get(*resident_entity) else {
-                        continue;
-                    };
-                    ui.label(format!("{sex} {name}"));
-                }
-            });
+        match id {
+            SelectedId::Building(building_id) => {
+                let Some(building_entity) = buildings_register.0.get(building_id) else {
+                    return true;
+                };
+
+                let Ok((address, residents)) = buildings.get(*building_entity) else {
+                    // TODO: Display the error message inside the window
+                    // error!(
+                    //     "Can't find entity {building_entity:?} for {building_id:?} among buildings"
+                    // );
+                    return true;
+                };
+                egui::Window::new(address.to_string())
+                    .open(&mut window_is_open)
+                    .show(contexts.ctx_mut(), |ui| {
+                        ui.heading("Residents");
+                        for resident_id in residents.0.iter() {
+                            let Some(resident_entity) = persons_register.0.get(resident_id) else {
+                                continue;
+                            };
+                            let Ok((name, sex, _residence)) = persons.get(*resident_entity) else {
+                                continue;
+                            };
+                            if ui.link(format!("{sex} {name}")).clicked() {
+                                commands.run_system_with_input(
+                                    systems.select,
+                                    resident_id.to_owned().into(),
+                                );
+                            };
+                        }
+                    });
+            }
+            SelectedId::Person(person_id) => {
+                let Some(person_entity) = persons_register.0.get(person_id) else {
+                    return true;
+                };
+                let Ok((name, _sex, residence)) = persons.get(*person_entity) else {
+                    return true;
+                };
+                let Some(residence_entity) = buildings_register.0.get(&residence.0) else {
+                    return true;
+                };
+                let Ok((address, _residents)) = buildings.get(*residence_entity) else {
+                    return true;
+                };
+
+                egui::Window::new(name.to_string())
+                    .open(&mut window_is_open)
+                    .show(contexts.ctx_mut(), |ui| {
+                        ui.heading("Residence");
+                        if ui.link(address.to_string()).clicked() {
+                            commands.run_system_with_input(
+                                systems.select,
+                                residence.0.to_owned().into(),
+                            );
+                        };
+                    });
+            }
+        }
=
=        window_is_open
=    });

Showing selected entities account for missing data

On by Tad Lispy

Information about empty houses, persons without a residence and errors is now displayed in the windows of elected entities.

For the errors I made a little macro that works similar to format! but produces a formatted label to be placed in a window (or anywhere else in UI).

index 9727f1c..808b5ab 100644
--- a/src/selecting.rs
+++ b/src/selecting.rs
@@ -3,6 +3,7 @@ use crate::population::{PersonId, PersonsRegister, Residence, Sex};
=use bevy::ecs::system::SystemId;
=use bevy::prelude::*;
=use bevy::utils::HashSet;
+use bevy_egui::egui::{Color32, RichText};
=use bevy_egui::{egui, EguiContexts};
=use derive_more::From;
=
@@ -16,6 +17,17 @@ impl Plugin for SelectingPlugin {
=    }
=}
=
+fn ui_error(ui: &mut egui::Ui, text: String) {
+    ui.label(RichText::new(text).monospace().color(Color32::RED));
+}
+
+macro_rules! ui_error {
+    ($ui: expr, $($arg:tt)*) => {
+        let formatted = format!($($arg)*);
+        ui_error($ui, formatted)
+    };
+}
+
=#[derive(Clone, Copy, Debug, From, Hash, PartialEq, Eq)]
=pub enum SelectedId {
=    Building(BuildingId),
@@ -49,9 +61,9 @@ fn draw_selected_windows(
=    mut commands: Commands,
=    systems: Res<SelectingSystems>,
=    buildings_register: Res<BuildingsRegister>,
-    buildings: Query<(&Name, &Residents), With<BuildingId>>,
+    buildings: Query<(&Name, Option<&Residents>), With<BuildingId>>,
=    persons_register: Res<PersonsRegister>,
-    persons: Query<(&Name, &Sex, &Residence), With<PersonId>>,
+    persons: Query<(&Name, &Sex, Option<&Residence>), With<PersonId>>,
=) {
=    selected.ids.retain(|id| {
=        let mut window_is_open = true;
@@ -73,19 +85,28 @@ fn draw_selected_windows(
=                    .open(&mut window_is_open)
=                    .show(contexts.ctx_mut(), |ui| {
=                        ui.heading("Residents");
-                        for resident_id in residents.0.iter() {
-                            let Some(resident_entity) = persons_register.0.get(resident_id) else {
-                                continue;
-                            };
-                            let Ok((name, sex, _residence)) = persons.get(*resident_entity) else {
-                                continue;
-                            };
-                            if ui.link(format!("{sex} {name}")).clicked() {
-                                commands.run_system_with_input(
-                                    systems.select,
-                                    resident_id.to_owned().into(),
-                                );
-                            };
+                        if let Some(residents) = residents {
+                            for resident_id in residents.0.iter() {
+                                let Some(resident_entity) = persons_register.0.get(resident_id)
+                                else {
+                                    // TODO: Implement a error_label! macro similar to format!
+                                    ui_error!(ui, "Can't find resident {resident_id:?} in the persons register");
+                                    continue
+                                };
+                                let Ok((name, sex, _residence)) = persons.get(*resident_entity)
+                                else {
+                                    ui_error!(ui, "Can't find resident {resident_id:?} in the persons register");
+                                    continue
+                                };
+                                if ui.link(format!("{sex} {name}")).clicked() {
+                                    commands.run_system_with_input(
+                                        systems.select,
+                                        resident_id.to_owned().into(),
+                                    );
+                                };
+                            }
+                        } else {
+                            ui.label(RichText::new("No residents").italics());
=                        }
=                    });
=            }
@@ -96,23 +117,30 @@ fn draw_selected_windows(
=                let Ok((name, _sex, residence)) = persons.get(*person_entity) else {
=                    return true;
=                };
-                let Some(residence_entity) = buildings_register.0.get(&residence.0) else {
-                    return true;
-                };
-                let Ok((address, _residents)) = buildings.get(*residence_entity) else {
-                    return true;
-                };
=
=                egui::Window::new(name.to_string())
=                    .open(&mut window_is_open)
=                    .show(contexts.ctx_mut(), |ui| {
=                        ui.heading("Residence");
-                        if ui.link(address.to_string()).clicked() {
-                            commands.run_system_with_input(
-                                systems.select,
-                                residence.0.to_owned().into(),
-                            );
-                        };
+                        if let Some(residence) = residence {
+                            let Some(residence_entity) = buildings_register.0.get(&residence.0)
+                            else {
+                                ui_error!(ui, "Can't find {residence:?} in the buildings register");
+                                return;
+                            };
+                            let Ok((address, _residents)) = buildings.get(*residence_entity) else {
+                                ui_error!(ui, "Can't find {residence_entity:?} among the buildings");
+                                return;
+                            };
+                            if ui.link(address.to_string()).clicked() {
+                                commands.run_system_with_input(
+                                    systems.select,
+                                    residence.0.to_owned().into(),
+                                );
+                            };
+                        } else {
+                            ui.label(RichText::new("No known residence").italics());
+                        }
=                    });
=            }
=        }

Implement a searchlight following the pointer

On by Tad Lispy

It illuminates the night, when otherwise everything looked gray and dull.

index 888a6c2..49aaab0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,6 +14,7 @@ pub mod parcels;
=pub mod population;
=pub mod roads;
=pub mod ron_export;
+pub mod searchlight;
=pub mod selecting;
=pub mod simulation;
=pub mod sun;
index c6ecb4d..4a57ca0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,6 +17,7 @@ use otterhide::major_state::MajorStatePlugin;
=use otterhide::parcels::ParcelsPlugin;
=use otterhide::population::PopulationPlugin;
=use otterhide::roads::RoadsPlugin;
+use otterhide::searchlight::SearchlightPlugin;
=use otterhide::selecting::SelectingPlugin;
=use otterhide::simulation::SimulationLoadingPlugin;
=use otterhide::sun::SunPlugin;
@@ -50,6 +51,7 @@ fn main() {
=        .add_plugins(HudPlugin)
=        .add_plugins(PopulationPlugin)
=        .add_plugins(SelectingPlugin)
+        .add_plugins(SearchlightPlugin)
=        .add_systems(Startup, greet)
=        .run()
=}
new file mode 100644
index 0000000..77ab4c3
--- /dev/null
+++ b/src/searchlight.rs
@@ -0,0 +1,71 @@
+use bevy::prelude::*;
+use bevy_mod_raycast::deferred::RaycastSource;
+use bevy_rts_camera::RtsCamera;
+
+use crate::{camera::PointerRay, major_state::MajorState};
+
+pub struct SearchlightPlugin;
+
+impl Plugin for SearchlightPlugin {
+    fn build(&self, app: &mut App) {
+        app.add_systems(OnEnter(MajorState::GettingReady), setup_searchlight)
+            .add_systems(Update, move_searchlight);
+    }
+}
+
+#[derive(Component, Debug, Reflect)]
+#[reflect(Component)]
+pub struct Searchlight;
+
+fn setup_searchlight(mut commands: Commands) {
+    commands.spawn((
+        Searchlight,
+        PointLightBundle {
+            point_light: PointLight {
+                color: Color::hsl(120.0, 1.0, 0.8),
+                intensity: 3e7,
+                range: 300.0,
+                shadows_enabled: true,
+                ..default()
+            },
+            transform: Transform::from_translation(Vec3 {
+                x: 0.0,
+                y: 30.0,
+                z: 0.0,
+            }),
+            ..default()
+        },
+        Name::new("Searchlight"),
+    ));
+}
+
+fn move_searchlight(
+    mut searchlight: Query<&mut Transform, With<Searchlight>>,
+    ray_sources: Query<&RaycastSource<PointerRay>, With<RtsCamera>>,
+    mut gizmos: Gizmos,
+    time: Res<Time<Real>>,
+) {
+    let Ok(mut transform) = searchlight.get_single_mut() else {
+        return;
+    };
+    gizmos.sphere(transform.translation, Quat::IDENTITY, 10.0, Color::YELLOW);
+
+    let Ok(ray_source) = ray_sources.get_single() else {
+        return;
+    };
+
+    // TODO: Use ground, trees, road and other meshes
+    let Some(intersection) =
+        ray_source.intersect_primitive(bevy_mod_raycast::primitives::Primitive3d::Plane {
+            point: Vec3::ZERO,
+            normal: Vec3::Y,
+        })
+    else {
+        return;
+    };
+
+    let target = intersection.position() + Vec3::Y * 40.0;
+
+    let delta = target - transform.translation;
+    transform.translation += delta * (time.delta_seconds() * 4.0).max(1.0);
+}