diff --git a/MODS.md b/MODS.md index ca0a4fa..d0d675d 100644 --- a/MODS.md +++ b/MODS.md @@ -1546,7 +1546,7 @@ objects: [ The simulator spawns the glTF at the track pose plus offset, rotation and height, floating-origin safe like the signal models; an unknown object name gets a placeholder -block and a warning. In the route editor the **object tool** (key 5) picks an object kind +block and a warning. In the route editor the **object tool** (key 3 in the equipment box) picks an object kind and drops it on the nearest track; the selection panel edits position, lateral offset, rotation and height, and the rule check flags placements outside their track or naming an object no installed mod has. **Repeat in a row** stamps copies of the selected instance @@ -1563,6 +1563,58 @@ editor's selection panel has the checkbox, and both programs resolve the height way: an object is placed by the terrain tile it stands on, so it streams in and out with that tile and its feet meet the ground the tile actually has. +### Parametric buildings + +The route editor's **building tool** places an editable building recipe, not a reference +to a glTF. Static houses under `objects:` remain supported and are the right choice for a +specific landmark. A generated building is stored under `buildings:`: + +```ron +buildings: [ + ( + edge: 0, + s: 540.0, + lateral_offset: -18.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 12.0, + length: 10.0, + floors: 3, + floor_height: 2.9, + roof_style: Gable, + roof_height: 3.2, + facade: Plaster, + facade_color: (0.82, 0.72, 0.58), + roof: ClayTile, + window_spacing: 2.6, + window_width: 1.25, + window_height: 1.45, + lit_window_share: 0.38, + balconies: true, + balcony_every: 2, + balcony_depth: 1.4, + seed: 1384, + ), + ), +], +``` + +`use_kind` is `Residential`, `Commercial` or `Industrial`; roofs are `Gable`, `Hip`, +`Flat`, `Shed` and `Mansard`. Facades use `Plaster`, `RedBrick`, `YellowBrick`, +`Concrete` or `MetalPanel`, and roofs use `ClayTile`, `Slate`, `StandingSeam` or +`Bitumen`. The editor constrains dimensions to valid ranges and exposes the same values +after placement. Copy/paste clones the complete recipe, including `seed`, so the copy has +the same design and lit-window pattern. + +The simulator receives only the normalised, tile-local recipe. It reuses one generated +three-level mesh set for every identical recipe and global PBR materials for every +building; it does not carry the editor's selection or authoring state. LOD0 includes +window surrounds and balconies, LOD1 retains the facade rhythm, and LOD2 is the silhouette. +The emissive share is selected deterministically from `seed` and switched by the normal +day/night system, so loading the route never rearranges illuminated apartments. + **Levels of detail** work as for vehicles and signals: nodes named `_LOD0`, `_LOD1`, … are shown by camera distance. A model without the suffix is one level, drawn up to the cull distance. This matters most for trees: every tree of a wood is drawn as an @@ -1811,14 +1863,14 @@ can be moved, rescaled or deleted on its own afterwards. Trees stream in and out their terrain tile and share meshes per species, so even a big wood renders as instanced draws. -In the route editor the **tree tool** (key 6) plants one tree per click. The **forest -brush** (key 7) outlines an area — Enter or right-click **bakes** it into single trees +In the route editor the **tree tool** (key 2 in the vegetation box) plants one tree per click. The **forest +brush** (key 3) outlines an area — Enter or right-click **bakes** it into single trees (one per `area per tree` m², species from the tool options, clear of the track strip). **File ▸ Import forest…** reads an Overpass JSON extract (`landuse=forest` / `natural=wood` ways, same download path as the track import) and bakes each polygon the same way — an optional aid: whoever wants every tree hand-set simply never uses it, and an imported wood is thinned out or cleared exactly like a painted one. For bulk edits the -**marking brush** (key 8) sweeps over the map and marks every tree and object under the +**marking brush** (key 6) sweeps over the map and marks every tree, object or parametric building under the circle; Delete (or the panel button) removes them together in one undo step. ### Fields @@ -2340,7 +2392,7 @@ deletes the whole layer. Retyping the layer in the selection panel moves a singl into another one. Hiding is session state; the markers themselves travel with the line, so the next session still has them. -The **marker tool** (key 9) sets one per click into the layer named in the tool options. +The **marker tool** (key 5 in the equipment box) sets one per click into the layer named in the tool options. **File ▸ Import reference markers…** reads an Overpass JSON extract and turns the tags it knows into markers, each in the layer of its tag: `level-crossing`, `platform`, `station`, `signal`, `switch`, `buffer-stop`, `kilometre-mark`, `bridge`, `tunnel`, diff --git a/README.md b/README.md index f6ebfdf..61e5988 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,16 @@ builder is shared read-only, the DGM sheets keep their own short lock. Terrain, vegetation and track objects live in `world-render` and therefore look the same in the simulator and in the route editor. +Parametric buildings use the same streamed terrain path without replacing static scenery. +Their route entry keeps an editable recipe (residential, commercial or industrial use, +width, length, floor count and height, five roof shapes, facade/roof material, colour, +window rhythm, balconies and a stable variation seed). The terrain compiler normalises and +bakes that recipe into a compact placement; `world-render` caches one three-level LOD mesh +set per distinct recipe and shares a global PBR library of plaster, red/yellow brick, +concrete, metal panels, clay tile, slate, standing seam and bitumen. Window illumination is +chosen deterministically from the seed and appears only at night. Copy/paste preserves the +whole recipe and seed, while all fields remain editable in the module editor. + **Fields** lie on that ground: the outline and the crop are line content, and what a field looks like on the day is a function of the crop, the date and the field's own seed — winter wheat is blue-green in April, gold in the last week of July and stubble in August, and no @@ -781,7 +791,7 @@ be laid, which never touches one already lying there. | Tool | What a click does | |---|---| | **Every category** | | -| `1` Select | In every category: pick whatever stands on the map and edit its fields; `Delete` removes it. `Ctrl`+click gathers devices, objects, trees and markers into a multi-selection (a second `Ctrl`+click takes one out again); a press on empty ground dragged open selects everything inside the circle — `Ctrl` adds it to what is gathered, `Delete` removes the lot in one step | +| `1` Select | In every category: pick whatever stands on the map and edit its fields; `Delete` removes it. `Ctrl`+click gathers devices, objects, parametric buildings, trees and markers into a multi-selection (a second `Ctrl`+click takes one out again); a press on empty ground dragged open selects everything inside the circle — `Ctrl` adds it to what is gathered, `Delete` removes the lot in one step | | **Track** | | | `2` Lay track | Press and drag sets the standing end and its heading — on an open end it continues that track, on a track's middle it starts the branch of a turnout (drag along the track = facing, against it = trailing). Every further click appends the arc that leaves the alignment tangentially and hits the point — G1-continuous by construction — or a straight while `Ctrl` is held; the running end snaps onto open ends and closes the gap with two tangent arcs. The status bar reads out length and radius. `Enter` or right-click finishes, `Esc` cancels. The *Tool* section sets what the piece is laid as: track type, speed, gradient, electrification, parallel tracks at a spacing; the toolbox's toggle box snaps radii onto the standard series, lays easements — a curve then goes down as clothoid – arc – clothoid with the rulebook's cant for the piece's speed, ramped over the transitions — and snaps the piece to the terrain: sampled ground heights become its gradient profile, a free start drops onto the surface, an end joined onto other track keeps that track's height | | `3` Split track | Cuts the track at the click — two tracks on one joint | @@ -793,11 +803,12 @@ be laid, which never touches one already lying there. | **Lineside equipment** | | | `2` Place device | Puts the chosen device kind (signal, magnet, LZB, platform, …) on the clicked track | | `3` Place object | Drops a mod's 3D object at its predefined offset and rotation; the toolbox's terrain-snap toggle bases it on the ground instead of the rail plane | -| `4` Place marker | A reference marker in a named layer — a drawing aid, nothing in the simulation reads it | +| `4` Parametric building | Places an editable residential, commercial or industrial building. Its properties panel controls dimensions, floors and total height, facade colour/material, five roof shapes, windows, stable night lighting and balconies; `Ctrl+C` / `Ctrl+V` preserves the complete recipe | +| `5` Place marker | A reference marker in a named layer — a drawing aid, nothing in the simulation reads it | | **Vegetation** | | | `2` / `3` Tree / forest | One tree per click, or an outlined area baked into single trees — each one stays editable | | `4` Field | Clicks outline a piece of farmland, Enter or right-click closes it, the crop comes from the tool options. The usual way to get fields is **File ▸ Import fields…**, which fetches them from the state agricultural registers | -| `5` Marking brush | Sweep to mark trees and objects in bulk and delete them together | +| `6` Marking brush | Sweep to mark trees, objects and parametric buildings in bulk and delete them together | | **Terrain** | | | `2` Raise ground | One lifting stroke per click, by the set amount and radius. The track keeps its height, cutting and embankment are laid over the strokes afterwards | | `3` Lower ground | The same stroke downward — a hollow, a pond bed, a pit | diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index ab16592..bc7039b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -36,7 +36,7 @@ use bevy::prelude::*; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use content::import::dgm::TerrainSource; use content::terrain::{ - Scenery, TerrainBuilder, TerrainEdits, TerrainOptions, TerrainStats, Vegetation, + Buildings, Scenery, TerrainBuilder, TerrainEdits, TerrainOptions, TerrainStats, Vegetation, }; use content::vehicles::passenger_coach; use mod_runtime::ModRuntime; @@ -1030,6 +1030,11 @@ fn setup( &sim.net, terrain_options.zone, )) + .with_buildings(Buildings::from_line( + &line_source, + &sim.net, + terrain_options.zone, + )) .with_crowd(crowd) .with_fields(farmland) .with_waters(waters) diff --git a/crates/app/src/streaming.rs b/crates/app/src/streaming.rs index d87c45a..f4e6520 100644 --- a/crates/app/src/streaming.rs +++ b/crates/app/src/streaming.rs @@ -142,6 +142,7 @@ impl TerrainStreamer { pub fn stream_terrain( mut commands: Commands, mut meshes: ResMut>, + mut building_assets: ResMut, mut streamer: ResMut, mut info: ResMut, sim: Res, @@ -255,6 +256,7 @@ pub fn stream_terrain( let entity = render::spawn_terrain_tile( &mut commands, &mut meshes, + &mut building_assets, &streamer.material, &streamer.catalog, &tile, diff --git a/crates/content/src/buildings.rs b/crates/content/src/buildings.rs new file mode 100644 index 0000000..7b986f9 --- /dev/null +++ b/crates/content/src/buildings.rs @@ -0,0 +1,494 @@ +//! Parametric buildings: editable source parameters and their compact runtime form. +//! +//! A route stores [`BuildingSource`]. Terrain compilation validates and normalises its +//! [`BuildingSpec`] once, then hands the renderer a [`BakedBuilding`]. The simulator never +//! edits these values; it only turns the baked specification into cached LOD meshes. + +use serde::{Deserialize, Serialize}; + +/// The broad use controls the facade rhythm and sensible editor defaults. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum BuildingUse { + #[default] + Residential, + Commercial, + Industrial, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum RoofStyle { + #[default] + Gable, + Hip, + Flat, + Shed, + Mansard, + /// Repeating north-light teeth, characteristic of workshops and factories. + Sawtooth, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum FacadeMaterial { + #[default] + Plaster, + RedBrick, + YellowBrick, + Concrete, + MetalPanel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum RoofMaterial { + #[default] + ClayTile, + Slate, + StandingSeam, + Bitumen, +} + +/// The complete authoring recipe for one building. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BuildingSpec { + pub use_kind: BuildingUse, + /// Outside dimensions [m]. Width is the front, length runs away from it. + pub width: f32, + pub length: f32, + pub floors: u8, + pub floor_height: f32, + pub roof_style: RoofStyle, + pub roof_height: f32, + pub facade: FacadeMaterial, + /// Linear sRGB tint multiplied with the shared facade material. + pub facade_color: [f32; 3], + pub roof: RoofMaterial, + /// Approximate horizontal spacing between window centres [m]. + pub window_spacing: f32, + pub window_width: f32, + pub window_height: f32, + /// Windows whose stable hash is below this share light at night. + pub lit_window_share: f32, + pub balconies: bool, + pub balcony_every: u8, + pub balcony_depth: f32, + /// Number of masonry chimney stacks distributed over the roof. + pub chimneys: u8, + /// Number of metal extraction vents distributed over the roof. + pub roof_vents: u8, + /// Number of glazed roof lights. Sawtooth roofs use this as the number of + /// glazed north-light bays; other roofs receive individual skylights. + pub skylights: u8, + /// Eaves and downpipes generated as shared metal detail geometry. + pub rain_gutters: bool, + /// Projecting entrance/loading canopy on the front facade. + pub entrance_canopy: bool, + /// Front entrances for industrial buildings. For the other uses there is + /// always one ordinary entrance door. + pub loading_doors: u8, + /// Stable design and night-light seed. Copy/paste deliberately preserves it. + pub seed: u64, +} + +impl Default for BuildingSpec { + fn default() -> Self { + Self { + use_kind: BuildingUse::Residential, + width: 12.0, + length: 10.0, + floors: 3, + floor_height: 2.9, + roof_style: RoofStyle::Gable, + roof_height: 3.2, + facade: FacadeMaterial::Plaster, + facade_color: [0.82, 0.72, 0.58], + roof: RoofMaterial::ClayTile, + window_spacing: 2.6, + window_width: 1.25, + window_height: 1.45, + lit_window_share: 0.38, + balconies: true, + balcony_every: 2, + balcony_depth: 1.4, + chimneys: 0, + roof_vents: 0, + skylights: 0, + rain_gutters: false, + entrance_canopy: false, + loading_doors: 1, + seed: 1, + } + } +} + +impl BuildingSpec { + /// Bounds unsafe or degenerate file values before mesh generation. + pub fn normalised(&self) -> Self { + let mut out = self.clone(); + out.width = out.width.clamp(3.0, 150.0); + out.length = out.length.clamp(3.0, 250.0); + out.floors = out.floors.clamp(1, 40); + out.floor_height = out.floor_height.clamp(2.2, 8.0); + out.roof_height = out.roof_height.clamp(0.0, 20.0); + out.window_spacing = out.window_spacing.clamp(1.2, 12.0); + out.window_width = out.window_width.clamp(0.5, out.window_spacing * 0.82); + out.window_height = out.window_height.clamp(0.5, out.floor_height * 0.72); + out.lit_window_share = out.lit_window_share.clamp(0.0, 1.0); + out.balcony_every = out.balcony_every.max(1); + out.balcony_depth = out.balcony_depth.clamp(0.6, 4.0); + out.chimneys = out.chimneys.min(8); + out.roof_vents = out.roof_vents.min(24); + out.skylights = out.skylights.min(24); + out.loading_doors = out.loading_doors.clamp(1, 10); + out + } + + pub fn wall_height(&self) -> f32 { + self.floors as f32 * self.floor_height + } + + pub fn total_height(&self) -> f32 { + self.wall_height() + + if self.roof_style == RoofStyle::Flat { + 0.35 + } else { + self.roof_height + } + } + + /// Stable key for the renderer's geometry cache. It includes every value that + /// changes geometry, vertex tint or the baked window pattern. + pub fn mesh_key(&self) -> u64 { + let s = self.normalised(); + let mut h = 0xcbf29ce484222325u64; + let mut add = |v: u64| { + h ^= v; + h = h.wrapping_mul(0x100000001b3); + }; + add(s.use_kind as u64); + add(s.width.to_bits() as u64); + add(s.length.to_bits() as u64); + add(s.floors as u64); + add(s.floor_height.to_bits() as u64); + add(s.roof_style as u64); + add(s.roof_height.to_bits() as u64); + add(s.window_spacing.to_bits() as u64); + add(s.window_width.to_bits() as u64); + add(s.window_height.to_bits() as u64); + add(s.lit_window_share.to_bits() as u64); + add(s.balconies as u64); + add(s.balcony_every as u64); + add(s.balcony_depth.to_bits() as u64); + add(s.chimneys as u64); + add(s.roof_vents as u64); + add(s.skylights as u64); + add(s.rain_gutters as u64); + add(s.entrance_canopy as u64); + add(s.loading_doors as u64); + add(s.seed); + for channel in s.facade_color { + add(channel.to_bits() as u64); + } + h + } +} + +/// Ready-to-use German building recipes. They are editor conveniences rather +/// than opaque assets: applying one copies every value into [`BuildingSpec`], +/// after which each property remains independently editable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BuildingPreset { + DetachedHouse, + Farmhouse, + TownHouse, + ApartmentBlock, + OfficeBlock, + RetailRow, + Workshop, + Warehouse, + FactoryHall, + LogisticsHall, +} + +impl BuildingPreset { + pub const ALL: [Self; 10] = [ + Self::DetachedHouse, + Self::Farmhouse, + Self::TownHouse, + Self::ApartmentBlock, + Self::OfficeBlock, + Self::RetailRow, + Self::Workshop, + Self::Warehouse, + Self::FactoryHall, + Self::LogisticsHall, + ]; + + pub fn spec(self) -> BuildingSpec { + match self { + Self::DetachedHouse => BuildingSpec { + width: 11.0, + length: 9.0, + floors: 2, + floor_height: 2.8, + roof_style: RoofStyle::Gable, + roof_height: 3.1, + facade_color: [0.91, 0.84, 0.70], + chimneys: 1, + rain_gutters: true, + balconies: false, + ..Default::default() + }, + Self::Farmhouse => BuildingSpec { + width: 18.0, + length: 11.5, + floors: 2, + floor_height: 2.85, + roof_style: RoofStyle::Gable, + roof_height: 4.2, + facade: FacadeMaterial::RedBrick, + facade_color: [0.94, 0.91, 0.86], + roof: RoofMaterial::ClayTile, + window_spacing: 3.0, + chimneys: 2, + rain_gutters: true, + balconies: false, + ..Default::default() + }, + Self::TownHouse => BuildingSpec { + width: 9.0, + length: 13.0, + floors: 4, + floor_height: 3.0, + roof_style: RoofStyle::Mansard, + roof_height: 3.6, + facade_color: [0.78, 0.88, 0.82], + roof: RoofMaterial::Slate, + window_spacing: 2.25, + chimneys: 2, + rain_gutters: true, + balconies: false, + ..Default::default() + }, + Self::ApartmentBlock => BuildingSpec { + width: 22.0, + length: 13.0, + floors: 5, + floor_height: 2.85, + roof_style: RoofStyle::Hip, + roof_height: 3.0, + facade_color: [0.82, 0.86, 0.76], + window_spacing: 2.7, + chimneys: 3, + rain_gutters: true, + balconies: true, + balcony_every: 1, + balcony_depth: 1.6, + ..Default::default() + }, + Self::OfficeBlock => BuildingSpec { + use_kind: BuildingUse::Commercial, + width: 30.0, + length: 18.0, + floors: 5, + floor_height: 3.5, + roof_style: RoofStyle::Flat, + roof_height: 0.0, + facade: FacadeMaterial::Concrete, + facade_color: [0.78, 0.82, 0.84], + roof: RoofMaterial::Bitumen, + window_spacing: 3.0, + window_width: 1.8, + window_height: 1.75, + roof_vents: 4, + skylights: 4, + entrance_canopy: true, + balconies: false, + ..Default::default() + }, + Self::RetailRow => BuildingSpec { + use_kind: BuildingUse::Commercial, + width: 34.0, + length: 12.0, + floors: 2, + floor_height: 3.7, + roof_style: RoofStyle::Flat, + roof_height: 0.0, + facade_color: [0.82, 0.76, 0.66], + roof: RoofMaterial::Bitumen, + window_spacing: 3.4, + window_width: 2.0, + window_height: 1.85, + roof_vents: 2, + entrance_canopy: true, + balconies: false, + ..Default::default() + }, + Self::Workshop => BuildingSpec { + use_kind: BuildingUse::Industrial, + width: 26.0, + length: 18.0, + floors: 1, + floor_height: 5.4, + roof_style: RoofStyle::Gable, + roof_height: 3.0, + facade: FacadeMaterial::RedBrick, + facade_color: [0.92, 0.90, 0.85], + roof: RoofMaterial::StandingSeam, + window_spacing: 4.2, + window_width: 1.8, + window_height: 1.8, + chimneys: 1, + roof_vents: 2, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 2, + balconies: false, + ..Default::default() + }, + Self::Warehouse => BuildingSpec { + use_kind: BuildingUse::Industrial, + width: 52.0, + length: 30.0, + floors: 1, + floor_height: 7.0, + roof_style: RoofStyle::Shed, + roof_height: 2.4, + facade: FacadeMaterial::MetalPanel, + facade_color: [0.68, 0.75, 0.78], + roof: RoofMaterial::StandingSeam, + window_spacing: 6.5, + window_width: 2.4, + window_height: 1.8, + roof_vents: 5, + skylights: 6, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 4, + balconies: false, + ..Default::default() + }, + Self::FactoryHall => BuildingSpec { + use_kind: BuildingUse::Industrial, + width: 64.0, + length: 38.0, + floors: 1, + floor_height: 7.5, + roof_style: RoofStyle::Sawtooth, + roof_height: 3.2, + facade: FacadeMaterial::YellowBrick, + facade_color: [0.86, 0.84, 0.78], + roof: RoofMaterial::StandingSeam, + window_spacing: 6.0, + window_width: 2.6, + window_height: 2.1, + roof_vents: 8, + skylights: 5, + rain_gutters: true, + loading_doors: 3, + balconies: false, + ..Default::default() + }, + Self::LogisticsHall => BuildingSpec { + use_kind: BuildingUse::Industrial, + width: 78.0, + length: 42.0, + floors: 1, + floor_height: 8.0, + roof_style: RoofStyle::Flat, + roof_height: 0.0, + facade: FacadeMaterial::MetalPanel, + facade_color: [0.76, 0.78, 0.74], + roof: RoofMaterial::Bitumen, + window_spacing: 7.5, + window_width: 2.6, + window_height: 2.0, + roof_vents: 10, + skylights: 12, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 8, + balconies: false, + ..Default::default() + }, + } + } +} + +/// A dynamic building placed relative to a track, matching static scenery placement. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuildingSource { + pub edge: u32, + pub s: f64, + #[serde(default)] + pub lateral_offset: f64, + #[serde(default)] + pub yaw_deg: f64, + #[serde(default)] + pub height: f64, + #[serde(default = "default_true")] + pub snap_to_terrain: bool, + #[serde(default)] + pub spec: BuildingSpec, +} + +fn default_true() -> bool { + true +} + +/// Runtime-only product of the terrain bake, already posed in its tile frame. +#[derive(Debug, Clone, PartialEq)] +pub struct BakedBuilding { + pub pos: [f32; 3], + pub rotation: [f32; 4], + pub source_index: u32, + pub spec: BuildingSpec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalisation_bounds_file_values() { + let spec = BuildingSpec { + width: -1.0, + floors: 0, + lit_window_share: 4.0, + balcony_every: 0, + chimneys: u8::MAX, + roof_vents: u8::MAX, + skylights: u8::MAX, + loading_doors: 0, + ..Default::default() + } + .normalised(); + assert_eq!(spec.width, 3.0); + assert_eq!(spec.floors, 1); + assert_eq!(spec.lit_window_share, 1.0); + assert_eq!(spec.balcony_every, 1); + assert_eq!(spec.chimneys, 8); + assert_eq!(spec.roof_vents, 24); + assert_eq!(spec.skylights, 24); + assert_eq!(spec.loading_doors, 1); + } + + #[test] + fn copies_have_the_same_mesh_and_light_pattern() { + let a = BuildingSpec::default(); + let b = a.clone(); + assert_eq!(a.mesh_key(), b.mesh_key()); + } + + #[test] + fn every_preset_is_valid_and_independently_editable() { + for preset in BuildingPreset::ALL { + let spec = preset.spec(); + assert_eq!(spec, spec.normalised()); + } + let mut factory = BuildingPreset::FactoryHall.spec(); + assert_eq!(factory.roof_style, RoofStyle::Sawtooth); + assert_eq!(factory.loading_doors, 3); + factory.width += 4.0; + assert_ne!(factory, BuildingPreset::FactoryHall.spec()); + } +} diff --git a/crates/content/src/compose.rs b/crates/content/src/compose.rs index 2b084de..4627525 100644 --- a/crates/content/src/compose.rs +++ b/crates/content/src/compose.rs @@ -359,6 +359,12 @@ fn merge_module(merged: &mut LineSource, module: &LineSource, off: ModuleOffsets o.edge += off.edges; merged.objects.push(o); } + // Parametric buildings use the same track-relative placement as scenery. + for building in &module.buildings { + let mut building = building.clone(); + building.edge += off.edges; + merged.buildings.push(building); + } // Trees are geo-positioned — the georeference is the connection, so a // plain append composes them. // Stabling roads and portals sit on an edge like a device; their names stay as they @@ -622,9 +628,10 @@ mod tests { assert!(gap < 0.01, "gap {gap} m"); } - /// Everything that names an edge follows its module's offset — the scenery objects - /// and the stabling roads as much as the devices. A list forgotten in `merge_module` - /// is lost silently, which is the one failure a composed line cannot show by itself. + /// Everything that names an edge follows its module's offset — scenery objects, + /// generated buildings and stabling roads as much as devices. A list forgotten in + /// `merge_module` is lost silently, which is the one failure a composed line cannot + /// show by itself. #[test] fn objects_and_yards_survive_the_composition() { use crate::route::{ObjectSource, YardSource}; @@ -641,6 +648,15 @@ mod tests { height: 0.0, snap_to_terrain: false, }); + line.buildings.push(crate::buildings::BuildingSource { + edge: 0, + s: 600.0, + lateral_offset: 18.0, + yaw_deg: 0.0, + height: 0.0, + snap_to_terrain: true, + spec: Default::default(), + }); line.yards.push(YardSource { name: format!("Portal {name}"), kind: YardKind::Portal, @@ -653,6 +669,8 @@ mod tests { let Composed { line: merged, .. } = composition.compose(&lines).expect("composes"); assert_eq!(merged.objects.len(), 2); assert_eq!(merged.objects[1].edge, 1); + assert_eq!(merged.buildings.len(), 2); + assert_eq!(merged.buildings[1].edge, 1); assert_eq!(merged.yards.len(), 2); assert_eq!( merged.yards[1].edge, 1, diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c9c9be2..d9e8d9f 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -2,6 +2,7 @@ #[cfg(test)] mod area_tests; +pub mod buildings; pub mod characters; pub mod compose; pub mod demo; @@ -16,6 +17,10 @@ pub mod terrain; pub mod vehicles; pub mod water; +pub use buildings::{ + BakedBuilding, BuildingPreset, BuildingSource, BuildingSpec, BuildingUse, FacadeMaterial, + RoofMaterial, RoofStyle, +}; pub use characters::{CharacterSpec, Gender, Role}; pub use compose::Composition; pub use demo::musterbahn; @@ -30,7 +35,7 @@ pub use roads::{RoadPatch, Roads}; pub use route::{CompiledLine, FieldSource, LineSource, PowerLineSource, TreeSource}; pub use scenarios::{musterbahn_day, re_4711, to_musterstadt}; pub use terrain::{ - Scenery, SceneryInstance, TerrainBuilder, TerrainEdits, TerrainOptions, TerrainStats, - TerrainTile, TileKey, Tree, Vegetation, + Buildings, Scenery, SceneryInstance, TerrainBuilder, TerrainEdits, TerrainOptions, + TerrainStats, TerrainTile, TileKey, Tree, Vegetation, }; pub use water::{WaterPatch, Waters}; diff --git a/crates/content/src/people.rs b/crates/content/src/people.rs index 400dd80..c258e36 100644 --- a/crates/content/src/people.rs +++ b/crates/content/src/people.rs @@ -1711,7 +1711,7 @@ mod tests { } // Rescattering onto the built tile gives the same placement. let tile = placed[0].0; - let (_, _, again) = builder.rescatter(tile); + let (_, _, _, again) = builder.rescatter(tile); assert_eq!(again, tile.people); } @@ -2214,7 +2214,7 @@ mod tests { assert_eq!(forecourt.len(), 4); // Rescattering gives the same people, and the same ways. for tile in &tiles { - let (_, _, again) = builder.rescatter(tile); + let (_, _, _, again) = builder.rescatter(tile); assert_eq!(again, tile.people); } } diff --git a/crates/content/src/route.rs b/crates/content/src/route.rs index 900b749..f7c8040 100644 --- a/crates/content/src/route.rs +++ b/crates/content/src/route.rs @@ -1225,6 +1225,10 @@ pub struct LineSource { /// them — they are the line's furniture. #[serde(default)] pub objects: Vec, + /// Parametric buildings. Their editable recipe stays in the module; terrain + /// compilation bakes it into streamed LOD mesh instances for the simulator. + #[serde(default)] + pub buildings: Vec, /// Stabling roads and portals — where stock may be put, and where trains appear and /// disappear (see [`YardSource`]). A line without them can be driven but not /// shunted, because there is nowhere to shunt to. @@ -1324,6 +1328,7 @@ impl Default for LineSource { edges: Vec::new(), devices: Vec::new(), objects: Vec::new(), + buildings: Vec::new(), yards: Vec::new(), trees: Vec::new(), walk_paths: Vec::new(), @@ -1606,6 +1611,8 @@ pub enum RuleIssue { ObjectOffEdge { object: u32 }, /// Scenery object names an `objects/*.ron` no installed mod has. UnknownObject { object: u32 }, + /// Parametric building outside its track (bad edge index or `s` beyond the length). + BuildingOffEdge { building: u32 }, /// Flank protection of a route names a node that is no switch, or a /// signal that does not exist. FlankGuardInvalid { route: u32 }, @@ -1909,6 +1916,10 @@ impl LineSource { for o in &mut self.objects { o.edge = edge_map(o.edge).expect("kept objects sit on kept edges"); } + self.buildings.retain(|b| edge_map(b.edge).is_some()); + for building in &mut self.buildings { + building.edge = edge_map(building.edge).expect("kept buildings sit on kept edges"); + } // A road whose track is gone is gone with it; the rest follow their edge. self.yards.retain(|y| edge_map(y.edge).is_some()); for y in &mut self.yards { @@ -2643,6 +2654,12 @@ impl LineSource { o.s -= s; } } + for building in &mut self.buildings { + if building.edge as usize == index && building.s >= s { + building.edge = new_index; + building.s -= s; + } + } for y in &mut self.yards { if y.edge as usize == index && y.s >= s { y.edge = new_index; @@ -2859,6 +2876,12 @@ impl LineSource { issues.push(RuleIssue::UnknownObject { object }); } } + for (i, building) in self.buildings.iter().enumerate() { + match lengths_of(building.edge) { + Some(len) if (0.0..=len).contains(&building.s) => {} + _ => issues.push(RuleIssue::BuildingOffEdge { building: i as u32 }), + } + } // Stabling roads and portals: on their track, uniquely named, and — for a portal — // at the edge of the line, which is where trains are allowed to appear from. @@ -3221,6 +3244,11 @@ impl LineSource { return Err(CompileError::UnknownEdge(o.edge)); } } + for building in &self.buildings { + if building.edge as usize >= edge_ids.len() { + return Err(CompileError::UnknownEdge(building.edge)); + } + } // Stabling roads and portals: marks on the graph, read by the simulation rather // than drawn. The edge ids run in source order, so a yard resolves straight. @@ -4357,6 +4385,38 @@ mod tests { assert!(issues.contains(&RuleIssue::UnknownObject { object: 1 })); } + #[test] + fn buildings_round_trip_and_follow_split_and_removal() { + let house = |edge: u32, s: f64| crate::buildings::BuildingSource { + edge, + s, + lateral_offset: 14.0, + yaw_deg: 90.0, + height: 0.0, + snap_to_terrain: true, + spec: crate::buildings::BuildingSpec::default(), + }; + let mut line = musterbahn(); + line.buildings = vec![house(0, 500.0), house(0, 2500.0), house(1, 100.0)]; + + let round_trip = LineSource::from_ron(&line.to_ron()).expect("building RON round-trip"); + assert_eq!(round_trip.buildings, line.buildings); + + line.split_edge(0, 1500.0).expect("splits"); + assert_eq!(line.buildings[0].edge, 0, "before the cut"); + assert_eq!(line.buildings[1].edge, 3, "beyond the cut"); + assert!((line.buildings[1].s - 1000.0).abs() < 1e-9); + + line.remove_edge(1); + assert_eq!(line.buildings.len(), 2); + assert_eq!(line.buildings[1].edge, 2); + line.compile().expect("still compiles"); + + line.buildings[0].s = 99_999.0; + let issues = line.check(&type_registry(), &Default::default()); + assert!(issues.contains(&RuleIssue::BuildingOffEdge { building: 0 })); + } + /// The registry-aware rules: unknown names and an LZB superstructure /// whose conductor was never placed. #[test] diff --git a/crates/content/src/terrain.rs b/crates/content/src/terrain.rs index ea24425..5e92a8e 100644 --- a/crates/content/src/terrain.rs +++ b/crates/content/src/terrain.rs @@ -18,6 +18,7 @@ //! length the app uses [`TerrainBuilder`] instead and builds single tiles by key while //! driving (plan 4.3). +use crate::buildings::{BakedBuilding, BuildingSource, BuildingSpec}; use crate::import::dgm::{HeightTile, TerrainSource}; use crate::people::{Crowd, PersonInstance, Walkway, scatter_people, scatter_walkways}; use crate::route::{LineSource, ObjectSource, TerrainEdit, TerrainEditSource, TreeSource}; @@ -148,6 +149,8 @@ pub struct TerrainTile { /// Scenery objects standing on this tile, their feet on its ground where /// they snap to it — streamed with the tile like the trees. pub objects: Vec, + /// Parametric buildings, with authoring settings normalised and placement baked. + pub buildings: Vec, /// The people waiting on this tile's platforms (plan ch. 12) — streamed /// with the tile like the objects, and derived like the trees of a forest: /// nothing about them is stored in the line. @@ -500,6 +503,66 @@ pub struct Scenery { by_tile: CellMap>, } +/// Parametric buildings prepared for tile builds. +#[derive(Debug, Clone, Default)] +pub struct Buildings { + placed: Vec, + by_tile: CellMap>, +} + +#[derive(Debug, Clone)] +struct PlacedBuilding { + pos: DVec2, + base: EcefPos, + up: DVec3, + dir: DVec3, + height: f64, + snap: bool, + source_index: u32, + spec: BuildingSpec, +} + +impl Buildings { + pub fn from_line(line: &LineSource, net: &TrackNetwork, zone: u8) -> Self { + Self::from_parts(&line.buildings, net, zone) + } + + pub fn from_parts(sources: &[BuildingSource], net: &TrackNetwork, zone: u8) -> Self { + let placed = sources + .iter() + .enumerate() + .filter_map(|(source_index, source)| { + let edge = net.edges().get(source.edge as usize)?; + let pose = edge.eval(source.s.clamp(0.0, edge.length())); + let right = pose.tangent.cross(pose.up).normalize(); + let base = EcefPos(pose.pos.0 + right * source.lateral_offset); + let dir = + DQuat::from_axis_angle(pose.up, -source.yaw_deg.to_radians()) * pose.tangent; + let (lat, lon, _) = geo::from_ecef(base); + let (e, n) = geo::to_utm(lat, lon, zone); + Some(PlacedBuilding { + pos: DVec2::new(e, n), + base, + up: pose.up, + dir, + height: source.height, + snap: source.snap_to_terrain, + source_index: source_index as u32, + spec: source.spec.normalised(), + }) + }) + .collect(); + Self { + placed, + by_tile: CellMap::default(), + } + } + + fn bucket(&mut self, tile_size: f64) { + self.by_tile = bucket(self.placed.iter().map(|building| building.pos), tile_size); + } +} + /// One placement, resolved against the track: UTM position for the tile /// lookup, the rail-plane base and the two directions that orient the model. #[derive(Debug, Clone, Copy)] @@ -840,6 +903,7 @@ pub struct TerrainBuilder { options: TerrainOptions, vegetation: Vegetation, scenery: Scenery, + buildings: Buildings, crowd: Crowd, fields: crate::farmland::Fields, waters: crate::water::Waters, @@ -856,6 +920,7 @@ impl TerrainBuilder { options, vegetation: Vegetation::default(), scenery: Scenery::default(), + buildings: Buildings::default(), crowd: Crowd::default(), fields: crate::farmland::Fields::default(), waters: crate::water::Waters::default(), @@ -880,6 +945,13 @@ impl TerrainBuilder { self } + /// Parametric buildings of the line, baked into the tile they stand on. + pub fn with_buildings(mut self, mut buildings: Buildings) -> Self { + buildings.bucket(self.options.tile_size); + self.buildings = buildings; + self + } + /// The people on the line's platforms — tiles built afterwards carry /// them, on the platform's height or on their ground. pub fn with_crowd(mut self, mut crowd: Crowd) -> Self { @@ -952,6 +1024,7 @@ impl TerrainBuilder { net: &TrackNetwork, vegetation: Vegetation, scenery: Scenery, + buildings: Buildings, fields: crate::farmland::Fields, waters: crate::water::Waters, roads: crate::roads::Roads, @@ -964,6 +1037,7 @@ impl TerrainBuilder { options: self.options, vegetation: Vegetation::default(), scenery: Scenery::default(), + buildings: Buildings::default(), crowd: Crowd::default(), fields, waters: crate::water::Waters::default(), @@ -973,6 +1047,7 @@ impl TerrainBuilder { } .with_vegetation(vegetation) .with_scenery(scenery) + .with_buildings(buildings) .with_waters(waters) .with_roads(roads) .with_power_lines(power) @@ -1057,6 +1132,7 @@ impl TerrainBuilder { &self.options, &self.vegetation, &self.scenery, + &self.buildings, &self.crowd, &self.fields, &self.waters, @@ -1076,7 +1152,12 @@ impl TerrainBuilder { pub fn rescatter( &self, tile: &TerrainTile, - ) -> (Vec, Vec, Vec) { + ) -> ( + Vec, + Vec, + Vec, + Vec, + ) { let frame = EnuFrame::at(tile.anchor); let n = tile.n(self.options.tile_size); let k = key( @@ -1089,6 +1170,7 @@ impl TerrainBuilder { ( scatter_trees(k, &grid, &frame, &self.options, &self.vegetation), scatter_objects(k, &grid, &frame, &self.scenery), + scatter_buildings(k, &grid, &frame, &self.buildings), people, ) } @@ -1181,6 +1263,7 @@ fn build_key( options: &TerrainOptions, vegetation: &Vegetation, scenery: &Scenery, + buildings: &Buildings, crowd: &Crowd, farmland: &crate::farmland::Fields, waters: &crate::water::Waters, @@ -1198,8 +1281,8 @@ fn build_key( // Only the strokes that reach this tile — the rest never see a grid point. let edits = edits.in_rect(min, options.tile_size); let tile = build_tile( - k, step, lod, centerline, sampler, options, vegetation, scenery, crowd, farmland, waters, - roads, power, &edits, stats, + k, step, lod, centerline, sampler, options, vegetation, scenery, buildings, crowd, + farmland, waters, roads, power, &edits, stats, ); stats.tiles += 1; stats.vertices += tile.positions.len(); @@ -1236,6 +1319,7 @@ pub fn build( options, &Vegetation::default(), &Scenery::default(), + &Buildings::default(), &Crowd::default(), &crate::farmland::Fields::default(), &crate::water::Waters::default(), @@ -1385,6 +1469,7 @@ fn build_tile( options: &TerrainOptions, vegetation: &Vegetation, scenery: &Scenery, + buildings: &Buildings, crowd: &Crowd, farmland: &crate::farmland::Fields, waters: &crate::water::Waters, @@ -1442,6 +1527,7 @@ fn build_tile( let grid = HeightGrid::new(min, &heights, step, n); let trees = scatter_trees(k, &grid, &frame, options, vegetation); let objects = scatter_objects(k, &grid, &frame, scenery); + let buildings = scatter_buildings(k, &grid, &frame, buildings); let mut people = scatter_people(k, &grid, &frame, crowd); // The ways of the tile, and the people who stand about on its areas // rather than walk them — ordinary people of the tile from here on. @@ -1519,6 +1605,7 @@ fn build_tile( splat, trees, objects, + buildings, people, walkways, fields, @@ -1639,6 +1726,36 @@ fn scatter_objects( .collect() } +fn scatter_buildings( + k: TileKey, + grid: &HeightGrid, + frame: &EnuFrame, + buildings: &Buildings, +) -> Vec { + let Some(indices) = buildings.by_tile.get(&k) else { + return Vec::new(); + }; + indices + .iter() + .map(|&i| { + let building = &buildings.placed[i as usize]; + let anchor = if building.snap { + let ground = grid.at(building.pos); + let (lat, lon, _) = geo::from_ecef(building.base); + geo::to_ecef(lat, lon, ground + building.height) + } else { + EcefPos(building.base.0 + building.up * building.height) + }; + BakedBuilding { + pos: to_render(frame.to_local(anchor)), + rotation: model_rotation(frame, building.dir, building.up).to_array(), + source_index: building.source_index, + spec: building.spec.clone(), + } + }) + .collect() +} + /// A model's frame in the tile's render axes: its front (−Z, Bevy's /// convention) along `dir`, its up the local vertical `up`. Identity where the /// two do not span a frame. @@ -2000,6 +2117,7 @@ mod tests { &net, Vegetation::default(), Scenery::default(), + Buildings::default(), crate::farmland::Fields::default(), crate::water::Waters::default(), crate::roads::Roads::default(), @@ -2166,7 +2284,7 @@ mod tests { ); // Rescattering onto the built tile gives the same placement. - let (_, again, _) = builder.rescatter(tile); + let (_, again, _, _) = builder.rescatter(tile); assert_eq!(again, tile.objects); } @@ -2429,7 +2547,7 @@ mod tests { // Rescattering onto the built tiles gives the same trees — the // editor does that for a tile whose ground has not changed. for tile in &tiles { - let (again, _, _) = builder.rescatter(tile); + let (again, _, _, _) = builder.rescatter(tile); assert_eq!(again, tile.trees); } @@ -2545,4 +2663,38 @@ mod tests { .fold(f32::INFINITY, f32::min); assert!(min_y < grid_min - 1.0, "{min_y} vs {grid_min}"); } + + #[test] + fn parametric_buildings_are_baked_and_rescattered_stably() { + let net = test_net(); + let source = BuildingSource { + edge: 0, + s: 35.0, + lateral_offset: 18.0, + yaw_deg: 25.0, + height: 0.0, + snap_to_terrain: true, + spec: BuildingSpec { + width: 16.0, + floors: 4, + seed: 77, + ..Default::default() + }, + }; + let builder = TerrainBuilder::new(&net, vec![test_source()], options()) + .with_buildings(Buildings::from_parts(&[source], &net, options().zone)); + let tiles: Vec = builder + .corridor_keys() + .into_iter() + .filter_map(|key| builder.build_key(key, &mut TerrainStats::default())) + .collect(); + let tile = tiles + .iter() + .find(|tile| !tile.buildings.is_empty()) + .expect("the building's tile"); + assert_eq!(tile.buildings[0].spec.seed, 77); + assert_eq!(tile.buildings[0].spec.floors, 4); + let (_, _, again, _) = builder.rescatter(tile); + assert_eq!(again, tile.buildings); + } } diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index 359ac21..114fafb 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -30,6 +30,8 @@ action-quit = Beenden action-suggest = Vorschlag action-undo = Rückgängig action-redo = Wiederherstellen +action-copy = Kopieren +action-paste = Einfügen filter-vehicle-ron = Fahrzeug (RON) filter-line-ron = Modul (RON) @@ -936,6 +938,7 @@ check-yard-off-edge = Gleis { $yard } liegt außerhalb seines Gleises check-portal-inside = Portal { $yard } liegt nicht am Rand der Strecke — das Gleis dahinter muss auf einen Prellbock oder eine Modulgrenze auslaufen check-yard-name-twice = Gleis { $yard } trägt einen Namen, den ein anderes schon hat check-unknown-object = Objekt { $object }: nennt ein Objekt, das kein installierter Mod hat +check-building-off-edge = Gebäude { $building } liegt außerhalb seines Gleises check-flank-guard = Fahrstraße { $route }: Flankenschutz nennt einen Knoten, der keine Weiche ist, oder ein Signal, das es nicht mehr gibt obj-repeat = Wiederholung obj-repeat-interval = Abstand @@ -2777,3 +2780,64 @@ ai-open-dialog = Erkennen… ai-clear-area = Bereich löschen ai-blank = { $blank } Ausschnitte hatten kein Luftbild — offline, oder der Anbieter deckt diesen Boden nicht ab ai-no-roads = Im Modul liegen keine Straßen, also konnte nichts von einer Fahrbahn ferngehalten werden. Importiere sie und lauf noch einmal, damit die Fahrspuren für den Verkehr frei bleiben. + +## Dynamische Gebäude + +tool-building = Dynamisches Gebäude +sel-building-summary = Dynamisches Gebäude { $index } an Gleis { $edge } +building-generation = Generierung +building-preset = Vorlage +building-preset-custom = Benutzerdefiniert +building-preset-detached = Einfamilienhaus +building-preset-farmhouse = Bauernhaus +building-preset-townhouse = Stadthaus +building-preset-apartment = Mehrfamilienhaus +building-preset-office = Bürogebäude +building-preset-retail = Ladenzeile +building-preset-workshop = Werkstatt +building-preset-warehouse = Lagerhalle +building-preset-factory = Fabrikhalle +building-preset-logistics = Logistikhalle +building-use = Nutzung +building-use-residential = Wohngebäude +building-use-commercial = Gewerbegebäude +building-use-industrial = Industriegebäude +building-width = Breite +building-length = Länge +building-floors = Stockwerke +building-floor-height = Stockwerkhöhe +building-total-height = Gesamthöhe +building-roof-style = Dachform +building-roof-height = Dachhöhe +building-facade = Fassadenmaterial +building-color = Fassadenfarbe +building-roof-material = Dachmaterial +building-window-spacing = Fensterabstand +building-window-width = Fensterbreite +building-window-height = Fensterhöhe +building-lit-share = Beleuchtete Fenster nachts +building-balconies = Balkone +building-balcony-every = Balkon jedes Stockwerk +building-balcony-depth = Balkontiefe +building-chimneys = Schornsteine +building-roof-vents = Dachlüfter +building-skylights = Oberlichter +building-rain-gutters = Regenrinnen und Fallrohre +building-entrance-canopy = Eingangs-/Ladevordach +building-loading-doors = Verladetore +building-seed = Muster-Seed +roof-gable = Satteldach +roof-hip = Walmdach +roof-flat = Flachdach +roof-shed = Pultdach +roof-mansard = Mansarddach +roof-sawtooth = Sägezahndach +facade-plaster = Putz +facade-red-brick = Roter Klinker +facade-yellow-brick = Gelber Klinker +facade-concrete = Sichtbeton +facade-metal-panel = Metallpaneele +roof-material-clay = Tonziegel +roof-material-slate = Schiefer +roof-material-seam = Stehfalzblech +roof-material-bitumen = Bitumen diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index 6db48de..06b4f22 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -30,6 +30,8 @@ action-quit = Quit action-suggest = Suggest action-undo = Undo action-redo = Redo +action-copy = Copy +action-paste = Paste filter-vehicle-ron = Vehicle (RON) filter-line-ron = Module (RON) @@ -935,6 +937,7 @@ check-yard-off-edge = Road { $yard } sits outside its track check-portal-inside = Portal { $yard } is not at the edge of the line — the track behind it has to run out to a buffer stop or a module boundary check-yard-name-twice = Road { $yard } carries a name another one already has check-unknown-object = Object { $object }: names an object no installed mod has +check-building-off-edge = Building { $building } sits outside its track check-flank-guard = Route { $route }: flank protection names a node that is no turnout, or a signal that is gone obj-repeat = Repeat obj-repeat-interval = Spacing @@ -2774,3 +2777,64 @@ ai-open-dialog = Detect… ai-clear-area = Clear area ai-blank = { $blank } windows had no imagery — offline, or the provider does not cover this ground ai-no-roads = No roads in the module, so nothing could be kept off a carriageway. Import them and run again to leave the running lanes clear for the traffic. + +## Parametric buildings + +tool-building = Parametric building +sel-building-summary = Parametric building { $index } on track { $edge } +building-generation = Generation +building-preset = Preset +building-preset-custom = Custom +building-preset-detached = Detached house +building-preset-farmhouse = Farmhouse +building-preset-townhouse = Town house +building-preset-apartment = Apartment block +building-preset-office = Office block +building-preset-retail = Retail row +building-preset-workshop = Workshop +building-preset-warehouse = Warehouse +building-preset-factory = Factory hall +building-preset-logistics = Logistics hall +building-use = Use +building-use-residential = Residential +building-use-commercial = Commercial +building-use-industrial = Industrial +building-width = Width +building-length = Length +building-floors = Floors +building-floor-height = Floor height +building-total-height = Total height +building-roof-style = Roof shape +building-roof-height = Roof height +building-facade = Facade material +building-color = Facade colour +building-roof-material = Roof material +building-window-spacing = Window spacing +building-window-width = Window width +building-window-height = Window height +building-lit-share = Lit windows at night +building-balconies = Balconies +building-balcony-every = Balcony every floor +building-balcony-depth = Balcony depth +building-chimneys = Chimneys +building-roof-vents = Roof vents +building-skylights = Skylights +building-rain-gutters = Gutters and downpipes +building-entrance-canopy = Entrance/loading canopy +building-loading-doors = Loading doors +building-seed = Pattern seed +roof-gable = Gable roof +roof-hip = Hip roof +roof-flat = Flat roof +roof-shed = Shed roof +roof-mansard = Mansard roof +roof-sawtooth = Sawtooth roof +facade-plaster = Plaster +facade-red-brick = Red brick +facade-yellow-brick = Yellow brick +facade-concrete = Exposed concrete +facade-metal-panel = Metal panels +roof-material-clay = Clay tiles +roof-material-slate = Slate +roof-material-seam = Standing seam metal +roof-material-bitumen = Bitumen diff --git a/crates/route-editor/src/gizmo.rs b/crates/route-editor/src/gizmo.rs index 6b262ec..9f9b58a 100644 --- a/crates/route-editor/src/gizmo.rs +++ b/crates/route-editor/src/gizmo.rs @@ -116,6 +116,19 @@ fn handles( ], )) } + Selection::Building(i) => { + let building = line.source.buildings.get(i)?; + let (along, across, up) = on_track(building.edge, building.s)?; + Some(( + at, + vec![ + (Axis::Along, along), + (Axis::Across, across), + (Axis::Up, up), + (Axis::Yaw, up), + ], + )) + } // Free of the track — a tree stands where it stands. Selection::Tree(_) | Selection::Marker(_) | Selection::TerrainEdit(_) => { let frame = EnuFrame::at(at); @@ -321,6 +334,24 @@ fn apply(line: &mut Line, selection: Selection, axis: Axis, dir: DVec3, delta: f _ => {} } } + Selection::Building(i) => { + let Some(edge) = line.source.buildings.get(i).map(|building| building.edge) else { + return; + }; + let length = edge_length(line, edge); + let Some(building) = line.source.buildings.get_mut(i) else { + return; + }; + match axis { + Axis::Along => building.s = (building.s + delta).clamp(0.0, length), + Axis::Across => building.lateral_offset += delta, + Axis::Up => building.height += delta, + Axis::Yaw => { + building.yaw_deg = (building.yaw_deg - delta.to_degrees()).rem_euclid(360.0) + } + _ => {} + } + } Selection::Tree(i) => { if let Some(tree) = line.source.trees.get_mut(i) { move_geo(&mut tree.lat, &mut tree.lon, focus, dir, delta); diff --git a/crates/route-editor/src/main.rs b/crates/route-editor/src/main.rs index b031047..9e6a856 100644 --- a/crates/route-editor/src/main.rs +++ b/crates/route-editor/src/main.rs @@ -885,6 +885,15 @@ fn diff( .add_disc(utm(lat.to_degrees(), lon.to_degrees()), 1.0); } } + for building in changed(&last.buildings, &now.buildings) { + if let Some(pos) = tools::building_pos(net, building) { + let (lat, lon, _) = geo::from_ecef(pos); + change.scatter.add_disc( + utm(lat.to_degrees(), lon.to_degrees()), + building.spec.width.max(building.spec.length) as f64, + ); + } + } // A field is not something standing on the ground — it *is* the ground, // part of the tile's own mesh, so a changed one rebuilds the tiles // under it. An import changes hundreds at once, and marking each of @@ -1405,7 +1414,11 @@ fn overlay_control( changed = true; } - if keys.just_pressed(KeyCode::KeyC) { + let command = keys.pressed(KeyCode::ControlLeft) + || keys.pressed(KeyCode::ControlRight) + || keys.pressed(KeyCode::SuperLeft) + || keys.pressed(KeyCode::SuperRight); + if keys.just_pressed(KeyCode::KeyC) && !command { overlay.source.clear_cache(); overlay.clear(&mut commands); overlay.status = t!("status-cache-cleared"); diff --git a/crates/route-editor/src/terrain.rs b/crates/route-editor/src/terrain.rs index 4146820..7f6a7c5 100644 --- a/crates/route-editor/src/terrain.rs +++ b/crates/route-editor/src/terrain.rs @@ -28,7 +28,7 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, block_on, poll_once}; use content::import::dgm::TerrainSource; use content::route::{MarkerSource, TerrainEditSource, TreeSource}; use content::terrain::{ - self, Scenery, TerrainBuilder, TerrainEdits, TerrainStats, TerrainTile, Vegetation, + self, Buildings, Scenery, TerrainBuilder, TerrainEdits, TerrainStats, TerrainTile, Vegetation, }; use content::{TerrainOptions, TileKey}; use glam::DVec2; @@ -336,6 +336,7 @@ impl TerrainView { /// a tuple is a type nobody wants to read. #[derive(bevy::ecs::system::SystemParam)] pub struct SurfaceMaterials<'w> { + buildings: ResMut<'w, world_render::BuildingAssets>, road: ResMut<'w, world_render::RoadMaterials>, road_assets: ResMut<'w, Assets>, conductor: ResMut<'w, world_render::ConductorMaterials>, @@ -452,17 +453,20 @@ pub fn update( let Some(loaded) = view.loaded.get_mut(&k) else { continue; }; - let (trees, objects, people) = builder.rescatter(&loaded.tile); + let (trees, objects, buildings, people) = builder.rescatter(&loaded.tile); let old: Vec = children .get(loaded.entity) .map(|c| c.iter().filter(|e| scattered.contains(*e)).collect()) .unwrap_or_default(); world_render::respawn_scatter( &mut commands, + &mut meshes, + &mut surfaces.buildings, loaded.entity, old, trees, &objects, + &buildings, &people, &view.catalog, ); @@ -523,6 +527,7 @@ pub fn update( let entity = world_render::spawn_terrain_tile( &mut commands, &mut meshes, + &mut surfaces.buildings, &view.material, &view.catalog, &tile, @@ -578,6 +583,7 @@ pub fn update( tile.splat = Vec::new(); tile.trees = Vec::new(); tile.objects = Vec::new(); + tile.buildings = Vec::new(); tile.fields = Vec::new(); tile.waters = Vec::new(); tile.roads = Vec::new(); @@ -803,6 +809,7 @@ fn refresh_builder(view: &mut TerrainView, line: &Line, options: TerrainOptions) .collect(); let vegetation = Vegetation::from_line(&line.source, options.zone); let scenery = Scenery::from_line(&line.source, &line.net, options.zone); + let buildings = Buildings::from_line(&line.source, &line.net, options.zone); let edits = TerrainEdits::from_line(&line.source, options.zone); let farmland = content::farmland::Fields::from_line(&line.source, options.zone, options.tile_size); @@ -831,7 +838,7 @@ fn refresh_builder(view: &mut TerrainView, line: &Line, options: TerrainOptions) }; view.waters_fingerprint = Some(waters_fingerprint); view.builder = Some(Arc::new(builder.with_line( - &line.net, vegetation, scenery, farmland, waters, roads, power, edits, + &line.net, vegetation, scenery, buildings, farmland, waters, roads, power, edits, ))); return; } @@ -862,6 +869,7 @@ fn refresh_builder(view: &mut TerrainView, line: &Line, options: TerrainOptions) TerrainBuilder::new(&line.net, sources, options) .with_vegetation(vegetation) .with_scenery(scenery) + .with_buildings(buildings) .with_fields(farmland) .with_waters(waters) .with_roads(roads) diff --git a/crates/route-editor/src/tools.rs b/crates/route-editor/src/tools.rs index 0b0031a..81a50f5 100644 --- a/crates/route-editor/src/tools.rs +++ b/crates/route-editor/src/tools.rs @@ -23,10 +23,13 @@ use bevy::world_serialization::WorldAsset; use content::LineSource; use content::TerrainOptions; use content::import::alignment::{CantRules, ramp_cant}; +#[cfg(test)] +use content::route::STARTER_TRACK_TYPE; use content::route::{ DeviceSource, EdgeSource, EdgeStart, FlankSource, GeoPoint, MarkerSource, NodeSource, - ObjectSource, STARTER_TRACK_TYPE, SignalSource, TerrainEdit, TerrainEditSource, TreeSource, + ObjectSource, SignalSource, TerrainEdit, TerrainEditSource, TreeSource, }; +use content::{BuildingSource, BuildingSpec}; use glam::{DQuat, DVec2, DVec3}; use i18n::t; use sim_core::interlock::{SignalKind, SignalSystem}; @@ -36,6 +39,9 @@ use world_coords::{EcefPos, EnuFrame, RenderOrigin, geo}; /// Throw time a freshly placed turnout gets [s] — the file format's own /// default; the selection panel edits it per switch afterwards. const DEFAULT_THROW_TIME: f64 = 6.0; +/// Track-relative buildings may be placed into the first rows of development +/// beside the railway while still retaining an unambiguous carrier edge. +const BUILDING_TRACK_REACH: f64 = 250.0; /// Active tool of the viewport. #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] @@ -61,6 +67,8 @@ pub enum Tool { Gradient, PlaceDevice, PlaceObject, + /// Places an editable parametric building, baked only for rendering. + PlaceBuilding, /// One tree per click, free of the track. PlaceTree, /// Forest brush: clicks collect a polygon; Enter/right-click bakes it into @@ -118,6 +126,7 @@ pub enum Selection { Edge(usize), Device(usize), Object(usize), + Building(usize), Tree(usize), Marker(usize), TerrainEdit(usize), @@ -166,6 +175,7 @@ impl AreaStroke { pub enum Mark { Tree(usize), Object(usize), + Building(usize), Device(usize), Marker(usize), } @@ -176,6 +186,7 @@ fn as_mark(selection: Selection) -> Option { match selection { Selection::Tree(i) => Some(Mark::Tree(i)), Selection::Object(i) => Some(Mark::Object(i)), + Selection::Building(i) => Some(Mark::Building(i)), Selection::Device(i) => Some(Mark::Device(i)), Selection::Marker(i) => Some(Mark::Marker(i)), _ => None, @@ -291,6 +302,11 @@ pub const TOOL_GROUPS: [(&str, editor_ui::Icon, &[ToolEntry]); 7] = [ &[ (Tool::PlaceDevice, "tool-device", editor_ui::Icon::Device), (Tool::PlaceObject, "tool-object", editor_ui::Icon::Object), + ( + Tool::PlaceBuilding, + "tool-building", + editor_ui::Icon::Object, + ), (Tool::PlaceMarker, "tool-marker", editor_ui::Icon::Marker), ], ), @@ -592,6 +608,10 @@ pub struct EditorState { pub jump_to: Option<&'static str>, /// Object (`":"`) the Place-object tool stamps. pub object: Option, + /// Recipe stamped by the parametric-building tool. + pub building_spec: BuildingSpec, + /// Full source clipboard; copying deliberately preserves every generation setting. + pub building_clipboard: Option, /// The next placed object stands on the terrain instead of at the track's /// height — the toolbox toggle, stamped into the object at placement. pub place_snap_to_terrain: bool, @@ -782,6 +802,7 @@ fn envelope_margin(tool: Tool) -> Option { Tool::PlaceTree | Tool::PlaceForest | Tool::PlaceObject + | Tool::PlaceBuilding | Tool::PlaceMarker | Tool::TerrainRaise | Tool::TerrainLower @@ -1559,6 +1580,16 @@ pub fn object_pos(net: &TrackNetwork, object: &ObjectSource) -> Option )) } +/// World position of a parametric building, in the scenery placement frame. +pub fn building_pos(net: &TrackNetwork, building: &BuildingSource) -> Option { + let edge = net.edges().get(building.edge as usize)?; + let pose = edge.eval(building.s.clamp(0.0, edge.length())); + let right = pose.tangent.cross(pose.up).normalize_or_zero(); + Some(EcefPos( + pose.pos.0 + right * building.lateral_offset + pose.up * building.height, + )) +} + /// How close a click has to come, scaled with the view height. Still the /// measure for what a click *places* (ghost snapping, tile picking) — what a /// click *selects* is measured on screen, see [`ScreenPick`]. @@ -1655,6 +1686,7 @@ pub fn selection_pos( } Selection::Device(i) => device_pos(&line.net, line.source.devices.get(i)?), Selection::Object(i) => object_pos(&line.net, line.source.objects.get(i)?), + Selection::Building(i) => building_pos(&line.net, line.source.buildings.get(i)?), Selection::Tree(i) => Some(marks.tree(i, line.source.trees.get(i)?)), Selection::Marker(i) => Some(marks.marker(i, line.source.markers.get(i)?)), Selection::TerrainEdit(i) => Some(marks.stroke(i, line.source.terrain.get(i)?)), @@ -1954,6 +1986,11 @@ pub fn delete_selection(line: &mut Line, state: &mut EditorState) { line.source.objects.remove(i); } } + Selection::Building(i) => { + if i < line.source.buildings.len() { + line.source.buildings.remove(i); + } + } Selection::Tree(i) => { if i < line.source.trees.len() { line.source.trees.remove(i); @@ -2020,6 +2057,29 @@ pub fn delete_selection(line: &mut Line, state: &mut EditorState) { } } +/// Copies the complete dynamic-building source, including its stable night seed. +pub fn copy_building(line: &Line, state: &mut EditorState) -> bool { + let Selection::Building(index) = state.selection else { + return false; + }; + let Some(building) = line.source.buildings.get(index) else { + return false; + }; + state.building_clipboard = Some(building.clone()); + true +} + +/// Pastes a building beside the original. All generation settings stay identical. +pub fn paste_building(line: &mut Line, state: &mut EditorState) -> bool { + let Some(mut building) = state.building_clipboard.clone() else { + return false; + }; + building.lateral_offset += building.spec.width as f64 + 2.0; + line.source.buildings.push(building); + state.selection = Selection::Building(line.source.buildings.len() - 1); + true +} + /// Deletes a whole marker layer — one undo step, like the marking brush. pub fn delete_layer(line: &mut Line, state: &mut EditorState, layer: &str) { line.source.markers.retain(|m| m.layer != layer); @@ -2034,18 +2094,26 @@ pub fn delete_layer(line: &mut Line, state: &mut EditorState, layer: &str) { pub fn delete_marked(line: &mut Line, state: &mut EditorState) { let mut trees: Vec = Vec::new(); let mut objects: Vec = Vec::new(); + let mut buildings: Vec = Vec::new(); let mut devices: Vec = Vec::new(); let mut markers: Vec = Vec::new(); for mark in state.marked.drain(..) { match mark { Mark::Tree(i) => trees.push(i), Mark::Object(i) => objects.push(i), + Mark::Building(i) => buildings.push(i), Mark::Device(i) => devices.push(i), Mark::Marker(i) => markers.push(i), } } // Descending order, so earlier removals do not shift later indices. - for list in [&mut trees, &mut objects, &mut devices, &mut markers] { + for list in [ + &mut trees, + &mut objects, + &mut buildings, + &mut devices, + &mut markers, + ] { list.sort_unstable(); list.dedup(); } @@ -2059,6 +2127,11 @@ pub fn delete_marked(line: &mut Line, state: &mut EditorState) { line.source.objects.remove(i); } } + for i in buildings.into_iter().rev() { + if i < line.source.buildings.len() { + line.source.buildings.remove(i); + } + } // A device takes its signal and the signal's route references along. for i in devices.into_iter().rev() { line.source.remove_device(i); @@ -2085,6 +2158,12 @@ fn mark_within(state: &mut EditorState, line: &Line, marks: &Marks, p: EcefPos, state.marked.push(Mark::Object(i)); } } + for (i, building) in line.source.buildings.iter().enumerate() { + let near = building_pos(&line.net, building).is_some_and(|q| q.distance(p) <= radius); + if near && !state.marked.contains(&Mark::Building(i)) { + state.marked.push(Mark::Building(i)); + } + } } /// Marks everything within `radius` of `p` — the select tool's circle: @@ -2783,6 +2862,9 @@ pub fn tool_input( Selection::Object(i) if i >= line.source.objects.len() => { state.selection = Selection::None; } + Selection::Building(i) if i >= line.source.buildings.len() => { + state.selection = Selection::None; + } Selection::Tree(i) if i >= line.source.trees.len() => { state.selection = Selection::None; } @@ -2809,6 +2891,7 @@ pub fn tool_input( let stale = state.marked.iter().any(|m| match m { Mark::Tree(i) => *i >= line.source.trees.len(), Mark::Object(i) => *i >= line.source.objects.len(), + Mark::Building(i) => *i >= line.source.buildings.len(), Mark::Device(i) => *i >= line.source.devices.len(), Mark::Marker(i) => *i >= line.source.markers.len(), }); @@ -3312,6 +3395,28 @@ pub fn tool_input( _ => overlay.status = t!("status-no-track-hit"), } } + Tool::PlaceBuilding => match nearest_on_network(&line.net, p) { + Some((edge, s, distance)) if distance <= BUILDING_TRACK_REACH => { + let pose = line.net.edges()[edge].eval(s); + let right = pose.tangent.cross(pose.up).normalize_or_zero(); + let mut spec = state.building_spec.normalised(); + spec.seed = + (line.source.buildings.len() as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15); + line.source.buildings.push(BuildingSource { + edge: edge as u32, + s, + lateral_offset: (p.0 - pose.pos.0) + .dot(right) + .clamp(-BUILDING_TRACK_REACH, BUILDING_TRACK_REACH), + yaw_deg: 0.0, + height: 0.0, + snap_to_terrain: true, + spec, + }); + state.selection = Selection::Building(line.source.buildings.len() - 1); + } + _ => overlay.status = t!("status-no-track-hit"), + }, Tool::PlaceTree => { let (lat, lon, _) = geo::from_ecef(p); // A stand plants one of its species, taken in turn — painting @@ -3561,6 +3666,15 @@ pub fn tool_input( .enumerate() .filter_map(|(i, o)| Some((Selection::Object(i), object_pos(&line.net, o)?))) .collect::>(); + let buildings = line + .source + .buildings + .iter() + .enumerate() + .filter_map(|(i, building)| { + Some((Selection::Building(i), building_pos(&line.net, building)?)) + }) + .collect::>(); let trees = line .source .trees @@ -3597,6 +3711,7 @@ pub fn tool_input( let nearest = device .into_iter() .chain(objects_) + .chain(buildings) .chain(trees) .chain(markers) .chain(terrain) @@ -4086,6 +4201,15 @@ pub fn draw_gizmos( ground_circle(&mut gizmos, &origin.0, p, radius, accent); } } + Selection::Building(i) => { + if let Some(building) = line.source.buildings.get(i) + && let Some(p) = building_pos(&line.net, building) + { + let radius = (building.spec.width.max(building.spec.length) * 0.55) + .max((focus.height * 0.012) as f32); + ground_circle(&mut gizmos, &origin.0, p, radius, accent); + } + } Selection::Object(i) => { if let Some(object) = line.source.objects.get(i) && let Some(p) = object_pos(&line.net, object) @@ -4226,6 +4350,11 @@ pub fn draw_gizmos( .objects .get(i) .and_then(|o| object_pos(&line.net, o)), + Mark::Building(i) => line + .source + .buildings + .get(i) + .and_then(|building| building_pos(&line.net, building)), Mark::Device(i) => line .source .devices @@ -4362,6 +4491,38 @@ pub fn draw_gizmos( let dir = origin.0.dir_to_render(pose.tangent) * (radius * 2.0); gizmos.line(base - dir, base + dir, accent); } + // A building is placed where the cursor stands beside its nearest track. + // Show its full footprint, because unlike a mast its size materially + // affects whether the chosen spot fits. + if state.tool == Tool::PlaceBuilding + && let Some(p) = cursor + && let Some((edge, s, distance)) = nearest_on_network(&line.net, p) + && distance <= BUILDING_TRACK_REACH + && let Some(edge) = line.net.edges().get(edge) + { + let pose = edge.eval(s); + let across = pose.tangent.cross(pose.up).normalize_or_zero(); + let lateral = (p.0 - pose.pos.0) + .dot(across) + .clamp(-BUILDING_TRACK_REACH, BUILDING_TRACK_REACH); + let center = EcefPos(pose.pos.0 + across * lateral); + let half_w = state.building_spec.width.clamp(3.0, 150.0) as f64 / 2.0; + let half_l = state.building_spec.length.clamp(3.0, 250.0) as f64 / 2.0; + let corners = [ + EcefPos(center.0 - across * half_w - pose.tangent * half_l), + EcefPos(center.0 + across * half_w - pose.tangent * half_l), + EcefPos(center.0 + across * half_w + pose.tangent * half_l), + EcefPos(center.0 - across * half_w + pose.tangent * half_l), + EcefPos(center.0 - across * half_w - pose.tangent * half_l), + ]; + let lift = origin.0.dir_to_render(pose.up) * MARK_LIFT; + gizmos.linestrip( + corners + .into_iter() + .map(|corner| origin.0.to_render(corner) + lift), + accent, + ); + } if let Some(drawing) = &state.drawing { // While Ctrl holds the piece straight the preview turns yellow, as the // World Editor's frame does; while the standing end is aimed, the @@ -5853,4 +6014,44 @@ mod palette_tests { select_tool(&mut state, Tool::Select); assert!(state.walk_points.is_empty()); } + + #[test] + fn building_copy_paste_preserves_the_generation_recipe() { + let original = BuildingSource { + edge: 2, + s: 40.0, + lateral_offset: 8.0, + yaw_deg: 15.0, + height: 0.5, + snap_to_terrain: true, + spec: BuildingSpec { + width: 18.0, + floors: 5, + seed: 123_456, + ..Default::default() + }, + }; + let mut line = Line { + source: LineSource { + buildings: vec![original.clone()], + ..Default::default() + }, + net: TrackNetwork::new(), + path: None, + dirty: false, + needs_rebuild: false, + terrain_change: Default::default(), + recenter: false, + issues: Vec::new(), + }; + let mut state = EditorState { + selection: Selection::Building(0), + ..Default::default() + }; + assert!(copy_building(&line, &mut state)); + assert!(paste_building(&mut line, &mut state)); + assert_eq!(line.source.buildings[1].spec, original.spec); + assert_eq!(line.source.buildings[1].yaw_deg, original.yaw_deg); + assert_eq!(state.selection, Selection::Building(1)); + } } diff --git a/crates/route-editor/src/ui.rs b/crates/route-editor/src/ui.rs index c5108ff..aaaef7c 100644 --- a/crates/route-editor/src/ui.rs +++ b/crates/route-editor/src/ui.rs @@ -12,6 +12,7 @@ use content::LineSource; use content::route::{ BoundarySource, FlankSource, NodeSource, RouteSource, RuleIssue, SectionSource, SignalSource, }; +use content::{BuildingPreset, BuildingSpec, BuildingUse, FacadeMaterial, RoofMaterial, RoofStyle}; use editor_ui::{colors, space}; use i18n::t; use imagery::ZoomMode; @@ -153,6 +154,10 @@ const SHORTCUT_DRAWER: egui::KeyboardShortcut = /// names the key beside the menu entry. const SHORTCUT_DELETE: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::NONE, egui::Key::Delete); +const SHORTCUT_COPY: egui::KeyboardShortcut = + egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::C); +const SHORTCUT_PASTE: egui::KeyboardShortcut = + egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::V); /// One frame of UI. Panels live inside a background `Ui` (egui 0.35). #[allow(clippy::too_many_arguments)] @@ -477,6 +482,12 @@ fn handle_shortcuts( if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT_UNDO)) { undo(line, history, state); } + if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT_COPY)) { + tools::copy_building(line, state); + } + if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT_PASTE)) { + tools::paste_building(line, state); + } if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT_SAVE)) && needs_saving(line) { save(line, state, overlay); } @@ -1094,6 +1105,7 @@ pub(crate) fn new_line( edges: vec![], devices: vec![], objects: vec![], + buildings: vec![], yards: vec![], trees: vec![], walk_paths: vec![], @@ -1238,6 +1250,23 @@ fn menu_bar( ui.close(); } ui.separator(); + let can_copy = matches!(state.selection, Selection::Building(_)); + let copy_button = egui::Button::new(t!("action-copy")) + .shortcut_text(keys(ui, &SHORTCUT_COPY)); + if ui.add_enabled(can_copy, copy_button).clicked() { + tools::copy_building(line, state); + ui.close(); + } + let paste_button = egui::Button::new(t!("action-paste")) + .shortcut_text(keys(ui, &SHORTCUT_PASTE)); + if ui + .add_enabled(state.building_clipboard.is_some(), paste_button) + .clicked() + { + tools::paste_building(line, state); + ui.close(); + } + ui.separator(); let has_target = state.selection != Selection::None || !state.marked.is_empty(); let delete_button = egui::Button::new(t!("action-delete")) .shortcut_text(keys(ui, &SHORTCUT_DELETE)); @@ -1767,6 +1796,55 @@ fn detail_panel( } } } + if state.tool == Tool::PlaceBuilding { + ui.add_space(space::XS); + editor_ui::form_grid("place-building").show(ui, |ui| { + row(ui, "building-preset", |ui| { + building_preset_combo(ui, usize::MAX, &mut state.building_spec); + }); + row(ui, "building-use", |ui| { + building_use_combo( + ui, + usize::MAX, + &mut state.building_spec.use_kind, + ); + }); + row(ui, "building-width", |ui| { + editor_ui::field( + ui, + &mut state.building_spec.width, + 0.25, + 3.0..=150.0, + "m", + ); + }); + row(ui, "building-length", |ui| { + editor_ui::field( + ui, + &mut state.building_spec.length, + 0.25, + 3.0..=250.0, + "m", + ); + }); + row(ui, "building-floors", |ui| { + editor_ui::field( + ui, + &mut state.building_spec.floors, + 0.1, + 1.0..=40.0, + "", + ); + }); + row(ui, "building-roof-style", |ui| { + roof_style_combo( + ui, + usize::MAX, + &mut state.building_spec.roof_style, + ); + }); + }); + } if matches!(state.tool, Tool::PlaceTree | Tool::PlaceForest) { ui.add_space(space::XS); editor_ui::form_grid("place-tree").show(ui, |ui| { @@ -2054,6 +2132,125 @@ fn object_combo(ui: &mut egui::Ui, id: &str, objects: &TrackObjects, state: &mut }); } +fn building_use_combo(ui: &mut egui::Ui, id: usize, value: &mut BuildingUse) { + enum_combo( + ui, + ("building-use", id), + value, + &[ + (BuildingUse::Residential, "building-use-residential"), + (BuildingUse::Commercial, "building-use-commercial"), + (BuildingUse::Industrial, "building-use-industrial"), + ], + ); +} + +fn building_preset_combo(ui: &mut egui::Ui, id: usize, spec: &mut BuildingSpec) { + let choices = [ + (BuildingPreset::DetachedHouse, "building-preset-detached"), + (BuildingPreset::Farmhouse, "building-preset-farmhouse"), + (BuildingPreset::TownHouse, "building-preset-townhouse"), + (BuildingPreset::ApartmentBlock, "building-preset-apartment"), + (BuildingPreset::OfficeBlock, "building-preset-office"), + (BuildingPreset::RetailRow, "building-preset-retail"), + (BuildingPreset::Workshop, "building-preset-workshop"), + (BuildingPreset::Warehouse, "building-preset-warehouse"), + (BuildingPreset::FactoryHall, "building-preset-factory"), + (BuildingPreset::LogisticsHall, "building-preset-logistics"), + ]; + debug_assert_eq!(choices.len(), BuildingPreset::ALL.len()); + let selected = choices + .iter() + .find(|(preset, _)| { + let mut candidate = preset.spec(); + candidate.seed = spec.seed; + candidate.normalised() == spec.normalised() + }) + .map_or_else(|| t!("building-preset-custom"), |(_, key)| t!(*key)); + egui::ComboBox::from_id_salt(("building-preset", id)) + .width(space::FIELD) + .selected_text(selected) + .show_ui(ui, |ui| { + for (preset, key) in choices { + if ui.selectable_label(false, t!(key)).clicked() { + let seed = spec.seed; + *spec = preset.spec(); + spec.seed = seed; + } + } + }); +} + +fn roof_style_combo(ui: &mut egui::Ui, id: usize, value: &mut RoofStyle) { + enum_combo( + ui, + ("roof-style", id), + value, + &[ + (RoofStyle::Gable, "roof-gable"), + (RoofStyle::Hip, "roof-hip"), + (RoofStyle::Flat, "roof-flat"), + (RoofStyle::Shed, "roof-shed"), + (RoofStyle::Mansard, "roof-mansard"), + (RoofStyle::Sawtooth, "roof-sawtooth"), + ], + ); +} + +fn facade_combo(ui: &mut egui::Ui, id: usize, value: &mut FacadeMaterial) { + enum_combo( + ui, + ("facade", id), + value, + &[ + (FacadeMaterial::Plaster, "facade-plaster"), + (FacadeMaterial::RedBrick, "facade-red-brick"), + (FacadeMaterial::YellowBrick, "facade-yellow-brick"), + (FacadeMaterial::Concrete, "facade-concrete"), + (FacadeMaterial::MetalPanel, "facade-metal-panel"), + ], + ); +} + +fn roof_material_combo(ui: &mut egui::Ui, id: usize, value: &mut RoofMaterial) { + enum_combo( + ui, + ("roof-material", id), + value, + &[ + (RoofMaterial::ClayTile, "roof-material-clay"), + (RoofMaterial::Slate, "roof-material-slate"), + (RoofMaterial::StandingSeam, "roof-material-seam"), + (RoofMaterial::Bitumen, "roof-material-bitumen"), + ], + ); +} + +fn enum_combo( + ui: &mut egui::Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: &mut T, + choices: &[(T, &'static str)], +) { + let selected = choices + .iter() + .find(|(candidate, _)| candidate == value) + .map_or_else(|| t!(choices[0].1), |(_, key)| t!(*key)); + egui::ComboBox::from_id_salt(id) + .width(space::FIELD) + .selected_text(selected) + .show_ui(ui, |ui| { + for (candidate, key) in choices { + if ui + .selectable_label(*value == *candidate, t!(*key)) + .clicked() + { + *value = *candidate; + } + } + }); +} + /// The toolbox, after Train Simulator Classic's World Editor: boxed icon /// groups on the left edge — the categories in the top box, the tools of the /// active one in the middle, and the active tool's own switches (radius snap, @@ -2607,6 +2804,155 @@ fn selection_panel( } }); } + Selection::Building(i) => { + let Some(building) = line.source.buildings.get(i) else { + return; + }; + let edge_length = line + .net + .edges() + .get(building.edge as usize) + .map(|edge| edge.length()) + .unwrap_or(f64::MAX); + let position = tools::building_pos(&line.net, building); + ui.label(t!("sel-building-summary", index = i, edge = building.edge)); + let building = &mut line.source.buildings[i]; + editor_ui::form_grid("sel-building-placement").show(ui, |ui| { + row(ui, "obj-s", |ui| { + editor_ui::field(ui, &mut building.s, 1.0, 0.0..=edge_length, "m"); + }); + row(ui, "obj-lateral", |ui| { + editor_ui::field(ui, &mut building.lateral_offset, 0.1, -250.0..=250.0, "m"); + }); + row(ui, "obj-yaw", |ui| { + editor_ui::field(ui, &mut building.yaw_deg, 1.0, -360.0..=360.0, "°"); + }); + row(ui, "obj-height", |ui| { + editor_ui::field(ui, &mut building.height, 0.1, -20.0..=100.0, "m"); + }); + row(ui, "obj-snap", |ui| { + ui.checkbox(&mut building.snap_to_terrain, ""); + }); + }); + editor_ui::subheading(ui, t!("building-generation")); + editor_ui::form_grid("sel-building-generation").show(ui, |ui| { + row(ui, "building-preset", |ui| { + building_preset_combo(ui, i, &mut building.spec) + }); + row(ui, "building-use", |ui| { + building_use_combo(ui, i, &mut building.spec.use_kind) + }); + row(ui, "building-width", |ui| { + editor_ui::field(ui, &mut building.spec.width, 0.25, 3.0..=150.0, "m"); + }); + row(ui, "building-length", |ui| { + editor_ui::field(ui, &mut building.spec.length, 0.25, 3.0..=250.0, "m"); + }); + row(ui, "building-floors", |ui| { + editor_ui::field(ui, &mut building.spec.floors, 0.1, 1.0..=40.0, ""); + }); + row(ui, "building-floor-height", |ui| { + editor_ui::field(ui, &mut building.spec.floor_height, 0.05, 2.2..=8.0, "m"); + }); + row(ui, "building-total-height", |ui| { + let mut total = building.spec.total_height(); + if editor_ui::field(ui, &mut total, 0.1, 2.2..=340.0, "m").changed() { + let roof = if building.spec.roof_style == RoofStyle::Flat { + 0.35 + } else { + building.spec.roof_height + }; + building.spec.floor_height = + ((total - roof) / building.spec.floors.max(1) as f32).clamp(2.2, 8.0); + } + }); + row(ui, "building-roof-style", |ui| { + roof_style_combo(ui, i, &mut building.spec.roof_style) + }); + row(ui, "building-roof-height", |ui| { + editor_ui::field(ui, &mut building.spec.roof_height, 0.1, 0.0..=20.0, "m"); + }); + row(ui, "building-facade", |ui| { + facade_combo(ui, i, &mut building.spec.facade) + }); + row(ui, "building-color", |ui| { + ui.color_edit_button_rgb(&mut building.spec.facade_color); + }); + row(ui, "building-roof-material", |ui| { + roof_material_combo(ui, i, &mut building.spec.roof) + }); + row(ui, "building-window-spacing", |ui| { + editor_ui::field(ui, &mut building.spec.window_spacing, 0.1, 1.2..=12.0, "m"); + }); + row(ui, "building-window-width", |ui| { + editor_ui::field(ui, &mut building.spec.window_width, 0.05, 0.5..=8.0, "m"); + }); + row(ui, "building-window-height", |ui| { + editor_ui::field(ui, &mut building.spec.window_height, 0.05, 0.5..=6.0, "m"); + }); + row(ui, "building-lit-share", |ui| { + editor_ui::field(ui, &mut building.spec.lit_window_share, 0.02, 0.0..=1.0, ""); + }); + row(ui, "building-balconies", |ui| { + ui.checkbox(&mut building.spec.balconies, ""); + }); + if building.spec.balconies { + row(ui, "building-balcony-every", |ui| { + editor_ui::field(ui, &mut building.spec.balcony_every, 0.1, 1.0..=20.0, ""); + }); + row(ui, "building-balcony-depth", |ui| { + editor_ui::field( + ui, + &mut building.spec.balcony_depth, + 0.05, + 0.6..=4.0, + "m", + ); + }); + } + row(ui, "building-chimneys", |ui| { + editor_ui::field(ui, &mut building.spec.chimneys, 0.1, 0.0..=8.0, ""); + }); + row(ui, "building-roof-vents", |ui| { + editor_ui::field(ui, &mut building.spec.roof_vents, 0.1, 0.0..=24.0, ""); + }); + row(ui, "building-skylights", |ui| { + editor_ui::field(ui, &mut building.spec.skylights, 0.1, 0.0..=24.0, ""); + }); + row(ui, "building-rain-gutters", |ui| { + ui.checkbox(&mut building.spec.rain_gutters, ""); + }); + row(ui, "building-entrance-canopy", |ui| { + ui.checkbox(&mut building.spec.entrance_canopy, ""); + }); + if building.spec.use_kind == BuildingUse::Industrial { + row(ui, "building-loading-doors", |ui| { + editor_ui::field(ui, &mut building.spec.loading_doors, 0.1, 1.0..=10.0, ""); + }); + } + row(ui, "building-seed", |ui| { + editor_ui::field(ui, &mut building.spec.seed, 1.0, 0.0..=u32::MAX as f64, ""); + }); + }); + ui.add_space(space::XS); + let mut delete_building = false; + ui.horizontal(|ui| { + if ui.button(t!("action-center")).clicked() + && let Some(position) = position + { + focus.position = position; + } + if ui.button(t!("action-copy")).clicked() { + state.building_clipboard = Some(building.clone()); + } + if ui.button(t!("action-delete")).clicked() { + delete_building = true; + } + }); + if delete_building { + tools::delete_selection(line, state); + } + } Selection::Tree(i) => { let Some(tree) = line.source.trees.get(i) else { return; @@ -4182,6 +4528,13 @@ fn issue_target( .and_then(|o| tools::object_pos(&line.net, o)), Selection::Object(*object as usize), ), + RuleIssue::BuildingOffEdge { building } => ( + line.source + .buildings + .get(*building as usize) + .and_then(|building| tools::building_pos(&line.net, building)), + Selection::Building(*building as usize), + ), // A stabling road or portal is a mark on the track; the edge it names is where // looking at it starts. It has no selection of its own yet — the roads are // written by hand, like the interlocking tables were. @@ -4273,6 +4626,9 @@ fn issue_text(issue: &RuleIssue) -> String { RuleIssue::LzbTypeWithoutConductor { edge } => t!("check-lzb-no-conductor", edge = edge), RuleIssue::ObjectOffEdge { object } => t!("check-object-off-edge", object = object), RuleIssue::UnknownObject { object } => t!("check-unknown-object", object = object), + RuleIssue::BuildingOffEdge { building } => { + t!("check-building-off-edge", building = building) + } RuleIssue::FlankGuardInvalid { route } => t!("check-flank-guard", route = route), RuleIssue::YardOffEdge { yard } => t!("check-yard-off-edge", yard = yard), RuleIssue::PortalNotAtTheEdge { yard } => t!("check-portal-inside", yard = yard), diff --git a/crates/world-render/src/buildings.rs b/crates/world-render/src/buildings.rs new file mode 100644 index 0000000..cec14cc --- /dev/null +++ b/crates/world-render/src/buildings.rs @@ -0,0 +1,2620 @@ +//! Baked parametric building meshes and the shared German-building PBR library. +//! +//! Every distinct recipe is meshed once and cached. Placements only add transforms; +//! all facade, roof, glass, trim and balcony materials are global handles, so Bevy can +//! batch equal parts across buildings and streamed terrain tiles. + +use std::collections::HashMap; + +use bevy::asset::RenderAssetUsages; +use bevy::camera::visibility::VisibilityRange; +use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; +use bevy::prelude::*; +use bevy::render::mesh::{Indices, PrimitiveTopology}; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; +use content::{BakedBuilding, BuildingSpec, BuildingUse, RoofStyle}; + +use crate::{NIGHT_SUFFIX, Scattered}; + +pub const BUILDING_LOD0_END: f32 = 180.0; +pub const BUILDING_LOD1_END: f32 = 650.0; +pub const BUILDING_CULL: f32 = 2_500.0; + +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuildingIndex(pub u32); + +#[derive(Clone)] +struct MaterialSet { + facade: [Handle; 5], + roof: [Handle; 4], + glass: Handle, + lit_glass: Handle, + trim: Handle, + balcony: Handle, + detail: Handle, + door: [Handle; 3], +} + +#[derive(Clone, Default)] +struct LodMeshes { + facade: Option>, + roof: Option>, + glass: Option>, + lit: Option>, + trim: Option>, + balcony: Option>, + detail: Option>, + door: Option>, +} + +#[derive(Clone)] +struct BuildingMeshes([LodMeshes; 3]); + +/// Global material library and geometry cache shared by editor and simulator. +#[derive(Resource)] +pub struct BuildingAssets { + materials: MaterialSet, + meshes: HashMap, +} + +impl FromWorld for BuildingAssets { + fn from_world(world: &mut World) -> Self { + let textures = { + let mut images = world.resource_mut::>(); + (0..9) + .map(|kind| { + let (albedo, normal, metal_rough) = surface_textures(kind); + ( + images.add(albedo), + images.add(normal), + images.add(metal_rough), + ) + }) + .collect::>() + }; + let mut materials = world.resource_mut::>(); + let textured = |texture: &(Handle, Handle, Handle)| StandardMaterial { + base_color_texture: Some(texture.0.clone()), + normal_map_texture: Some(texture.1.clone()), + metallic_roughness_texture: Some(texture.2.clone()), + // The packed map already contains physically meaningful values; + // unit factors preserve them instead of multiplying them twice. + perceptual_roughness: 1.0, + metallic: 1.0, + ..default() + }; + let facade = [ + materials.add(textured(&textures[0])), + materials.add(textured(&textures[1])), + materials.add(textured(&textures[2])), + materials.add(textured(&textures[3])), + materials.add(textured(&textures[4])), + ]; + let roof = [ + materials.add(textured(&textures[5])), + materials.add(textured(&textures[6])), + materials.add(textured(&textures[7])), + materials.add(textured(&textures[8])), + ]; + let glass = materials.add(StandardMaterial { + base_color: Color::srgb(0.045, 0.075, 0.105), + metallic: 0.0, + perceptual_roughness: 0.11, + reflectance: 0.9, + ..default() + }); + let lit_glass = materials.add(StandardMaterial { + base_color: Color::srgb(0.82, 0.52, 0.20), + emissive: LinearRgba::rgb(2.2, 1.05, 0.30), + perceptual_roughness: 0.34, + reflectance: 0.35, + ..default() + }); + let trim = materials.add(StandardMaterial { + base_color: Color::srgb(0.82, 0.84, 0.82), + perceptual_roughness: 0.44, + reflectance: 0.42, + ..default() + }); + let balcony = materials.add(StandardMaterial { + base_color: Color::srgb(0.24, 0.27, 0.29), + metallic: 0.62, + perceptual_roughness: 0.42, + ..default() + }); + let detail = materials.add(StandardMaterial { + base_color: Color::srgb(0.55, 0.59, 0.61), + metallic: 0.72, + perceptual_roughness: 0.38, + reflectance: 0.48, + ..default() + }); + let door = [ + materials.add(StandardMaterial { + base_color: Color::srgb(0.22, 0.105, 0.045), + perceptual_roughness: 0.64, + reflectance: 0.32, + ..default() + }), + materials.add(StandardMaterial { + base_color: Color::srgb(0.075, 0.10, 0.12), + metallic: 0.18, + perceptual_roughness: 0.3, + ..default() + }), + materials.add(StandardMaterial { + base_color: Color::srgb(0.34, 0.37, 0.39), + metallic: 0.68, + perceptual_roughness: 0.42, + ..default() + }), + ]; + Self { + materials: MaterialSet { + facade, + roof, + glass, + lit_glass, + trim, + balcony, + detail, + door, + }, + meshes: HashMap::new(), + } + } +} + +/// Adds every baked building as a child of its terrain tile. +pub fn spawn_buildings( + commands: &mut Commands, + meshes: &mut Assets, + assets: &mut BuildingAssets, + tile: Entity, + buildings: &[BakedBuilding], +) { + if buildings.is_empty() || commands.get_entity(tile).is_err() { + return; + } + for building in buildings { + let spec = building.spec.normalised(); + let key = spec.mesh_key(); + let mesh_set = assets + .meshes + .entry(key) + .or_insert_with(|| build_meshes(meshes, &spec)) + .clone(); + let root = commands + .spawn(( + Transform::from_translation(Vec3::from(building.pos)) + .with_rotation(Quat::from_array(building.rotation)), + Visibility::default(), + BuildingIndex(building.source_index), + Scattered, + )) + .id(); + commands.entity(tile).add_child(root); + let facade = assets.materials.facade[spec.facade as usize].clone(); + let roof = assets.materials.roof[spec.roof as usize].clone(); + let door = assets.materials.door[spec.use_kind as usize].clone(); + for (level, lod) in mesh_set.0.iter().enumerate() { + let range = match level { + 0 => VisibilityRange::abrupt(0.0, BUILDING_LOD0_END), + 1 => VisibilityRange::abrupt(BUILDING_LOD0_END, BUILDING_LOD1_END), + _ => VisibilityRange::abrupt(BUILDING_LOD1_END, BUILDING_CULL), + }; + let parts = [ + (&lod.facade, &facade, "facade"), + (&lod.roof, &roof, "roof"), + (&lod.glass, &assets.materials.glass, "windows"), + (&lod.trim, &assets.materials.trim, "trim"), + (&lod.balcony, &assets.materials.balcony, "balconies"), + (&lod.detail, &assets.materials.detail, "details"), + (&lod.door, &door, "doors"), + ]; + for (mesh, material, name) in parts { + if let Some(mesh) = mesh { + let child = commands + .spawn(( + Name::new(format!("building_{name}_LOD{level}")), + Mesh3d(mesh.clone()), + MeshMaterial3d(material.clone()), + Transform::IDENTITY, + range.clone(), + Scattered, + )) + .id(); + commands.entity(root).add_child(child); + } + } + if let Some(mesh) = &lod.lit { + let child = commands + .spawn(( + Name::new(format!("building_windows_LOD{level}{NIGHT_SUFFIX}")), + Mesh3d(mesh.clone()), + MeshMaterial3d(assets.materials.lit_glass.clone()), + Transform::IDENTITY, + range, + Scattered, + )) + .id(); + commands.entity(root).add_child(child); + } + } + } +} + +fn build_meshes(meshes: &mut Assets, spec: &BuildingSpec) -> BuildingMeshes { + BuildingMeshes(std::array::from_fn(|lod| { + let built = geometry(spec, lod); + LodMeshes { + facade: built.facade.finish().map(|m| meshes.add(m)), + roof: built.roof.finish().map(|m| meshes.add(m)), + glass: built.glass.finish().map(|m| meshes.add(m)), + lit: built.lit.finish().map(|m| meshes.add(m)), + trim: built.trim.finish().map(|m| meshes.add(m)), + balcony: built.balcony.finish().map(|m| meshes.add(m)), + detail: built.detail.finish().map(|m| meshes.add(m)), + door: built.door.finish().map(|m| meshes.add(m)), + } + })) +} + +#[derive(Default)] +struct Geometry { + facade: MeshData, + roof: MeshData, + glass: MeshData, + lit: MeshData, + trim: MeshData, + balcony: MeshData, + detail: MeshData, + door: MeshData, +} + +#[derive(Default)] +struct MeshData { + positions: Vec<[f32; 3]>, + normals: Vec<[f32; 3]>, + uvs: Vec<[f32; 2]>, + colors: Vec<[f32; 4]>, + indices: Vec, +} + +impl MeshData { + fn quad(&mut self, p: [[f32; 3]; 4], normal: [f32; 3], uv: [[f32; 2]; 4], color: [f32; 4]) { + let start = self.positions.len() as u32; + self.positions.extend(p); + self.normals.extend([normal; 4]); + self.uvs.extend(uv); + self.colors.extend([color; 4]); + self.indices + .extend_from_slice(&[start, start + 1, start + 2, start, start + 2, start + 3]); + } + + fn box_(&mut self, center: Vec3, size: Vec3, color: [f32; 4]) { + self.box_uv(center, size, color, None); + } + + fn closed_box(&mut self, center: Vec3, size: Vec3, color: [f32; 4]) { + self.closed_box_uv(center, size, color, None); + } + + /// Closed low-poly vertical cylinder for flues and roof ventilators. + /// It lives in the shared detail mesh, so even a busy factory adds no + /// entities or material switches per vent. + fn vertical_prism( + &mut self, + center: Vec3, + radius: f32, + height: f32, + sides: usize, + color: [f32; 4], + ) { + self.prism_between( + center - Vec3::Y * height * 0.5, + center + Vec3::Y * height * 0.5, + radius, + sides, + color, + ); + } + + /// Closed round profile along an arbitrary line. Used for both gutters + /// and their vertical/offset drain pipes, keeping the same cross-section + /// through every connector. + fn prism_between( + &mut self, + start: Vec3, + end: Vec3, + radius: f32, + sides: usize, + color: [f32; 4], + ) { + let sides = sides.max(3); + let axis = (end - start).normalize_or_zero(); + if axis.length_squared() < 0.5 { + return; + } + let radial_a = if axis.y.abs() < 0.9 { + axis.cross(Vec3::Y).normalize_or_zero() + } else { + Vec3::X + }; + let radial_b = radial_a.cross(axis).normalize_or_zero(); + for side in 0..sides { + let a = side as f32 * std::f32::consts::TAU / sides as f32; + let b = (side + 1) as f32 * std::f32::consts::TAU / sides as f32; + let direction_a = radial_a * a.cos() + radial_b * a.sin(); + let direction_b = radial_a * b.cos() + radial_b * b.sin(); + let start_a = start + direction_a * radius; + let start_b = start + direction_b * radius; + let end_a = end + direction_a * radius; + let end_b = end + direction_b * radius; + let normal = (direction_a + direction_b).normalize_or_zero(); + self.quad( + [ + start_a.to_array(), + end_a.to_array(), + end_b.to_array(), + start_b.to_array(), + ], + normal.to_array(), + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + color, + ); + self.triangle( + [end.to_array(), end_b.to_array(), end_a.to_array()], + axis.to_array(), + [[0.5, 0.5], [0.0, 0.0], [1.0, 0.0]], + color, + ); + self.triangle( + [start.to_array(), start_a.to_array(), start_b.to_array()], + (-axis).to_array(), + [[0.5, 0.5], [1.0, 0.0], [0.0, 0.0]], + color, + ); + } + } + + fn closed_box_uv(&mut self, center: Vec3, size: Vec3, color: [f32; 4], density: Option) { + self.box_uv(center, size, color, density); + let lo = center - size * 0.5; + let hi = center + size * 0.5; + let uv = match density { + Some(density) => [ + [0.0, 0.0], + [0.0, size.z * density], + [size.x * density, size.z * density], + [size.x * density, 0.0], + ], + None => [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + }; + self.quad( + [ + [lo.x, lo.y, hi.z], + [lo.x, lo.y, lo.z], + [hi.x, lo.y, lo.z], + [hi.x, lo.y, hi.z], + ], + [0.0, -1.0, 0.0], + uv, + color, + ); + } + + fn box_uv(&mut self, center: Vec3, size: Vec3, color: [f32; 4], density: Option) { + let lo = center - size * 0.5; + let hi = center + size * 0.5; + let uv = |u: f32, v: f32| match density { + Some(density) => [ + [0.0, 0.0], + [0.0, v * density], + [u * density, v * density], + [u * density, 0.0], + ], + None => [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + }; + self.quad( + [ + [lo.x, lo.y, lo.z], + [lo.x, hi.y, lo.z], + [hi.x, hi.y, lo.z], + [hi.x, lo.y, lo.z], + ], + [0.0, 0.0, -1.0], + uv(size.x, size.y), + color, + ); + self.quad( + [ + [hi.x, lo.y, hi.z], + [hi.x, hi.y, hi.z], + [lo.x, hi.y, hi.z], + [lo.x, lo.y, hi.z], + ], + [0.0, 0.0, 1.0], + uv(size.x, size.y), + color, + ); + self.quad( + [ + [lo.x, lo.y, hi.z], + [lo.x, hi.y, hi.z], + [lo.x, hi.y, lo.z], + [lo.x, lo.y, lo.z], + ], + [-1.0, 0.0, 0.0], + uv(size.z, size.y), + color, + ); + self.quad( + [ + [hi.x, lo.y, lo.z], + [hi.x, hi.y, lo.z], + [hi.x, hi.y, hi.z], + [hi.x, lo.y, hi.z], + ], + [1.0, 0.0, 0.0], + uv(size.z, size.y), + color, + ); + self.quad( + [ + [lo.x, hi.y, lo.z], + [lo.x, hi.y, hi.z], + [hi.x, hi.y, hi.z], + [hi.x, hi.y, lo.z], + ], + [0.0, 1.0, 0.0], + uv(size.x, size.z), + color, + ); + } + + fn triangle(&mut self, p: [[f32; 3]; 3], normal: [f32; 3], uv: [[f32; 2]; 3], color: [f32; 4]) { + let start = self.positions.len() as u32; + self.positions.extend(p); + self.normals.extend([normal; 3]); + self.uvs.extend(uv); + self.colors.extend([color; 3]); + self.indices + .extend_from_slice(&[start, start + 1, start + 2]); + } + + fn quad_auto(&mut self, p: [[f32; 3]; 4], uv: [[f32; 2]; 4], color: [f32; 4]) { + let a = Vec3::from_array(p[1]) - Vec3::from_array(p[0]); + let b = Vec3::from_array(p[2]) - Vec3::from_array(p[0]); + self.quad(p, a.cross(b).normalize_or_zero().to_array(), uv, color); + } + + fn triangle_auto(&mut self, p: [[f32; 3]; 3], uv: [[f32; 2]; 3], color: [f32; 4]) { + let a = Vec3::from_array(p[1]) - Vec3::from_array(p[0]); + let b = Vec3::from_array(p[2]) - Vec3::from_array(p[0]); + self.triangle(p, a.cross(b).normalize_or_zero().to_array(), uv, color); + } + + /// A thin roof panel with a visible underside. `outer_edges` identifies + /// the perimeter edges that need fascia; shared ridges and hips remain + /// open inside the joined shell and cannot produce overlapping faces. + fn roof_quad( + &mut self, + top: [[f32; 3]; 4], + uv: [[f32; 2]; 4], + outer_edges: [bool; 4], + color: [f32; 4], + ) { + const THICKNESS: f32 = 0.12; + self.quad_auto(top, uv, color); + let bottom = top.map(|point| [point[0], point[1] - THICKNESS, point[2]]); + self.quad_auto( + [bottom[3], bottom[2], bottom[1], bottom[0]], + [uv[3], uv[2], uv[1], uv[0]], + color, + ); + for edge in 0..4 { + if !outer_edges[edge] { + continue; + } + let next = (edge + 1) % 4; + let length = Vec3::from_array(top[next]).distance(Vec3::from_array(top[edge])) + * SURFACE_UV_DENSITY; + self.quad_auto( + [top[edge], top[next], bottom[next], bottom[edge]], + [ + [0.0, 0.0], + [length, 0.0], + [length, THICKNESS * SURFACE_UV_DENSITY], + [0.0, THICKNESS * SURFACE_UV_DENSITY], + ], + color, + ); + } + } + + fn roof_triangle( + &mut self, + top: [[f32; 3]; 3], + uv: [[f32; 2]; 3], + outer_edges: [bool; 3], + color: [f32; 4], + ) { + const THICKNESS: f32 = 0.12; + self.triangle_auto(top, uv, color); + let bottom = top.map(|point| [point[0], point[1] - THICKNESS, point[2]]); + self.triangle_auto( + [bottom[2], bottom[1], bottom[0]], + [uv[2], uv[1], uv[0]], + color, + ); + for edge in 0..3 { + if !outer_edges[edge] { + continue; + } + let next = (edge + 1) % 3; + let length = Vec3::from_array(top[next]).distance(Vec3::from_array(top[edge])) + * SURFACE_UV_DENSITY; + self.quad_auto( + [top[edge], top[next], bottom[next], bottom[edge]], + [ + [0.0, 0.0], + [length, 0.0], + [length, THICKNESS * SURFACE_UV_DENSITY], + [0.0, THICKNESS * SURFACE_UV_DENSITY], + ], + color, + ); + } + } + + /// Two thin flashing wings that each lie on one of the adjoining roof + /// planes. `inside_a` and `inside_b` identify those planes and keep the + /// construction valid for ridges as well as diagonal hip joints. + fn roof_cap( + &mut self, + start: Vec3, + end: Vec3, + inside_a: Vec3, + inside_b: Vec3, + width: f32, + color: [f32; 4], + ) { + let axis = (end - start).normalize_or_zero(); + let middle = (start + end) * 0.5; + let into_plane = |inside: Vec3| { + let offset = inside - middle; + (offset - axis * offset.dot(axis)).normalize_or_zero() + }; + let direction_a = into_plane(inside_a); + let direction_b = into_plane(inside_b); + let upward_normal = |direction: Vec3| { + let mut normal = axis.cross(direction).normalize_or_zero(); + if normal.y < 0.0 { + normal = -normal; + } + normal + }; + let normal_a = upward_normal(direction_a); + let normal_b = upward_normal(direction_b); + let crest = (normal_a + normal_b).normalize_or(Vec3::Y) * 0.018; + let along = (end - start).length() * SURFACE_UV_DENSITY; + for (direction, normal) in [(direction_a, normal_a), (direction_b, normal_b)] { + let mut points = [ + (start + crest).to_array(), + (end + crest).to_array(), + (end + direction * width + normal * 0.008).to_array(), + (start + direction * width + normal * 0.008).to_array(), + ]; + let mut uv = [ + [0.0, 0.0], + [along, 0.0], + [along, width * SURFACE_UV_DENSITY], + [0.0, width * SURFACE_UV_DENSITY], + ]; + let face = (Vec3::from_array(points[1]) - Vec3::from_array(points[0])) + .cross(Vec3::from_array(points[2]) - Vec3::from_array(points[0])); + if face.dot(normal) < 0.0 { + points.swap(1, 3); + uv.swap(1, 3); + } + self.quad_auto(points, uv, color); + } + } + + fn finish(self) -> Option { + if self.positions.is_empty() { + return None; + } + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, self.positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, self.normals); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, self.uvs); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, self.colors); + mesh.insert_indices(Indices::U32(self.indices)); + // Shared facade and roof materials carry normal maps, which require a + // tangent basis. Every generated quad has non-degenerate UVs. + let _ = mesh.generate_tangents(); + Some(mesh) + } +} + +fn geometry(spec: &BuildingSpec, lod: usize) -> Geometry { + let mut g = Geometry::default(); + let (w, l, h) = (spec.width, spec.length, spec.wall_height()); + let tint = [ + spec.facade_color[0], + spec.facade_color[1], + spec.facade_color[2], + 1.0, + ]; + add_walls(&mut g.facade, w, l, h, tint); + add_roof_end_walls(&mut g.facade, spec, w, l, h, tint); + add_roof(&mut g.roof, spec, w, l, h); + if lod < 2 { + add_roof_caps(&mut g.roof, spec, w, l, h); + if spec.roof_style == RoofStyle::Sawtooth { + add_sawtooth_glazing(&mut g.glass, spec, w, l, h); + } + add_openings(&mut g, spec, lod); + add_roof_details(&mut g, spec, lod, tint); + } + if lod == 0 && spec.balconies { + add_balconies(&mut g.balcony, spec); + } + g +} + +fn add_roof_end_walls( + mesh: &mut MeshData, + spec: &BuildingSpec, + w: f32, + l: f32, + h: f32, + color: [f32; 4], +) { + let r = spec.roof_height; + if spec.roof_style == RoofStyle::Shed { + // A shed roof ends one full roof-height above the ordinary wall on + // its high side. Closing only the triangular end walls leaves this + // whole long side open and exposes the culled wall backs from above. + mesh.quad( + [ + [w / 2.0, h, -l / 2.0], + [w / 2.0, h + r, -l / 2.0], + [w / 2.0, h + r, l / 2.0], + [w / 2.0, h, l / 2.0], + ], + [1.0, 0.0, 0.0], + [[0.0, h], [0.0, h + r], [l, h + r], [l, h]], + color, + ); + } + let profile: Vec<[f32; 2]> = match spec.roof_style { + RoofStyle::Gable => vec![[-w / 2.0, h], [0.0, h + r], [w / 2.0, h]], + RoofStyle::Shed => vec![[-w / 2.0, h], [w / 2.0, h + r], [w / 2.0, h]], + RoofStyle::Mansard => vec![ + [-w / 2.0, h], + [-w * 0.32, h + r * 0.72], + [0.0, h + r], + [w * 0.32, h + r * 0.72], + [w / 2.0, h], + ], + RoofStyle::Sawtooth => { + add_sawtooth_end_walls(mesh, spec, w, l, h, color); + return; + } + RoofStyle::Flat | RoofStyle::Hip => return, + }; + for end in [-1.0_f32, 1.0] { + let z = end * l / 2.0; + for i in 1..profile.len() - 1 { + let source = [profile[0], profile[i], profile[i + 1]]; + let mut p = [ + [source[0][0], source[0][1], z], + [source[1][0], source[1][1], z], + [source[2][0], source[2][1], z], + ]; + // Planar projection shared by every triangle: brick and plaster + // continue through the gable instead of restarting diagonally. + let mut uv = source.map(|point| { + [ + (point[0] + w / 2.0) * SURFACE_UV_DENSITY, + point[1] * SURFACE_UV_DENSITY, + ] + }); + if end > 0.0 { + p.swap(1, 2); + uv.swap(1, 2); + } + mesh.triangle_auto(p, uv, color); + } + } +} + +fn add_walls(mesh: &mut MeshData, w: f32, l: f32, h: f32, color: [f32; 4]) { + let uv = |u: f32, v: f32| { + let (u, v) = (u * SURFACE_UV_DENSITY, v * SURFACE_UV_DENSITY); + [[0.0, 0.0], [0.0, v], [u, v], [u, 0.0]] + }; + mesh.quad( + [ + [-w / 2.0, 0.0, -l / 2.0], + [-w / 2.0, h, -l / 2.0], + [w / 2.0, h, -l / 2.0], + [w / 2.0, 0.0, -l / 2.0], + ], + [0.0, 0.0, -1.0], + uv(w, h), + color, + ); + mesh.quad( + [ + [w / 2.0, 0.0, l / 2.0], + [w / 2.0, h, l / 2.0], + [-w / 2.0, h, l / 2.0], + [-w / 2.0, 0.0, l / 2.0], + ], + [0.0, 0.0, 1.0], + uv(w, h), + color, + ); + mesh.quad( + [ + [-w / 2.0, 0.0, l / 2.0], + [-w / 2.0, h, l / 2.0], + [-w / 2.0, h, -l / 2.0], + [-w / 2.0, 0.0, -l / 2.0], + ], + [-1.0, 0.0, 0.0], + uv(l, h), + color, + ); + mesh.quad( + [ + [w / 2.0, 0.0, -l / 2.0], + [w / 2.0, h, -l / 2.0], + [w / 2.0, h, l / 2.0], + [w / 2.0, 0.0, l / 2.0], + ], + [1.0, 0.0, 0.0], + uv(l, h), + color, + ); +} + +fn sawtooth_count(width: f32) -> usize { + (width / 10.0).round().clamp(2.0, 8.0) as usize +} + +fn sawtooth_is_glazed(spec: &BuildingSpec, tooth: usize, count: usize) -> bool { + let glazed = (spec.skylights as usize).min(count); + glazed == count || (tooth + spec.seed as usize % count) % count < glazed +} + +fn add_sawtooth_end_walls( + mesh: &mut MeshData, + spec: &BuildingSpec, + w: f32, + l: f32, + h: f32, + color: [f32; 4], +) { + let count = sawtooth_count(w); + let run = w / count as f32; + for end in [-1.0_f32, 1.0] { + let z = end * l / 2.0; + for tooth in 0..count { + let x0 = -w / 2.0 + tooth as f32 * run; + let x1 = x0 + run * 0.78; + let x2 = x0 + run; + for source in [ + [[x0, h, z], [x1, h + spec.roof_height, z], [x1, h, z]], + [[x1, h, z], [x1, h + spec.roof_height, z], [x2, h, z]], + ] { + let mut p = source; + let mut uv = source.map(|point| { + [ + (point[0] + w / 2.0) * SURFACE_UV_DENSITY, + point[1] * SURFACE_UV_DENSITY, + ] + }); + if end > 0.0 { + p.swap(1, 2); + uv.swap(1, 2); + } + mesh.triangle_auto(p, uv, color); + } + } + } +} + +fn add_sawtooth_glazing(mesh: &mut MeshData, spec: &BuildingSpec, w: f32, l: f32, h: f32) { + let e = 0.28; + let count = sawtooth_count(w); + let run = (w + e * 2.0) / count as f32; + for tooth in 0..count { + if !sawtooth_is_glazed(spec, tooth, count) { + continue; + } + let x0 = -w / 2.0 - e + tooth as f32 * run; + let high = x0 + run * 0.78; + let low = x0 + run; + mesh.quad_auto( + [ + [high, h + spec.roof_height, -l / 2.0 - e], + [high, h + spec.roof_height, l / 2.0 + e], + [low, h, l / 2.0 + e], + [low, h, -l / 2.0 - e], + ], + [ + [0.0, 0.0], + [(l + e * 2.0) * SURFACE_UV_DENSITY, 0.0], + [ + (l + e * 2.0) * SURFACE_UV_DENSITY, + run.hypot(spec.roof_height) * SURFACE_UV_DENSITY, + ], + [0.0, run.hypot(spec.roof_height) * SURFACE_UV_DENSITY], + ], + [0.72, 0.78, 0.84, 1.0], + ); + } +} + +fn add_roof(mesh: &mut MeshData, spec: &BuildingSpec, w: f32, l: f32, h: f32) { + let c = [1.0; 4]; + let e = 0.28; + // U always follows the eave/ridge and V always climbs the roof. Besides + // making the scale independent of the building footprint, this is what + // makes tile courses run parallel to the eave and standing seams drain + // from ridge to gutter. + let density = SURFACE_UV_DENSITY; + let along = (l + e * 2.0) * density; + match spec.roof_style { + RoofStyle::Flat => mesh.closed_box_uv( + Vec3::new(0.0, h + 0.18, 0.0), + Vec3::new(w + e * 2.0, 0.36, l + e * 2.0), + c, + Some(density), + ), + RoofStyle::Gable => { + let r = spec.roof_height; + let eave = h - r * e / (w / 2.0).max(0.1); + let slope = (w / 2.0 + e).hypot(h + r - eave) * density; + mesh.roof_quad( + [ + [-w / 2.0 - e, eave, -l / 2.0 - e], + [-w / 2.0 - e, eave, l / 2.0 + e], + [0.0, h + r, l / 2.0 + e], + [0.0, h + r, -l / 2.0 - e], + ], + [[0.0, 0.0], [along, 0.0], [along, slope], [0.0, slope]], + [true, true, false, true], + c, + ); + mesh.roof_quad( + [ + [0.0, h + r, -l / 2.0 - e], + [0.0, h + r, l / 2.0 + e], + [w / 2.0 + e, eave, l / 2.0 + e], + [w / 2.0 + e, eave, -l / 2.0 - e], + ], + [[0.0, slope], [along, slope], [along, 0.0], [0.0, 0.0]], + [false, true, true, true], + c, + ); + } + RoofStyle::Hip => { + let r = spec.roof_height; + let ridge = (l * 0.22).min(l / 2.0 - e); + let eave = h - r * e / (w / 2.0).max(0.1); + let slope = (w / 2.0 + e).hypot(h + r - eave) * density; + let ridge_front = (l / 2.0 + e - ridge) * density; + let ridge_back = (l / 2.0 + e + ridge) * density; + mesh.roof_quad( + [ + [-w / 2.0 - e, eave, -l / 2.0 - e], + [-w / 2.0 - e, eave, l / 2.0 + e], + [0.0, h + r, ridge], + [0.0, h + r, -ridge], + ], + [ + [0.0, 0.0], + [along, 0.0], + [ridge_back, slope], + [ridge_front, slope], + ], + [true, false, false, false], + c, + ); + mesh.roof_quad( + [ + [0.0, h + r, -ridge], + [0.0, h + r, ridge], + [w / 2.0 + e, eave, l / 2.0 + e], + [w / 2.0 + e, eave, -l / 2.0 - e], + ], + [ + [ridge_front, slope], + [ridge_back, slope], + [along, 0.0], + [0.0, 0.0], + ], + [false, false, true, false], + c, + ); + let across = (w + e * 2.0) * density; + let hip_slope = (l / 2.0 + e - ridge).hypot(h + r - eave) * density; + mesh.roof_triangle( + [ + [-w / 2.0 - e, eave, -l / 2.0 - e], + [0.0, h + r, -ridge], + [w / 2.0 + e, eave, -l / 2.0 - e], + ], + [[0.0, 0.0], [across / 2.0, hip_slope], [across, 0.0]], + [false, false, true], + c, + ); + mesh.roof_triangle( + [ + [w / 2.0 + e, eave, l / 2.0 + e], + [0.0, h + r, ridge], + [-w / 2.0 - e, eave, l / 2.0 + e], + ], + [[across, 0.0], [across / 2.0, hip_slope], [0.0, 0.0]], + [false, false, true], + c, + ); + } + RoofStyle::Shed => { + let extension = spec.roof_height * e / w.max(0.1); + let low = h - extension; + let high = h + spec.roof_height + extension; + let slope = (w + e * 2.0).hypot(high - low) * density; + mesh.roof_quad( + [ + [-w / 2.0 - e, low, -l / 2.0 - e], + [-w / 2.0 - e, low, l / 2.0 + e], + [w / 2.0 + e, high, l / 2.0 + e], + [w / 2.0 + e, high, -l / 2.0 - e], + ], + [[0.0, 0.0], [along, 0.0], [along, slope], [0.0, slope]], + [true; 4], + c, + ); + } + RoofStyle::Mansard => { + let r = spec.roof_height; + let shoulder = w * 0.32; + let lower_run = w / 2.0 - shoulder; + let eave = h - r * 0.72 * e / lower_run.max(0.1); + let lower = (w / 2.0 + e - shoulder).hypot(h + r * 0.72 - eave) * density; + let upper = shoulder.hypot(r * 0.28) * density; + let crown = lower + upper; + for (panel, (a, b, va, vb)) in [ + ((-w / 2.0 - e, eave), (-shoulder, h + r * 0.72), 0.0, lower), + ((-shoulder, h + r * 0.72), (0.0, h + r), lower, crown), + ((0.0, h + r), (shoulder, h + r * 0.72), crown, lower), + ((shoulder, h + r * 0.72), (w / 2.0 + e, eave), lower, 0.0), + ] + .into_iter() + .enumerate() + { + mesh.roof_quad( + [ + [a.0, a.1, -l / 2.0 - e], + [a.0, a.1, l / 2.0 + e], + [b.0, b.1, l / 2.0 + e], + [b.0, b.1, -l / 2.0 - e], + ], + [[0.0, va], [along, va], [along, vb], [0.0, vb]], + [panel == 0, true, panel == 3, true], + c, + ); + } + } + RoofStyle::Sawtooth => { + let count = sawtooth_count(w); + let full_width = w + e * 2.0; + let run = full_width / count as f32; + for tooth in 0..count { + let x0 = -w / 2.0 - e + tooth as f32 * run; + let high = x0 + run * 0.78; + let low = x0 + run; + let rise = run.mul_add(0.78, 0.0).hypot(spec.roof_height) * density; + mesh.roof_quad( + [ + [x0, h, -l / 2.0 - e], + [x0, h, l / 2.0 + e], + [high, h + spec.roof_height, l / 2.0 + e], + [high, h + spec.roof_height, -l / 2.0 - e], + ], + [[0.0, 0.0], [along, 0.0], [along, rise], [0.0, rise]], + [true; 4], + c, + ); + if !sawtooth_is_glazed(spec, tooth, count) { + let fall = (run * 0.22).hypot(spec.roof_height) * density; + mesh.roof_quad( + [ + [high, h + spec.roof_height, -l / 2.0 - e], + [high, h + spec.roof_height, l / 2.0 + e], + [low, h, l / 2.0 + e], + [low, h, -l / 2.0 - e], + ], + [[0.0, 0.0], [along, 0.0], [along, fall], [0.0, fall]], + [true; 4], + c, + ); + } + } + } + } +} + +fn add_roof_caps(mesh: &mut MeshData, spec: &BuildingSpec, w: f32, l: f32, h: f32) { + let r = spec.roof_height; + let e = 0.28; + // The cap shares the selected roof material. A slight vertex tint keeps + // the joint readable without turning it into a white window-trim stripe. + let color = [0.82, 0.82, 0.82, 1.0]; + let ridge_front = Vec3::new(0.0, h + r, -l / 2.0 - e); + let ridge_back = Vec3::new(0.0, h + r, l / 2.0 + e); + match spec.roof_style { + RoofStyle::Gable => { + mesh.roof_cap( + ridge_front, + ridge_back, + Vec3::new(-w * 0.25, h + r * 0.5, 0.0), + Vec3::new(w * 0.25, h + r * 0.5, 0.0), + 0.10, + color, + ); + } + RoofStyle::Mansard => { + mesh.roof_cap( + ridge_front, + ridge_back, + Vec3::new(-w * 0.16, h + r * 0.86, 0.0), + Vec3::new(w * 0.16, h + r * 0.86, 0.0), + 0.10, + color, + ); + } + RoofStyle::Hip => { + let ridge = (l * 0.22).min(l / 2.0 - e); + let eave = h - r * e / (w / 2.0).max(0.1); + let front = Vec3::new(0.0, h + r, -ridge); + let back = Vec3::new(0.0, h + r, ridge); + let left_front = Vec3::new(-w / 2.0 - e, eave, -l / 2.0 - e); + let right_front = Vec3::new(w / 2.0 + e, eave, -l / 2.0 - e); + let left_back = Vec3::new(-w / 2.0 - e, eave, l / 2.0 + e); + let right_back = Vec3::new(w / 2.0 + e, eave, l / 2.0 + e); + let left_face = (left_front + left_back + front + back) * 0.25; + let right_face = (right_front + right_back + front + back) * 0.25; + let front_face = (left_front + right_front + front) / 3.0; + let back_face = (left_back + right_back + back) / 3.0; + mesh.roof_cap(front, back, left_face, right_face, 0.10, color); + mesh.roof_cap(front, left_front, left_face, front_face, 0.085, color); + mesh.roof_cap(front, right_front, right_face, front_face, 0.085, color); + mesh.roof_cap(back, left_back, left_face, back_face, 0.085, color); + mesh.roof_cap(back, right_back, right_face, back_face, 0.085, color); + } + RoofStyle::Flat | RoofStyle::Shed | RoofStyle::Sawtooth => {} + } +} + +fn roof_surface_height(spec: &BuildingSpec, x: f32, z: f32) -> f32 { + let w = spec.width.max(0.1); + let l = spec.length.max(0.1); + let h = spec.wall_height(); + let r = spec.roof_height; + match spec.roof_style { + RoofStyle::Flat => h + 0.36, + RoofStyle::Gable => h + r * (1.0 - 2.0 * x.abs() / w).clamp(0.0, 1.0), + RoofStyle::Hip => { + let ridge = (l * 0.22).min(l / 2.0 - 0.01); + let end = ((z.abs() - ridge) / (l / 2.0 - ridge).max(0.01)).max(0.0); + let edge = (2.0 * x.abs() / w).max(end); + h + r * (1.0 - edge).clamp(0.0, 1.0) + } + RoofStyle::Shed => h + r * (x / w + 0.5).clamp(0.0, 1.0), + RoofStyle::Mansard => { + let x = x.abs(); + let shoulder = w * 0.32; + if x <= shoulder { + h + r * (1.0 - x / shoulder.max(0.01) * 0.28) + } else { + h + r * 0.72 * (1.0 - (x - shoulder) / (w / 2.0 - shoulder).max(0.01)) + } + } + RoofStyle::Sawtooth => { + let count = sawtooth_count(w); + let run = w / count as f32; + let local = (x + w / 2.0).rem_euclid(run); + if local <= run * 0.78 { + h + r * local / (run * 0.78).max(0.01) + } else { + h + r * (1.0 - (local - run * 0.78) / (run * 0.22).max(0.01)) + } + } + } +} + +fn add_roof_details(g: &mut Geometry, spec: &BuildingSpec, lod: usize, facade_tint: [f32; 4]) { + let count = spec.chimneys as usize; + for chimney in 0..count { + let side = if chimney % 2 == 0 { -1.0 } else { 1.0 }; + let x = if spec.roof_style == RoofStyle::Sawtooth { + 0.0 + } else { + side * spec.width * 0.18 + }; + let z = -spec.length * 0.34 + + spec.length * 0.68 * (chimney as f32 + 1.0) / (count as f32 + 1.0); + let visible_height = if spec.use_kind == BuildingUse::Industrial { + 1.8 + } else { + 1.05 + }; + let size = if spec.use_kind == BuildingUse::Industrial { + 0.88 + } else { + 0.54 + }; + let depth = size * 0.82; + let apron = size + 0.20; + let mut flashing = [ + [x - apron / 2.0, 0.0, z - apron / 2.0], + [x - apron / 2.0, 0.0, z + apron / 2.0], + [x + apron / 2.0, 0.0, z + apron / 2.0], + [x + apron / 2.0, 0.0, z - apron / 2.0], + ]; + for point in &mut flashing { + point[1] = roof_surface_height(spec, point[0], point[2]) + 0.012; + } + g.detail.quad_auto( + flashing, + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [0.42, 0.44, 0.44, 1.0], + ); + let bottom = flashing + .iter() + .map(|point| point[1]) + .fold(f32::INFINITY, f32::min) + - 0.08; + let top = roof_surface_height(spec, x, z) + visible_height; + let shaft_height = top - bottom; + g.facade.closed_box_uv( + Vec3::new(x, (bottom + top) * 0.5, z), + Vec3::new(size, shaft_height, depth), + facade_tint, + Some(SURFACE_UV_DENSITY), + ); + g.detail.closed_box( + Vec3::new(x, top + 0.03, z), + Vec3::new(size + 0.045, 0.06, depth + 0.045), + [0.44, 0.45, 0.44, 1.0], + ); + let flue_radius = size * 0.13; + g.detail.vertical_prism( + Vec3::new(x, top + 0.13, z), + flue_radius, + 0.20, + if lod == 0 { 10 } else { 6 }, + [0.30, 0.31, 0.31, 1.0], + ); + g.detail.vertical_prism( + Vec3::new(x, top + 0.245, z), + flue_radius * 1.30, + 0.035, + if lod == 0 { 10 } else { 6 }, + [0.26, 0.27, 0.27, 1.0], + ); + } + + let count = spec.roof_vents as usize; + for vent in 0..count { + let row = vent % 2; + let x = if spec.roof_style == RoofStyle::Sawtooth { + -spec.width * 0.28 + spec.width * 0.56 * (vent as f32 + 0.5) / count.max(1) as f32 + } else { + (row as f32 * 2.0 - 1.0) * spec.width * 0.22 + }; + let z = + -spec.length * 0.38 + spec.length * 0.76 * (vent as f32 + 1.0) / (count as f32 + 1.0); + let base = roof_surface_height(spec, x, z); + let stem_height = if spec.use_kind == BuildingUse::Industrial { + 0.9 + } else { + 0.62 + }; + let radius = if spec.use_kind == BuildingUse::Industrial { + 0.24 + } else { + 0.17 + }; + g.detail.vertical_prism( + Vec3::new(x, base + stem_height * 0.5, z), + radius, + stem_height, + if lod == 0 { 10 } else { 6 }, + [0.82, 0.84, 0.84, 1.0], + ); + g.detail.vertical_prism( + Vec3::new(x, base + stem_height + 0.07, z), + radius * 1.55, + 0.14, + if lod == 0 { 10 } else { 6 }, + [0.68, 0.71, 0.72, 1.0], + ); + } + + if lod == 0 { + add_skylights(&mut g.glass, &mut g.detail, spec); + if spec.rain_gutters { + add_rain_gutters(&mut g.detail, spec); + } + if spec.entrance_canopy { + add_entrance_canopy(&mut g.detail, spec); + } + } +} + +fn add_skylights(glass: &mut MeshData, frame_mesh: &mut MeshData, spec: &BuildingSpec) { + if spec.roof_style == RoofStyle::Sawtooth { + return; + } + let count = spec.skylights as usize; + let (max_w, max_l) = if spec.use_kind == BuildingUse::Industrial { + (1.35, 1.8) + } else { + (0.95, 1.35) + }; + let patch_w = (spec.width * 0.055).clamp(0.65, max_w); + let patch_l = (spec.length / (count.max(1) as f32 + 1.0) * 0.40).clamp(0.85, max_l); + for light in 0..count { + let side = if light % 2 == 0 { -1.0 } else { 1.0 }; + let x = match spec.roof_style { + RoofStyle::Gable | RoofStyle::Hip | RoofStyle::Mansard => side * spec.width * 0.26, + RoofStyle::Flat | RoofStyle::Shed => side * spec.width * 0.16, + RoofStyle::Sawtooth => unreachable!(), + }; + let z = + -spec.length * 0.38 + spec.length * 0.76 * (light as f32 + 1.0) / (count as f32 + 1.0); + let mut p = [ + [x - patch_w / 2.0, 0.0, z - patch_l / 2.0], + [x - patch_w / 2.0, 0.0, z + patch_l / 2.0], + [x + patch_w / 2.0, 0.0, z + patch_l / 2.0], + [x + patch_w / 2.0, 0.0, z - patch_l / 2.0], + ]; + for point in &mut p { + point[1] = roof_surface_height(spec, point[0], point[2]) + 0.012; + } + glass.quad_auto( + p, + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [0.38, 0.52, 0.62, 1.0], + ); + + // Four strips follow the same bilinear roof patch. Unlike an + // axis-aligned box they cannot lift off a pitched or hipped roof. + let p = p.map(Vec3::from_array); + let u = (0.055 / patch_w).clamp(0.03, 0.15); + let v = (0.055 / patch_l).clamp(0.03, 0.15); + let on_patch = + |u: f32, v: f32| p[0].lerp(p[3], u).lerp(p[1].lerp(p[2], u), v) + Vec3::Y * 0.004; + let i00 = on_patch(u, v); + let i01 = on_patch(u, 1.0 - v); + let i11 = on_patch(1.0 - u, 1.0 - v); + let i10 = on_patch(1.0 - u, v); + for strip in [ + [p[0], p[1], i01, i00], + [i10, i11, p[2], p[3]], + [p[0], i00, i10, p[3]], + [i01, p[1], p[2], i11], + ] { + frame_mesh.quad_auto( + strip.map(|point| point.to_array()), + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [0.78, 0.81, 0.82, 1.0], + ); + } + } +} + +fn add_rain_gutters(mesh: &mut MeshData, spec: &BuildingSpec) { + let h = spec.wall_height(); + let detail = [0.80, 0.83, 0.84, 1.0]; + let pipe_radius = 0.036; + let sides = 10; + let gutter_end = (spec.length / 2.0 - 0.12).max(0.3); + let (left_y, right_y) = match spec.roof_style { + RoofStyle::Shed => (h, h + spec.roof_height), + RoofStyle::Flat => (h + 0.34, h + 0.34), + _ => (h, h), + }; + for (x, y) in [ + (-spec.width / 2.0 - 0.31, left_y), + (spec.width / 2.0 + 0.31, right_y), + ] { + mesh.closed_box( + Vec3::new(x, y - 0.055, 0.0), + Vec3::new(0.075, 0.085, gutter_end * 2.0), + detail, + ); + } + if matches!(spec.roof_style, RoofStyle::Hip | RoofStyle::Flat) { + let y = if spec.roof_style == RoofStyle::Flat { + h + 0.34 + } else { + h + }; + let gutter_end_x = (spec.width / 2.0 - 0.12).max(0.3); + for z in [-spec.length / 2.0 - 0.31, spec.length / 2.0 + 0.31] { + mesh.closed_box( + Vec3::new(0.0, y - 0.055, z), + Vec3::new(gutter_end_x * 2.0, 0.085, 0.075), + detail, + ); + } + } + if spec.roof_style == RoofStyle::Sawtooth { + let teeth = sawtooth_count(spec.width); + for valley in 1..teeth { + let x = -spec.width / 2.0 + spec.width * valley as f32 / teeth as f32; + mesh.closed_box( + Vec3::new(x, h + 0.035, 0.0), + Vec3::new(0.065, 0.065, gutter_end * 2.0), + detail, + ); + } + } + for sign_x in [-1.0_f32, 1.0] { + let gutter_x = sign_x * (spec.width / 2.0 + 0.31); + let pipe_x = sign_x * (spec.width / 2.0 + pipe_radius * 0.72); + let top = if sign_x < 0.0 { left_y } else { right_y }; + for sign_z in [-1.0_f32, 1.0] { + // The pipe sits on the side facade, slightly inboard of the + // corner. A short diagonal elbow reaches the gutter above it. + let pipe_z = sign_z * (spec.length / 2.0 - 0.26).max(0.1); + let pipe_top = Vec3::new(pipe_x, top - 0.12, pipe_z); + mesh.prism_between( + Vec3::new(pipe_x, 0.06, pipe_z), + pipe_top, + pipe_radius, + sides, + detail, + ); + mesh.prism_between( + pipe_top, + Vec3::new(gutter_x, top - 0.055, pipe_z), + pipe_radius, + sides, + detail, + ); + } + } +} + +fn add_entrance_canopy(mesh: &mut MeshData, spec: &BuildingSpec) { + let width = if spec.use_kind == BuildingUse::Industrial { + (spec.width * 0.72).min(28.0) + } else { + (spec.width * 0.58).min(10.0) + }; + let depth = if spec.use_kind == BuildingUse::Industrial { + 2.4 + } else { + 1.55 + }; + let y = if spec.use_kind == BuildingUse::Industrial { + (spec.floor_height * 0.82).min(spec.wall_height() - 0.2) + } else { + 2.55_f32.min(spec.wall_height() - 0.1) + }; + let z = -spec.length / 2.0 - depth / 2.0; + mesh.closed_box( + Vec3::new(0.0, y, z), + Vec3::new(width, 0.16, depth), + [0.72, 0.75, 0.76, 1.0], + ); + for x in [-width / 2.0 + 0.18, width / 2.0 - 0.18] { + if spec.use_kind == BuildingUse::Industrial { + // Cantilever beams leave every loading lane unobstructed. + mesh.closed_box( + Vec3::new(x, y - 0.16, z + 0.18), + Vec3::new(0.12, 0.12, depth - 0.30), + [0.62, 0.65, 0.67, 1.0], + ); + } else { + mesh.closed_box( + Vec3::new(x, y * 0.5, z - depth / 2.0 + 0.12), + Vec3::new(0.12, y, 0.12), + [0.62, 0.65, 0.67, 1.0], + ); + } + } +} + +fn has_balcony(spec: &BuildingSpec, floor: usize) -> bool { + floor > 0 && spec.balconies && floor % spec.balcony_every.max(1) as usize == 0 +} + +/// A centred balcony must end on bay boundaries, not through the windows at +/// arbitrary percentages of the facade. Matching the span parity to the total +/// bay count is what keeps both ends equally far from the nearest opening. +fn balcony_width(spec: &BuildingSpec) -> f32 { + let bays = (spec.width / spec.window_spacing).floor().max(1.0) as usize; + let desired = bays as f32 * 0.5; + let span_bays = (1..=bays) + .filter(|candidate| candidate % 2 == bays % 2) + .min_by(|a, b| ((*a as f32 - desired).abs()).total_cmp(&(*b as f32 - desired).abs())) + .unwrap_or(1); + spec.width / bays as f32 * span_bays as f32 +} + +fn entrance_layout(spec: &BuildingSpec) -> (Vec, f32, f32) { + let height = (if spec.use_kind == BuildingUse::Industrial { + spec.floor_height * 0.78 + } else { + 2.15 + }) + .min(spec.wall_height() - 0.1); + if spec.use_kind != BuildingUse::Industrial { + return (vec![0.0], 1.25, height); + } + let count = spec.loading_doors.clamp(1, 10) as usize; + let width = if count == 1 { + (spec.width * 0.32).min(7.0) + } else { + (spec.width / (count as f32 + 1.0) * 0.62).clamp(2.6, 5.5) + }; + let centers = (0..count) + .map(|door| -spec.width / 2.0 + spec.width * (door as f32 + 1.0) / (count as f32 + 1.0)) + .collect(); + (centers, width, height) +} + +fn add_openings(g: &mut Geometry, spec: &BuildingSpec, lod: usize) { + let h = spec.wall_height(); + let inset = 0.025; + let (door_centers, door_w, door_h) = entrance_layout(spec); + let sides = [ + (spec.width, -spec.length / 2.0 - inset, false), + (spec.length, -spec.width / 2.0 - inset, true), + ]; + for (side_index, (span, plane, side)) in sides.into_iter().enumerate() { + let bays = (span / spec.window_spacing).floor().max(1.0) as usize; + for mirror in 0..2 { + for floor in 0..spec.floors as usize { + for bay in 0..bays { + let center = -span / 2.0 + span * (bay as f32 + 0.5) / bays as f32; + let y = floor as f32 * spec.floor_height + spec.floor_height * 0.56; + let (ww, wh) = if spec.use_kind == BuildingUse::Commercial && floor == 0 { + (spec.window_width * 1.35, spec.window_height * 1.25) + } else { + (spec.window_width, spec.window_height) + }; + // The opening owns its whole rectangle, not just the bay + // containing its centre. Wide industrial doors commonly + // span two or three window bays. + if side_index == 0 + && mirror == 0 + && door_centers + .iter() + .any(|door| (center - door).abs() < door_w / 2.0 + ww / 2.0 + 0.18) + && y - wh / 2.0 < door_h + 0.12 + { + continue; + } + if side_index == 0 + && mirror == 0 + && has_balcony(spec, floor) + && center.abs() < 0.5 + ww / 2.0 + 0.12 + { + continue; + } + let signed_plane = if mirror == 0 { plane } else { -plane }; + let p = window_quad( + center, + y, + ww.min(span / bays as f32 * 0.82), + wh.min(h * 0.8), + signed_plane, + side, + mirror == 0, + ); + g.glass.quad( + p.0, + p.1, + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [1.0; 4], + ); + if lit( + spec.seed, + side_index, + mirror, + floor, + bay, + spec.lit_window_share, + ) { + let mut lp = p.0; + for point in &mut lp { + point[0] += p.1[0] * inset; + point[1] += p.1[1] * inset; + point[2] += p.1[2] * inset; + } + g.lit.quad( + lp, + p.1, + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [1.0; 4], + ); + } + if lod == 0 { + add_window_trim( + &mut g.trim, + center, + y, + ww, + wh, + signed_plane, + side, + mirror == 0, + ); + } + } + } + } + } + let door_plane = -spec.length / 2.0 - 0.055; + for center in &door_centers { + g.door.quad( + [ + [center - door_w / 2.0, 0.02, door_plane], + [center - door_w / 2.0, door_h, door_plane], + [center + door_w / 2.0, door_h, door_plane], + [center + door_w / 2.0, 0.02, door_plane], + ], + [0.0, 0.0, -1.0], + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [1.0; 4], + ); + } + if lod == 0 && spec.use_kind == BuildingUse::Industrial { + let frame = 0.11; + let frame_color = [0.42, 0.45, 0.47, 1.0]; + for center in &door_centers { + for x in [center - door_w / 2.0, center + door_w / 2.0] { + g.trim.box_( + Vec3::new(x, door_h / 2.0, door_plane - 0.015), + Vec3::new(frame, door_h, frame), + frame_color, + ); + } + g.trim.box_( + Vec3::new(*center, door_h, door_plane - 0.015), + Vec3::new(door_w + frame, frame, frame), + frame_color, + ); + let panels = (door_h / 0.9).floor().max(1.0) as usize; + for panel in 1..panels { + g.trim.box_( + Vec3::new( + *center, + door_h * panel as f32 / panels as f32, + door_plane - 0.015, + ), + Vec3::new(door_w, frame * 0.45, frame), + frame_color, + ); + } + } + } + if spec.balconies { + let balcony_door_w = 1.0; + let balcony_door_h = 2.15_f32.min(spec.floor_height - 0.25); + for floor in 1..spec.floors as usize { + if !has_balcony(spec, floor) { + continue; + } + let bottom = floor as f32 * spec.floor_height + 0.13; + g.glass.quad( + [ + [-balcony_door_w / 2.0, bottom, door_plane], + [-balcony_door_w / 2.0, bottom + balcony_door_h, door_plane], + [balcony_door_w / 2.0, bottom + balcony_door_h, door_plane], + [balcony_door_w / 2.0, bottom, door_plane], + ], + [0.0, 0.0, -1.0], + [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + [1.0; 4], + ); + if lod == 0 { + let frame = 0.075; + let center_y = bottom + balcony_door_h / 2.0; + for x in [-balcony_door_w / 2.0, balcony_door_w / 2.0] { + g.trim.box_( + Vec3::new(x, center_y, door_plane - 0.012), + Vec3::new(frame, balcony_door_h + frame, frame), + [1.0; 4], + ); + } + g.trim.box_( + Vec3::new(0.0, bottom + balcony_door_h, door_plane - 0.012), + Vec3::new(balcony_door_w + frame, frame, frame), + [1.0; 4], + ); + for y in [bottom, bottom + balcony_door_h * 0.46] { + g.trim.box_( + Vec3::new(0.0, y, door_plane - 0.012), + Vec3::new(balcony_door_w + frame, frame, frame), + [1.0; 4], + ); + } + } + } + } +} + +fn window_quad( + c: f32, + y: f32, + w: f32, + h: f32, + plane: f32, + side: bool, + near: bool, +) -> ([[f32; 3]; 4], [f32; 3]) { + let sign = if near { -1.0 } else { 1.0 }; + let (mut points, normal) = if side { + ( + [ + [plane, y - h / 2.0, c + w / 2.0], + [plane, y + h / 2.0, c + w / 2.0], + [plane, y + h / 2.0, c - w / 2.0], + [plane, y - h / 2.0, c - w / 2.0], + ], + [sign, 0.0, 0.0], + ) + } else { + ( + [ + [c - w / 2.0, y - h / 2.0, plane], + [c - w / 2.0, y + h / 2.0, plane], + [c + w / 2.0, y + h / 2.0, plane], + [c + w / 2.0, y - h / 2.0, plane], + ], + [0.0, 0.0, sign], + ) + }; + // The base winding faces the negative side on both axes. Reverse the + // opposite wall so back-face culling and the authored normal agree. + if !near { + points.swap(1, 3); + } + (points, normal) +} + +fn add_window_trim( + mesh: &mut MeshData, + c: f32, + y: f32, + w: f32, + h: f32, + plane: f32, + side: bool, + near: bool, +) { + let bar = 0.075; + if side { + let x = plane; + mesh.box_( + Vec3::new(x, y, c - w / 2.0), + Vec3::new(bar, h + bar, bar), + [1.0; 4], + ); + mesh.box_( + Vec3::new(x, y, c + w / 2.0), + Vec3::new(bar, h + bar, bar), + [1.0; 4], + ); + mesh.box_( + Vec3::new(x, y - h / 2.0, c), + Vec3::new(bar, bar, w), + [1.0; 4], + ); + mesh.box_( + Vec3::new(x, y + h / 2.0, c), + Vec3::new(bar, bar, w), + [1.0; 4], + ); + mesh.box_( + Vec3::new(x, y, c), + Vec3::new(bar, h + bar, bar * 0.72), + [1.0; 4], + ); + mesh.box_(Vec3::new(x, y, c), Vec3::new(bar, bar * 0.72, w), [1.0; 4]); + } else { + let z = plane; + mesh.box_( + Vec3::new(c - w / 2.0, y, z), + Vec3::new(bar, h + bar, bar), + [1.0; 4], + ); + mesh.box_( + Vec3::new(c + w / 2.0, y, z), + Vec3::new(bar, h + bar, bar), + [1.0; 4], + ); + mesh.box_( + Vec3::new(c, y - h / 2.0, z), + Vec3::new(w, bar, bar), + [1.0; 4], + ); + mesh.box_( + Vec3::new(c, y + h / 2.0, z), + Vec3::new(w, bar, bar), + [1.0; 4], + ); + mesh.box_( + Vec3::new(c, y, z), + Vec3::new(bar * 0.72, h + bar, bar), + [1.0; 4], + ); + mesh.box_(Vec3::new(c, y, z), Vec3::new(w, bar * 0.72, bar), [1.0; 4]); + } + let _ = near; +} + +fn add_balconies(mesh: &mut MeshData, spec: &BuildingSpec) { + let front = -spec.length / 2.0 - spec.balcony_depth / 2.0; + for floor in 1..spec.floors { + if !has_balcony(spec, floor as usize) { + continue; + } + let y = floor as f32 * spec.floor_height + 0.12; + let width = balcony_width(spec); + mesh.closed_box( + Vec3::new(0.0, y, front), + Vec3::new(width, 0.18, spec.balcony_depth), + [1.0; 4], + ); + mesh.closed_box( + Vec3::new(0.0, y + 1.0, front - spec.balcony_depth / 2.0), + Vec3::new(width, 0.075, 0.075), + [1.0; 4], + ); + // Use an odd number of intervals so no upright stands on the centre + // line directly in front of the balcony door. + let mut intervals = (width / 1.2).ceil().max(1.0) as usize; + if intervals % 2 == 0 { + intervals += 1; + } + for post in 0..=intervals { + let x = -width / 2.0 + width * post as f32 / intervals as f32; + mesh.closed_box( + Vec3::new(x, y + 0.55, front - spec.balcony_depth / 2.0), + Vec3::new(0.055, 0.95, 0.055), + [1.0; 4], + ); + } + // Close both short sides. The front-only railing looks plausible + // head-on but becomes an unsafe floating comb from every side view. + for x in [-width / 2.0, width / 2.0] { + mesh.closed_box( + Vec3::new(x, y + 1.0, front), + Vec3::new(0.075, 0.075, spec.balcony_depth), + [1.0; 4], + ); + for z in [front, front + spec.balcony_depth / 2.0] { + mesh.closed_box( + Vec3::new(x, y + 0.55, z), + Vec3::new(0.055, 0.95, 0.055), + [1.0; 4], + ); + } + } + } +} + +fn lit(seed: u64, side: usize, mirror: usize, floor: usize, bay: usize, share: f32) -> bool { + let mut x = seed + ^ (side as u64).wrapping_mul(0x9e3779b97f4a7c15) + ^ (mirror as u64).wrapping_mul(0xbf58476d1ce4e5b9) + ^ (floor as u64).wrapping_mul(0x94d049bb133111eb) + ^ bay as u64; + x ^= x >> 30; + x = x.wrapping_mul(0xbf58476d1ce4e5b9); + x ^= x >> 27; + x = x.wrapping_mul(0x94d049bb133111eb); + ((x ^ (x >> 31)) as u32) as f32 / (u32::MAX as f32) < share +} + +#[derive(Clone, Copy)] +struct SurfaceSample { + albedo: [u8; 3], + height: f32, + roughness: u8, + metallic: u8, +} + +const SURFACE_SIZE: i32 = 512; +const SURFACE_WORLD_SIZE: f32 = 8.0; +const SURFACE_UV_DENSITY: f32 = 1.0 / SURFACE_WORLD_SIZE; + +fn surface_hash(kind: usize, x: i32, y: i32, salt: u64) -> u64 { + let x = x as u64; + let y = y as u64; + let mut hash = x.wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ y.wrapping_mul(0xC2B2_AE3D_27D4_EB4F) + ^ (kind as u64).wrapping_mul(0x1656_67B1_9E37_79F9) + ^ salt.wrapping_mul(0xD6E8_FEB8_6659_FD93); + hash = (hash ^ (hash >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + hash = (hash ^ (hash >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + hash ^ (hash >> 31) +} + +fn random_signed(kind: usize, x: i32, y: i32, salt: u64) -> f32 { + let bits = (surface_hash(kind, x, y, salt) >> 40) as u32; + bits as f32 / 0x00ff_ffff as f32 * 2.0 - 1.0 +} + +/// Seamless interpolated noise. Every selected cell size divides the texture, +/// so both value and gradient agree across the repeated border. +fn smooth_noise(kind: usize, x: i32, y: i32, cell: i32, salt: u64) -> f32 { + let cells = SURFACE_SIZE / cell; + let x = x.rem_euclid(SURFACE_SIZE); + let y = y.rem_euclid(SURFACE_SIZE); + let gx = x / cell; + let gy = y / cell; + let tx = (x % cell) as f32 / cell as f32; + let ty = (y % cell) as f32 / cell as f32; + let sx = tx * tx * (3.0 - 2.0 * tx); + let sy = ty * ty * (3.0 - 2.0 * ty); + let sample = |dx: i32, dy: i32| { + random_signed( + kind, + (gx + dx).rem_euclid(cells), + (gy + dy).rem_euclid(cells), + salt, + ) + }; + let top = sample(0, 0) + (sample(1, 0) - sample(0, 0)) * sx; + let bottom = sample(0, 1) + (sample(1, 1) - sample(0, 1)) * sx; + top + (bottom - top) * sy +} + +fn surface_color(base: (i16, i16, i16), light: f32, warm: f32) -> [u8; 3] { + let channel = |base: i16, offset: f32| (base as f32 + offset).round().clamp(0.0, 255.0) as u8; + [ + channel(base.0, light + warm), + channel(base.1, light + warm * 0.25), + channel(base.2, light - warm * 0.75), + ] +} + +/// One texel of a seamless technical surface definition. Colour, relief and +/// material response all use the same joint mask, so mortar can never drift +/// away from the normal map again. +fn surface_sample(kind: usize, x: i32, y: i32) -> SurfaceSample { + let x = x.rem_euclid(SURFACE_SIZE); + let y = y.rem_euclid(SURFACE_SIZE); + let grain = random_signed(kind, x, y, 1); + let broad = smooth_noise(kind, x, y, 256, 2); + let medium = smooth_noise(kind, x, y, 64, 3); + match kind { + 0 => SurfaceSample { + albedo: surface_color( + (226, 222, 211), + broad * 5.0 + medium * 2.2 + grain * 1.1, + broad * 1.2, + ), + height: 128.0 + smooth_noise(kind, x, y, 16, 4) * 1.5 + grain * 0.35, + roughness: (226.0 + medium * 5.0).clamp(205.0, 240.0) as u8, + metallic: 0, + }, + 1 | 2 => { + const BRICK_W: i32 = 16; + const BRICK_H: i32 = 8; + let row = y / BRICK_H; + let shifted = x + (row & 1) * (BRICK_W / 2); + let local_x = shifted.rem_euclid(BRICK_W); + let local_y = y.rem_euclid(BRICK_H); + let brick = (shifted / BRICK_W).rem_euclid(SURFACE_SIZE / BRICK_W); + let mortar = local_y == 0 || local_x == 0; + let unit = random_signed(kind, brick, row, 20); + let edge = local_x + .min(BRICK_W - local_x) + .min(local_y.min(BRICK_H - local_y)) as f32; + let (mortar_color, brick_color) = if kind == 1 { + ((166, 158, 146), (174, 72, 48)) + } else { + ((181, 174, 157), (202, 158, 83)) + }; + SurfaceSample { + albedo: if mortar { + surface_color(mortar_color, broad * 4.0 + grain * 0.8, 0.0) + } else { + surface_color( + brick_color, + unit * 11.0 + broad * 5.0 + medium * 2.0 + grain * 1.2, + unit * 3.0, + ) + }, + height: if mortar { + 112.0 + medium + } else { + 128.0 + edge.min(2.0) * 2.1 + grain * 0.25 + }, + roughness: if mortar { + 238 + } else { + (204.0 + unit * 8.0).clamp(188.0, 220.0) as u8 + }, + metallic: 0, + } + } + 3 => { + let pore = random_signed(kind, x, y, 31) > 0.975; + SurfaceSample { + albedo: surface_color( + (183, 186, 185), + broad * 7.0 + medium * 3.0 + grain * 1.4 - if pore { 9.0 } else { 0.0 }, + broad * 0.6, + ), + height: 128.0 + smooth_noise(kind, x, y, 16, 32) * 1.4 + grain * 0.45 + - if pore { 4.0 } else { 0.0 }, + roughness: (232.0 + medium * 7.0).clamp(214.0, 246.0) as u8, + metallic: 0, + } + } + 4 => { + const PANEL: i32 = 32; + let local = x.rem_euclid(PANEL); + let distance = local.min(PANEL - local); + let seam = distance < 2; + let panel = x / PANEL; + let unit = random_signed(kind, panel, 0, 40); + SurfaceSample { + albedo: surface_color( + if seam { + (119, 126, 130) + } else { + (148, 154, 157) + }, + unit * 4.0 + broad * 3.5 + medium * 1.5 + grain * 0.7, + broad * 0.5, + ), + height: if seam { + 141.0 - distance as f32 * 2.0 + } else { + 128.0 + medium * 0.6 + grain * 0.15 + }, + roughness: (158.0 + unit * 10.0).clamp(135.0, 180.0) as u8, + metallic: 176, + } + } + 5 => { + const TILE: i32 = 16; + let row = y / TILE; + let shifted = x + (row & 1) * (TILE / 2); + let local_x = shifted.rem_euclid(TILE); + let local_y = y.rem_euclid(TILE); + let tile = (shifted / TILE).rem_euclid(SURFACE_SIZE / TILE); + let joint = local_y < 2 || local_x == 0; + let unit = random_signed(kind, tile, row, 50); + let crown = 1.0 - ((local_x as f32 + 0.5) / TILE as f32 * 2.0 - 1.0).abs(); + SurfaceSample { + albedo: surface_color( + if joint { (105, 50, 38) } else { (164, 77, 52) }, + unit * 10.0 + broad * 5.0 + medium * 2.0 + grain * 1.0, + unit * 2.5, + ), + height: if joint { + 114.0 + } else { + 128.0 + crown * 5.5 + (local_y as f32 / TILE as f32) * 1.2 + }, + roughness: (192.0 + unit * 10.0).clamp(170.0, 212.0) as u8, + metallic: 0, + } + } + 6 => { + const SLATE_W: i32 = 32; + const SLATE_H: i32 = 16; + let row = y / SLATE_H; + let shifted = x + (row & 1) * (SLATE_W / 2); + let local_x = shifted.rem_euclid(SLATE_W); + let local_y = y.rem_euclid(SLATE_H); + let tile = (shifted / SLATE_W).rem_euclid(SURFACE_SIZE / SLATE_W); + let joint = local_y < 2 || local_x == 0; + let unit = random_signed(kind, tile, row, 60); + SurfaceSample { + albedo: surface_color( + if joint { (54, 59, 63) } else { (79, 86, 91) }, + unit * 8.0 + broad * 4.0 + medium * 1.5 + grain * 0.8, + -unit, + ), + height: if joint { + 116.0 + } else { + 128.0 + (local_y as f32 / SLATE_H as f32) * 2.5 + grain * 0.2 + }, + roughness: (211.0 + unit * 9.0).clamp(190.0, 230.0) as u8, + metallic: 0, + } + } + 7 => { + const PANEL: i32 = 32; + let local = x.rem_euclid(PANEL); + let distance = local.min(PANEL - local); + let seam = distance < 2; + let panel = x / PANEL; + let unit = random_signed(kind, panel, 0, 70); + SurfaceSample { + albedo: surface_color( + if seam { + (122, 130, 134) + } else { + (146, 154, 158) + }, + unit * 4.0 + broad * 4.0 + medium * 1.5 + grain * 0.6, + broad * 0.4, + ), + height: if seam { + 142.0 - distance as f32 * 2.5 + } else { + 128.0 + medium * 0.45 + grain * 0.12 + }, + roughness: (142.0 + unit * 10.0).clamp(120.0, 165.0) as u8, + metallic: 218, + } + } + _ => { + let roll_seam = x.rem_euclid(64) < 2; + let lap = y.rem_euclid(128) < 2; + SurfaceSample { + albedo: surface_color( + (68, 70, 68), + broad * 7.0 + medium * 3.0 + grain * 1.8 + - if roll_seam || lap { 3.0 } else { 0.0 }, + broad * 0.3, + ), + height: 128.0 + grain * 0.55 + if roll_seam || lap { 1.8 } else { 0.0 }, + roughness: (244.0 + medium * 5.0).clamp(232.0, 252.0) as u8, + metallic: 0, + } + } + } +} + +/// Builds the registered PBR channels from one shared sample field. Besides +/// being substantially faster than evaluating the procedural material three +/// times, this makes channel drift structurally impossible. +fn surface_textures(kind: usize) -> (Image, Image, Image) { + const S: u32 = SURFACE_SIZE as u32; + let samples = (0..S) + .flat_map(|y| (0..S).map(move |x| surface_sample(kind, x as i32, y as i32))) + .collect::>(); + let at = |x: i32, y: i32| { + samples[(y.rem_euclid(SURFACE_SIZE) * SURFACE_SIZE + x.rem_euclid(SURFACE_SIZE)) as usize] + }; + let capacity = (S * S * 4) as usize; + let mut albedo = Vec::with_capacity(capacity); + let mut normal = Vec::with_capacity(capacity); + let mut metal_rough = Vec::with_capacity(capacity); + for y in 0..S { + for x in 0..S { + let sample = at(x as i32, y as i32); + albedo.extend_from_slice(&[sample.albedo[0], sample.albedo[1], sample.albedo[2], 255]); + let dx = at(x as i32 + 1, y as i32).height - at(x as i32 - 1, y as i32).height; + let dy = at(x as i32, y as i32 + 1).height - at(x as i32, y as i32 - 1).height; + let direction = Vec3::new(-dx * 0.025, -dy * 0.025, 1.0).normalize(); + let encode = |channel: f32| ((channel * 0.5 + 0.5) * 255.0) as u8; + normal.extend_from_slice(&[ + encode(direction.x), + encode(direction.y), + encode(direction.z), + 255, + ]); + // glTF convention: G = roughness, B = metallic. + metal_rough.extend_from_slice(&[255, sample.roughness, sample.metallic, 255]); + } + } + ( + surface_image(albedo, true), + surface_image(normal, false), + surface_image(metal_rough, false), + ) +} + +fn surface_image(data: Vec, srgb: bool) -> Image { + const S: u32 = SURFACE_SIZE as u32; + let mut image = Image::new( + Extent3d { + width: S, + height: S, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + data, + if srgb { + TextureFormat::Rgba8UnormSrgb + } else { + TextureFormat::Rgba8Unorm + }, + RenderAssetUsages::default(), + ); + let base = image.data.take().unwrap_or_default(); + let (data, mip_level_count) = crate::with_mipmaps(base, S, S, None); + image.data = Some(data); + image.texture_descriptor.mip_level_count = mip_level_count; + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + anisotropy_clamp: 16, + ..default() + }); + image +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_winding_matches_normals(mesh: &MeshData) { + for triangle in mesh.indices.chunks_exact(3) { + let a = Vec3::from_array(mesh.positions[triangle[0] as usize]); + let b = Vec3::from_array(mesh.positions[triangle[1] as usize]); + let c = Vec3::from_array(mesh.positions[triangle[2] as usize]); + let face = (b - a).cross(c - a).normalize_or_zero(); + let normal = Vec3::from_array(mesh.normals[triangle[0] as usize]); + assert!( + face.dot(normal) > 0.999, + "triangle winding and vertex normal disagree: {face:?} / {normal:?}" + ); + } + } + + #[test] + fn all_roofs_and_lods_make_meshes() { + for roof_style in [ + RoofStyle::Gable, + RoofStyle::Hip, + RoofStyle::Flat, + RoofStyle::Shed, + RoofStyle::Mansard, + RoofStyle::Sawtooth, + ] { + let spec = BuildingSpec { + roof_style, + ..Default::default() + }; + for lod in 0..3 { + let geometry = geometry(&spec, lod); + assert!(!geometry.facade.positions.is_empty()); + assert!(!geometry.roof.positions.is_empty()); + } + } + } + + #[test] + fn generated_faces_have_matching_winding_and_normals() { + for roof_style in [ + RoofStyle::Gable, + RoofStyle::Hip, + RoofStyle::Flat, + RoofStyle::Shed, + RoofStyle::Mansard, + RoofStyle::Sawtooth, + ] { + let geometry = geometry( + &BuildingSpec { + roof_style, + balconies: true, + ..Default::default() + }, + 0, + ); + for mesh in [ + &geometry.facade, + &geometry.roof, + &geometry.glass, + &geometry.lit, + &geometry.trim, + &geometry.balcony, + &geometry.detail, + &geometry.door, + ] { + assert_winding_matches_normals(mesh); + } + } + } + + #[test] + fn pitched_roof_uvs_run_along_eave_then_up_slope() { + let geometry = geometry( + &BuildingSpec { + roof_style: RoofStyle::Gable, + ..Default::default() + }, + 0, + ); + let uv = &geometry.roof.uvs[..4]; + assert_eq!(uv[0][1], uv[1][1]); + assert!(uv[1][0] > uv[0][0]); + assert_eq!(uv[1][0], uv[2][0]); + assert!(uv[2][1] > uv[1][1]); + } + + #[test] + fn pitched_roofs_have_undersides_and_outer_fascia() { + for roof_style in [ + RoofStyle::Gable, + RoofStyle::Hip, + RoofStyle::Shed, + RoofStyle::Mansard, + RoofStyle::Sawtooth, + ] { + let roof = geometry( + &BuildingSpec { + roof_style, + ..Default::default() + }, + 0, + ) + .roof; + assert!(roof.normals.iter().any(|normal| normal[1] < -0.2)); + assert!(roof.normals.iter().any(|normal| normal[1].abs() < 0.001)); + assert_winding_matches_normals(&roof); + } + } + + #[test] + fn roof_overhangs_continue_through_the_wall_crown() { + let at_x = + |a: [f32; 3], b: [f32; 3], x: f32| a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0]); + for roof_style in [RoofStyle::Gable, RoofStyle::Mansard] { + let spec = BuildingSpec { + roof_style, + ..Default::default() + }; + let roof = geometry(&spec, 0).roof; + assert!( + (at_x(roof.positions[0], roof.positions[3], -spec.width / 2.0) + - spec.wall_height()) + .abs() + < 0.0001 + ); + } + + let spec = BuildingSpec { + roof_style: RoofStyle::Shed, + ..Default::default() + }; + let roof = geometry(&spec, 0).roof; + let low = at_x(roof.positions[0], roof.positions[3], -spec.width / 2.0); + let high = at_x(roof.positions[0], roof.positions[3], spec.width / 2.0); + assert!((low - spec.wall_height()).abs() < 0.0001); + assert!((high - spec.wall_height() - spec.roof_height).abs() < 0.0001); + } + + #[test] + fn flat_roof_texture_is_not_stretched_over_the_whole_building() { + let spec = BuildingSpec { + roof_style: RoofStyle::Flat, + ..Default::default() + }; + let roof = geometry(&spec, 0).roof; + let top = &roof.uvs[16..20]; + assert!(top.iter().map(|uv| uv[0]).fold(0.0_f32, f32::max) > 1.0); + assert!(top.iter().map(|uv| uv[1]).fold(0.0_f32, f32::max) > 1.0); + assert!( + roof.normals + .iter() + .any(|normal| *normal == [0.0, -1.0, 0.0]) + ); + assert!(roof.positions.iter().any(|point| { + (point[1] - spec.wall_height()).abs() < 0.0001 && point[0].abs() > spec.width / 2.0 + })); + } + + #[test] + fn shed_roof_high_side_is_closed_to_the_roofline() { + let spec = BuildingSpec { + roof_style: RoofStyle::Shed, + ..Default::default() + }; + let facade = geometry(&spec, 0).facade; + let roofline = spec.wall_height() + spec.roof_height; + let high_corners = facade + .positions + .iter() + .filter(|p| { + (p[0] - spec.width / 2.0).abs() < 0.0001 + && (p[1] - roofline).abs() < 0.0001 + && (p[2].abs() - spec.length / 2.0).abs() < 0.0001 + }) + .count(); + assert!(high_corners >= 2); + } + + #[test] + fn hip_roof_has_a_ridge_and_four_grat_caps() { + let spec = BuildingSpec { + roof_style: RoofStyle::Hip, + ..Default::default() + }; + let mut caps = MeshData::default(); + add_roof_caps( + &mut caps, + &spec, + spec.width, + spec.length, + spec.wall_height(), + ); + assert_eq!(caps.indices.len() / 6, 5 * 2); + assert!(caps.normals.iter().any(|normal| normal[1] > 0.5)); + assert_winding_matches_normals(&caps); + } + + #[test] + fn industrial_gate_reserves_every_window_bay_it_covers() { + let spec = BuildingSpec { + use_kind: BuildingUse::Industrial, + width: 48.0, + length: 27.0, + floors: 1, + floor_height: 6.2, + window_spacing: 6.0, + window_width: 2.4, + window_height: 2.0, + loading_doors: 4, + ..Default::default() + }; + let geometry = geometry(&spec, 0); + let (door_centers, door_w, door_h) = entrance_layout(&spec); + assert_eq!(geometry.door.positions.len(), 4 * door_centers.len()); + for window in geometry.glass.positions.chunks_exact(4) { + if window.iter().all(|p| p[2] < -spec.length / 2.0) { + let left = window.iter().map(|p| p[0]).fold(f32::INFINITY, f32::min); + let right = window + .iter() + .map(|p| p[0]) + .fold(f32::NEG_INFINITY, f32::max); + let bottom = window.iter().map(|p| p[1]).fold(f32::INFINITY, f32::min); + let overlaps_gate = door_centers + .iter() + .any(|center| right > center - door_w / 2.0 && left < center + door_w / 2.0) + && bottom < door_h; + assert!(!overlaps_gate); + } + } + } + + #[test] + fn sawtooth_factory_has_closed_roof_and_north_lights() { + let spec = BuildingSpec { + use_kind: BuildingUse::Industrial, + width: 64.0, + length: 38.0, + floors: 1, + floor_height: 7.5, + roof_style: RoofStyle::Sawtooth, + roof_height: 3.2, + skylights: 4, + ..Default::default() + }; + let geometry = geometry(&spec, 0); + assert!(!geometry.roof.positions.is_empty()); + assert!(!geometry.glass.positions.is_empty()); + assert!( + geometry + .facade + .positions + .iter() + .any(|point| point[1] > spec.wall_height() + 3.0) + ); + assert_winding_matches_normals(&geometry.roof); + assert_winding_matches_normals(&geometry.glass); + } + + #[test] + fn roof_accessories_are_detailed_and_lod_bounded() { + let spec = BuildingSpec { + chimneys: 2, + roof_vents: 3, + skylights: 4, + rain_gutters: true, + entrance_canopy: true, + ..Default::default() + }; + let near = geometry(&spec, 0); + assert!(!near.detail.positions.is_empty()); + assert!( + near.facade + .positions + .iter() + .any(|point| point[1] > spec.wall_height()) + ); + assert_winding_matches_normals(&near.detail); + let far = geometry(&spec, 2); + assert!(far.detail.positions.is_empty()); + } + + #[test] + fn balcony_railings_close_the_front_and_both_sides() { + let spec = BuildingSpec { + floors: 2, + width: 12.0, + length: 9.0, + balcony_every: 1, + balcony_depth: 1.2, + ..Default::default() + }; + let mut balcony = MeshData::default(); + add_balconies(&mut balcony, &spec); + let width = balcony_width(&spec); + let mut intervals = (width / 1.2).ceil().max(1.0) as usize; + if intervals % 2 == 0 { + intervals += 1; + } + // Slab, front rail, adaptive front posts, two side rails and four + // side posts. Every exposed balcony component is a closed six-face box. + let boxes = 1 + 1 + intervals + 1 + 2 + 4; + assert_eq!(balcony.positions.len(), boxes * 6 * 4); + assert!( + balcony + .normals + .iter() + .any(|normal| *normal == [0.0, -1.0, 0.0]) + ); + assert_winding_matches_normals(&balcony); + } + + #[test] + fn explicit_balconies_are_not_limited_to_residential_buildings() { + let geometry = geometry( + &BuildingSpec { + use_kind: BuildingUse::Commercial, + floors: 2, + balconies: true, + balcony_every: 1, + ..Default::default() + }, + 0, + ); + assert!(!geometry.balcony.positions.is_empty()); + } + + #[test] + fn balcony_edges_land_between_window_bays() { + let spec = BuildingSpec { + width: 15.0, + window_spacing: 2.6, + window_width: 1.25, + ..Default::default() + }; + let bays = (spec.width / spec.window_spacing).floor() as usize; + let width = balcony_width(&spec); + for bay in 0..bays { + let center = -spec.width / 2.0 + spec.width * (bay as f32 + 0.5) / bays as f32; + for edge in [-width / 2.0, width / 2.0] { + assert!( + (center - edge).abs() > spec.window_width / 2.0 + 0.1, + "balcony edge {edge} intersects window bay at {center}" + ); + } + } + } + + #[test] + fn residential_balconies_replace_central_windows_with_doors() { + let spec = BuildingSpec { + use_kind: BuildingUse::Residential, + floors: 3, + width: 15.0, + length: 11.0, + window_spacing: 2.6, + balconies: true, + balcony_every: 1, + ..Default::default() + }; + let geometry = geometry(&spec, 0); + + assert_eq!(geometry.door.positions.len(), 4); + let balcony_doors = geometry + .glass + .positions + .chunks_exact(4) + .filter(|pane| { + let is_front = pane.iter().all(|point| point[2] < -spec.length / 2.0); + let center_x = pane.iter().map(|point| point[0]).sum::() / 4.0; + let bottom = pane + .iter() + .map(|point| point[1]) + .fold(f32::INFINITY, f32::min); + let top = pane + .iter() + .map(|point| point[1]) + .fold(f32::NEG_INFINITY, f32::max); + is_front + && center_x.abs() < 0.1 + && bottom > spec.floor_height + && top - bottom > spec.window_height + }) + .count(); + assert_eq!(balcony_doors, 2); + } + + #[test] + fn all_surface_channels_wrap_on_the_same_seam() { + for kind in 0..9 { + for y in 0..SURFACE_SIZE { + let before = surface_sample(kind, -1, y); + let end = surface_sample(kind, SURFACE_SIZE - 1, y); + assert_eq!(before.albedo, end.albedo); + assert_eq!(before.height, end.height); + assert_eq!(before.roughness, end.roughness); + assert_eq!(before.metallic, end.metallic); + + let start = surface_sample(kind, 0, y); + let after = surface_sample(kind, SURFACE_SIZE, y); + assert_eq!(start.albedo, after.albedo); + assert_eq!(start.height, after.height); + assert_eq!(start.roughness, after.roughness); + assert_eq!(start.metallic, after.metallic); + } + } + } + + #[test] + fn procedural_surfaces_have_mips_and_anisotropic_filtering() { + let image = surface_textures(7).0; + assert_eq!(image.texture_descriptor.size.width, 512); + assert_eq!(image.texture_descriptor.size.height, 512); + assert_eq!(image.texture_descriptor.mip_level_count, 10); + let ImageSampler::Descriptor(sampler) = image.sampler else { + panic!("procedural surface needs an explicit tiling sampler"); + }; + assert_eq!(sampler.anisotropy_clamp, 16); + assert_eq!(sampler.mipmap_filter, ImageFilterMode::Linear); + } + + #[test] + fn old_one_metre_period_does_not_repeat() { + for kind in 0..9 { + let changed = (0..32) + .filter(|sample| { + let x = sample * 13 + 3; + let y = sample * 7 + 5; + surface_sample(kind, x, y).albedo != surface_sample(kind, x + 64, y).albedo + }) + .count(); + assert!( + changed >= 20, + "surface {kind} still repeats every 64 texels" + ); + } + } + + #[test] + fn night_pattern_is_stable_and_not_uniform() { + let pattern: Vec = (0..32).map(|bay| lit(42, 0, 0, 1, bay, 0.4)).collect(); + assert_eq!( + pattern, + (0..32) + .map(|bay| lit(42, 0, 0, 1, bay, 0.4)) + .collect::>() + ); + assert!(pattern.iter().any(|v| *v) && pattern.iter().any(|v| !*v)); + } +} diff --git a/crates/world-render/src/lib.rs b/crates/world-render/src/lib.rs index 15634d5..14e83a8 100644 --- a/crates/world-render/src/lib.rs +++ b/crates/world-render/src/lib.rs @@ -26,6 +26,7 @@ use sim_core::train::lod_level; use track_model::{Facing, TrackNetwork, TrackObject}; use world_coords::{EcefPos, EnuFrame, RenderOrigin}; +pub mod buildings; pub mod clouds; pub mod conductors; pub mod farmland; @@ -41,6 +42,7 @@ pub mod water; pub mod weather; pub mod windscreen; +pub use buildings::{BuildingAssets, BuildingIndex, spawn_buildings}; pub use conductors::{ConductorMark, ConductorMaterial, ConductorMaterials, spawn_conductors}; pub use farmland::{ CropExt, CropParams, FieldDraw, FieldMaterial, FieldMaterials, FieldSurface, spawn_fields, @@ -87,6 +89,7 @@ impl Plugin for WorldRenderPlugin { .add_plugins(conductors::plugin) .add_plugins(roads::plugin) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -559,6 +562,7 @@ fn colored(mut mesh: Mesh, color: [f32; 4]) -> Mesh { pub fn spawn_terrain_tile( commands: &mut Commands, meshes: &mut Assets, + building_assets: &mut BuildingAssets, material: &Handle, catalog: &WorldCatalog, tile: &TerrainTile, @@ -581,7 +585,10 @@ pub fn spawn_terrain_tile( &tile.walkways, catalog, ); - entity.id() + let id = entity.id(); + drop(entity); + buildings::spawn_buildings(commands, meshes, building_assets, id, &tile.buildings); + id } /// Places the trees, objects and people of a standing tile anew — the editor @@ -591,10 +598,13 @@ pub fn spawn_terrain_tile( /// who does this, shows no crowd and builds no ways. pub fn respawn_scatter( commands: &mut Commands, + meshes: &mut Assets, + building_assets: &mut BuildingAssets, tile: Entity, old: impl IntoIterator, trees: Vec, objects: &[SceneryInstance], + buildings: &[content::BakedBuilding], people: &[PersonInstance], catalog: &WorldCatalog, ) { @@ -610,6 +620,8 @@ pub fn respawn_scatter( return; }; scatter::spawn_scatter(&mut entity, trees, objects, people, &[], catalog); + drop(entity); + buildings::spawn_buildings(commands, meshes, building_assets, tile, buildings); } /// 3D models of the line's signals, resolved at setup — index = signal index. diff --git a/mods/example/lines/boerde.ron b/mods/example/lines/boerde.ron index 7bd0c14..ae4e1dd 100644 --- a/mods/example/lines/boerde.ron +++ b/mods/example/lines/boerde.ron @@ -381,6 +381,428 @@ snap_to_terrain: true, ), ], + // A compact sample settlement for the parametric building generator. The + // recipes deliberately cover the characteristic materials and roof forms + // while staying close enough to the start of the Bördefahrt to be seen. + buildings: [ + ( + edge: 0, + s: 220.0, + lateral_offset: -34.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 11.0, + length: 9.0, + floors: 2, + floor_height: 2.85, + roof_style: Gable, + roof_height: 3.0, + facade: Plaster, + facade_color: (0.88, 0.78, 0.58), + roof: ClayTile, + window_spacing: 2.5, + window_width: 1.2, + window_height: 1.4, + lit_window_share: 0.42, + balconies: true, + balcony_every: 1, + balcony_depth: 1.25, + seed: 101, + ), + ), + ( + edge: 0, + s: 285.0, + lateral_offset: -36.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 13.0, + length: 10.5, + floors: 2, + floor_height: 2.9, + roof_style: Hip, + roof_height: 2.8, + facade: RedBrick, + facade_color: (0.92, 0.90, 0.86), + roof: Slate, + window_spacing: 2.7, + window_width: 1.25, + window_height: 1.45, + lit_window_share: 0.35, + balconies: false, + balcony_every: 1, + balcony_depth: 1.2, + seed: 202, + ), + ), + ( + edge: 0, + s: 355.0, + lateral_offset: -35.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 12.0, + length: 9.5, + floors: 3, + floor_height: 2.85, + roof_style: Mansard, + roof_height: 3.4, + facade: YellowBrick, + facade_color: (0.96, 0.94, 0.88), + roof: Slate, + window_spacing: 2.45, + window_width: 1.15, + window_height: 1.4, + lit_window_share: 0.5, + balconies: true, + balcony_every: 1, + balcony_depth: 1.35, + seed: 303, + ), + ), + ( + edge: 0, + s: 260.0, + lateral_offset: 40.0, + yaw_deg: 270.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 15.0, + length: 11.0, + floors: 3, + floor_height: 2.9, + roof_style: Gable, + roof_height: 3.2, + facade: Plaster, + facade_color: (0.70, 0.82, 0.72), + roof: ClayTile, + window_spacing: 2.6, + window_width: 1.25, + window_height: 1.45, + lit_window_share: 0.38, + balconies: true, + balcony_every: 1, + balcony_depth: 1.5, + seed: 404, + ), + ), + ( + edge: 0, + s: 345.0, + lateral_offset: 43.0, + yaw_deg: 270.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 17.0, + length: 12.0, + floors: 4, + floor_height: 2.9, + roof_style: Flat, + roof_height: 0.0, + facade: Concrete, + facade_color: (0.86, 0.82, 0.72), + roof: Bitumen, + window_spacing: 2.65, + window_width: 1.3, + window_height: 1.5, + lit_window_share: 0.46, + balconies: true, + balcony_every: 1, + balcony_depth: 1.6, + seed: 505, + ), + ), + ( + edge: 0, + s: 610.0, + lateral_offset: -58.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Commercial, + width: 29.0, + length: 18.0, + floors: 3, + floor_height: 3.6, + roof_style: Flat, + roof_height: 0.0, + facade: Concrete, + facade_color: (0.72, 0.76, 0.80), + roof: Bitumen, + window_spacing: 3.2, + window_width: 1.8, + window_height: 1.8, + lit_window_share: 0.28, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + seed: 606, + ), + ), + ( + edge: 0, + s: 760.0, + lateral_offset: 72.0, + yaw_deg: 270.0, + snap_to_terrain: true, + spec: ( + use_kind: Commercial, + width: 25.0, + length: 16.0, + floors: 2, + floor_height: 3.8, + roof_style: Shed, + roof_height: 2.0, + facade: RedBrick, + facade_color: (0.94, 0.88, 0.80), + roof: StandingSeam, + window_spacing: 3.4, + window_width: 1.75, + window_height: 1.75, + lit_window_share: 0.32, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + seed: 707, + ), + ), + ( + edge: 0, + s: 930.0, + lateral_offset: -92.0, + yaw_deg: 91.0, + snap_to_terrain: true, + spec: ( + use_kind: Industrial, + width: 48.0, + length: 27.0, + floors: 1, + floor_height: 6.2, + roof_style: Shed, + roof_height: 3.0, + facade: MetalPanel, + facade_color: (0.72, 0.78, 0.80), + roof: StandingSeam, + window_spacing: 6.0, + window_width: 2.4, + window_height: 2.0, + lit_window_share: 0.2, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + seed: 808, + ), + ), + // Additional kit variants: traditional rural housing, a narrow town + // house and three distinct industrial scales. Their new roof details + // are deliberately visible near the line for visual regression checks. + ( + edge: 0, + s: 1060.0, + lateral_offset: -48.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 18.0, + length: 11.5, + floors: 2, + floor_height: 2.85, + roof_style: Gable, + roof_height: 4.2, + facade: RedBrick, + facade_color: (0.94, 0.91, 0.86), + roof: ClayTile, + window_spacing: 3.0, + window_width: 1.25, + window_height: 1.45, + lit_window_share: 0.35, + balconies: false, + balcony_every: 1, + balcony_depth: 1.2, + chimneys: 2, + roof_vents: 0, + skylights: 2, + rain_gutters: true, + entrance_canopy: false, + loading_doors: 1, + seed: 909, + ), + ), + ( + edge: 0, + s: 1140.0, + lateral_offset: 42.0, + yaw_deg: 270.0, + snap_to_terrain: true, + spec: ( + use_kind: Residential, + width: 9.0, + length: 13.0, + floors: 4, + floor_height: 3.0, + roof_style: Mansard, + roof_height: 3.6, + facade: Plaster, + facade_color: (0.78, 0.88, 0.82), + roof: Slate, + window_spacing: 2.25, + window_width: 1.2, + window_height: 1.45, + lit_window_share: 0.44, + balconies: false, + balcony_every: 1, + balcony_depth: 1.2, + chimneys: 2, + roof_vents: 1, + skylights: 3, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 1, + seed: 1010, + ), + ), + ( + edge: 0, + s: 1260.0, + lateral_offset: -68.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Industrial, + width: 26.0, + length: 18.0, + floors: 1, + floor_height: 5.4, + roof_style: Gable, + roof_height: 3.0, + facade: RedBrick, + facade_color: (0.92, 0.90, 0.85), + roof: StandingSeam, + window_spacing: 4.2, + window_width: 1.8, + window_height: 1.8, + lit_window_share: 0.18, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + chimneys: 1, + roof_vents: 2, + skylights: 3, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 2, + seed: 1111, + ), + ), + ( + edge: 0, + s: 1410.0, + lateral_offset: 88.0, + yaw_deg: 270.0, + snap_to_terrain: true, + spec: ( + use_kind: Industrial, + width: 52.0, + length: 30.0, + floors: 1, + floor_height: 7.0, + roof_style: Shed, + roof_height: 2.4, + facade: MetalPanel, + facade_color: (0.68, 0.75, 0.78), + roof: StandingSeam, + window_spacing: 6.5, + window_width: 2.4, + window_height: 1.8, + lit_window_share: 0.16, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + chimneys: 0, + roof_vents: 5, + skylights: 6, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 4, + seed: 1212, + ), + ), + ( + edge: 0, + s: 1580.0, + lateral_offset: -105.0, + yaw_deg: 90.0, + snap_to_terrain: true, + spec: ( + use_kind: Industrial, + width: 64.0, + length: 38.0, + floors: 1, + floor_height: 7.5, + roof_style: Sawtooth, + roof_height: 3.2, + facade: YellowBrick, + facade_color: (0.86, 0.84, 0.78), + roof: StandingSeam, + window_spacing: 6.0, + window_width: 2.6, + window_height: 2.1, + lit_window_share: 0.22, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + chimneys: 0, + roof_vents: 8, + skylights: 5, + rain_gutters: true, + entrance_canopy: false, + loading_doors: 3, + seed: 1313, + ), + ), + ( + edge: 0, + s: 1780.0, + lateral_offset: 120.0, + yaw_deg: 270.0, + snap_to_terrain: true, + spec: ( + use_kind: Industrial, + width: 78.0, + length: 42.0, + floors: 1, + floor_height: 8.0, + roof_style: Flat, + roof_height: 0.0, + facade: MetalPanel, + facade_color: (0.76, 0.78, 0.74), + roof: Bitumen, + window_spacing: 7.5, + window_width: 2.6, + window_height: 2.0, + lit_window_share: 0.12, + balconies: false, + balcony_every: 1, + balcony_depth: 1.0, + chimneys: 0, + roof_vents: 10, + skylights: 12, + rain_gutters: true, + entrance_canopy: true, + loading_doors: 8, + seed: 1414, + ), + ), + ], yards: [], trees: [], walk_paths: [], @@ -27586,4 +28008,4 @@ ), ], script: None, -) \ No newline at end of file +)