From 115a17be1d031d0d8d7796b4226f10da81628bfc Mon Sep 17 00:00:00 2001 From: Nils van Lueck Date: Sat, 5 Sep 2026 07:24:54 +0200 Subject: [PATCH] feat(wind): add turbine import and animated scenery support - Import turbines from OSM and MaStR data - Add runtime rendering, editor tools, models, and tests --- Cargo.lock | 1 + MODS.md | 70 + STATUS.md | 136 +- crates/content/src/bin/import-module.rs | 85 +- crates/content/src/compose.rs | 6 + crates/content/src/demo.rs | 29 +- crates/content/src/import/mod.rs | 4 +- crates/content/src/import/osm.rs | 269 ++- crates/content/src/lib.rs | 6 +- crates/content/src/route.rs | 67 + crates/content/src/terrain.rs | 70 +- crates/content/src/wind.rs | 577 ++++++ crates/content/tests/wind_turbines.rs | 165 ++ crates/fields/src/lib.rs | 9 + crates/fields/src/mastr.rs | 417 ++++ crates/i18n/locales/de/main.ftl | 20 + crates/i18n/locales/en/main.ftl | 20 + crates/route-editor/src/main.rs | 13 +- crates/route-editor/src/tools.rs | 1 - crates/route-editor/src/ui.rs | 5 + crates/route-editor/src/wind.rs | 455 +++++ crates/world-render/Cargo.toml | 2 + crates/world-render/src/lib.rs | 15 +- crates/world-render/src/scatter.rs | 95 +- crates/world-render/src/wind.rs | 456 +++++ mods/wind/assets/beton_colour.png | 3 + mods/wind/assets/beton_normal.png | 3 + mods/wind/assets/beton_orm.png | 3 + mods/wind/assets/lack_colour.png | 3 + mods/wind/assets/lack_normal.png | 3 + mods/wind/assets/lack_orm.png | 3 + mods/wind/assets/verzinkt_colour.png | 3 + mods/wind/assets/verzinkt_normal.png | 3 + mods/wind/assets/verzinkt_orm.png | 3 + mods/wind/assets/wea_115.bin | 3 + mods/wind/assets/wea_115_enercon.gltf | 1963 ++++++++++++++++++ mods/wind/assets/wea_115_standard.gltf | 2133 +++++++++++++++++++ mods/wind/assets/wea_150.bin | 3 + mods/wind/assets/wea_150_enercon.gltf | 1963 ++++++++++++++++++ mods/wind/assets/wea_150_standard.gltf | 2133 +++++++++++++++++++ mods/wind/assets/wea_50.bin | 3 + mods/wind/assets/wea_50_enercon.gltf | 1571 ++++++++++++++ mods/wind/assets/wea_50_gitter.gltf | 2081 +++++++++++++++++++ mods/wind/assets/wea_50_standard.gltf | 1741 ++++++++++++++++ mods/wind/assets/wea_80.bin | 3 + mods/wind/assets/wea_80_enercon.gltf | 1963 ++++++++++++++++++ mods/wind/assets/wea_80_gitter.gltf | 2473 +++++++++++++++++++++++ mods/wind/assets/wea_80_standard.gltf | 2133 +++++++++++++++++++ mods/wind/mod.ron | 10 + mods/wind/objects/wea_115_enercon.ron | 11 + mods/wind/objects/wea_115_standard.ron | 11 + mods/wind/objects/wea_150_enercon.ron | 11 + mods/wind/objects/wea_150_standard.ron | 11 + mods/wind/objects/wea_50_enercon.ron | 11 + mods/wind/objects/wea_50_gitter.ron | 11 + mods/wind/objects/wea_50_standard.ron | 11 + mods/wind/objects/wea_80_enercon.ron | 11 + mods/wind/objects/wea_80_gitter.ron | 11 + mods/wind/objects/wea_80_standard.ron | 11 + tools/pylons/lib/gltf.mjs | 283 ++- tools/pylons/lib/preview.mjs | 4 +- tools/trees/lib/png.mjs | 183 ++ tools/wind/README.md | 182 ++ tools/wind/build_wind.mjs | 445 ++++ tools/wind/lib/kit.mjs | 872 ++++++++ tools/wind/lib/preview.mjs | 220 ++ tools/wind/lib/texture.mjs | 197 ++ tools/wind/wind.json | 95 + 68 files changed, 25658 insertions(+), 129 deletions(-) create mode 100644 crates/content/src/wind.rs create mode 100644 crates/content/tests/wind_turbines.rs create mode 100644 crates/fields/src/mastr.rs create mode 100644 crates/route-editor/src/wind.rs create mode 100644 crates/world-render/src/wind.rs create mode 100644 mods/wind/assets/beton_colour.png create mode 100644 mods/wind/assets/beton_normal.png create mode 100644 mods/wind/assets/beton_orm.png create mode 100644 mods/wind/assets/lack_colour.png create mode 100644 mods/wind/assets/lack_normal.png create mode 100644 mods/wind/assets/lack_orm.png create mode 100644 mods/wind/assets/verzinkt_colour.png create mode 100644 mods/wind/assets/verzinkt_normal.png create mode 100644 mods/wind/assets/verzinkt_orm.png create mode 100644 mods/wind/assets/wea_115.bin create mode 100644 mods/wind/assets/wea_115_enercon.gltf create mode 100644 mods/wind/assets/wea_115_standard.gltf create mode 100644 mods/wind/assets/wea_150.bin create mode 100644 mods/wind/assets/wea_150_enercon.gltf create mode 100644 mods/wind/assets/wea_150_standard.gltf create mode 100644 mods/wind/assets/wea_50.bin create mode 100644 mods/wind/assets/wea_50_enercon.gltf create mode 100644 mods/wind/assets/wea_50_gitter.gltf create mode 100644 mods/wind/assets/wea_50_standard.gltf create mode 100644 mods/wind/assets/wea_80.bin create mode 100644 mods/wind/assets/wea_80_enercon.gltf create mode 100644 mods/wind/assets/wea_80_gitter.gltf create mode 100644 mods/wind/assets/wea_80_standard.gltf create mode 100644 mods/wind/mod.ron create mode 100644 mods/wind/objects/wea_115_enercon.ron create mode 100644 mods/wind/objects/wea_115_standard.ron create mode 100644 mods/wind/objects/wea_150_enercon.ron create mode 100644 mods/wind/objects/wea_150_standard.ron create mode 100644 mods/wind/objects/wea_50_enercon.ron create mode 100644 mods/wind/objects/wea_50_gitter.ron create mode 100644 mods/wind/objects/wea_50_standard.ron create mode 100644 mods/wind/objects/wea_80_enercon.ron create mode 100644 mods/wind/objects/wea_80_gitter.ron create mode 100644 mods/wind/objects/wea_80_standard.ron create mode 100644 tools/trees/lib/png.mjs create mode 100644 tools/wind/README.md create mode 100644 tools/wind/build_wind.mjs create mode 100644 tools/wind/lib/kit.mjs create mode 100644 tools/wind/lib/preview.mjs create mode 100644 tools/wind/lib/texture.mjs create mode 100644 tools/wind/wind.json diff --git a/Cargo.lock b/Cargo.lock index 598b0e86..95490174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11261,6 +11261,7 @@ dependencies = [ "content", "fields", "glam 0.32.1", + "serde_json", "sim-core", "track-model", "wgpu", diff --git a/MODS.md b/MODS.md index 293e5c2e..81c543b8 100644 --- a/MODS.md +++ b/MODS.md @@ -2382,6 +2382,76 @@ Overhead lines are **scenery**. Nothing about them is simulated, nothing is repl and two clients of a multiplayer run build the same masts and the same wires out of the same line file without a byte crossing the network. +### Wind turbines + +The turbines on the horizon are `wind_turbines:` on the line, one entry per machine: + +```ron +wind_turbines: [ + ( + lat: 51.5852389, + lon: 8.1421652, + hub_height: 78.0, // the rotor's axis over the ground [m] + rotor_diameter: 44.0, // [m] + object: "wind:wea_50_enercon", // the 3D object; empty = nothing is placed + yaw_deg: 250.0, // which way the nacelle looks, into the wind + model: "Enercon E 44", // a label, not a key + mastr: "SEE948327353778", // the unit in the Marktstammdatenregister + tags: ["wea-50", "mastr"], + ), +], +``` + +Two numbers are what a viewer perceives, so those are what the file carries: the hub +height and the rotor diameter. A turbine is **scenery with moving parts**: it takes the +same path through the tiles as a placed hut does — a scene instance on the ground, culled +and levelled by its object's own distances — but its nacelle yaws with the wind and its +rotor turns, which is why it is a scene with named nodes and not an instance in the +vegetation the way a mast is. + +**The models are `mods/wind`**, generated by `tools/wind/build_wind.mjs` from the +catalogue in `tools/wind/wind.json`: four size classes — the German fleet's generations, +a 600 kW machine of the nineties on a 50 m rotor, the 2 MW workhorse of the 2000s on +80 m, the 3 MW class on 115 m and today's 5 MW machines on 150 m — in up to three builds +each: `standard` (the box nacelle of Vestas, Nordex, Senvion, GE and Siemens), `enercon` +(the drop-shaped nacelle and the green-ringed tower foot) and `gitter` (a lattice tower +under a 1990s Fuhrländer). Four levels of detail each, PBR maps painted rather than +photographed, the aviation marking on every machine whose tip reaches over 100 m: red +bands on the blade tips, a red band round the tower, two red lamps on the nacelle by +night. `content::wind::PRESETS` is a copy of the catalogue's dimensions; the import picks +the class by the rotor and the build by the maker, and the placement scales the model to +the machine that actually stands there. + +**A model with moving parts** is a glTF whose nodes the game moves by name — the same +convention as `_LOD` and `_NIGHT`, and open to any mod that builds its own: + +| node | what the game does with it | +|---|---| +| `nacelle` | yaws about its Y to the wind's bearing, at a yaw drive's half a degree a second, with a dead band | +| `rotor` | turns about its own Z at the speed the wind at hub height gives it — read off the node's `extras`: `{"rotor_diameter": 80, "rated_rpm": 18, "hub_height": 95}` | +| `blink` | switched on the scenario clock, one second on and half a second off — under a `_NIGHT` node, so it is off by day. A screenshot of a lamp has to land in the lit second: `--frames` picks the moment | + +The wind is `sim_core::weather`, a shared function of the scenario clock, so nothing +about the movement is in the line file and nothing crosses the network: two clients see +the same park turning the same way. + +**The import** (File ▸ Import wind turbines…, `import-module`, `--no-wind` to skip) asks +two sources, because neither answers alone. OpenStreetMap has the **position** — +`power=generator` + `generator:source=wind`, surveyed by people who walked past — and +the machine's name on about a third of them. The Bundesnetzagentur's +**Marktstammdatenregister** has manufacturer, type designation, hub height, rotor +diameter and rated power for every unit in the country, and the import matches it to +what OSM mapped by the `ref:mastr` a mapper wrote or, failing that, by distance under a +hundred metres. A machine neither source sized is worked out from its rated power and +tagged `estimated`, so the file says which of its numbers were surveyed. `--no-register` +leaves the second question out, `--small-turbines` keeps the farmyard machines under a +20 m rotor that the import otherwise drops. + +**Which way a turbine faces is not a datum.** It yaws into the wind and keeps no +direction of its own, so the import writes the prevailing German westerly (250°) for +every machine of a box, and the weather takes it from there. Change `yaw_deg` and that is +where the machine starts. + ### Height data (DGM) A module can carry its own ground, so it runs without `--dgm` on the command line: diff --git a/STATUS.md b/STATUS.md index c2a860b3..ac4845c3 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # Implementation status against PLAN.md -As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and fmt clean. +As of 2026-09-05 · `cargo test --workspace`: **1269 tests green** · clippy and fmt clean. **This project is mod-first.** See [MODS.md](MODS.md) for how to create trains, signals and lines. @@ -434,8 +434,8 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and from `GDAL_NODATA`) from a single file or an entire directory supply the gradient profile. Tiles are loaded lazily (sheet boundaries from the file name) and kept in an LRU, so even a federal state's DGM1 is usable. - CLI: `import-line`, and `import-module` for re-fetching a whole module's fields, roads - and heights headless (2026-08-31, see below). + CLI: `import-line`, and `import-module` for re-fetching a whole module's fields, roads, + overhead lines, wind turbines and heights headless (2026-08-31, see below). - **Fields from the agricultural registers (field plan, complete):** the countryside beside the line is farmed, and a field is a crop rather than a green rectangle. A line stores the outline, the crop, the working direction and a seed (`route::FieldSource`); what a field @@ -1325,6 +1325,136 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and because a crossarm changed on one side only puts the conductors beside the insulator strings instead of on them. +- **Wind turbines from two registers (2026-09-05, `content::wind`, `fields::mastr`, + route editor File ▸ Import wind turbines…):** a module in the Börde or in + Dithmarschen has dozens of them on its horizon, so they come out of public data + like the roads and the overhead lines do — but from **two** sources, because + neither answers alone. + + *OpenStreetMap knows where they stand.* Somebody walked to the foot of the + tower. What it mostly does not know is the machine: over 554 turbines in a + Dithmarschen box and 605 in the Magdeburger Börde, the mappers wrote + `manufacturer` on 31–55 % of them, `model` on 31–46 %, `height:hub` on 8–21 % + and `rotor:diameter` on 7 %. Hub height and rotor diameter are exactly the two + numbers a viewer perceives, so a turbine without them is a guess. + + *The Bundesnetzagentur's Marktstammdatenregister knows what they are.* Every + generating unit in Germany is registered with its manufacturer, type + designation, hub height, rotor diameter, rated power, operating status and + coordinates, and the register publishes that as open data (dl-de/by-2-0). The + extended public unit data its own web front end reads is a JSON endpoint that + takes no key and **filters on the WGS84 coordinates**, so a module's envelope + box is one query — 500 rows a page, some two megabytes, about a second + (`fields::mastr`, beside the field registers of `fields::wfs` because a + register client belongs with the register clients). + + *Putting the two together* is `content::wind::match_register`: a turbine + carrying `ref:mastr` — half of them do — is matched on that number and nothing + else, the rest take the nearest **standing** unit within a hundred metres. In + a test box of 78 mapped turbines, 75 carried the reference, the nearest-unit + match agreed with every one of them, and the two positions differed by 1.9 m + in the median, 16 m at the ninth decile and 98 m in the single worst case. + German turbines stand hundreds of metres apart, so the radius is wide enough + for the outlier and far too narrow to reach the neighbour. What the register + cannot answer for falls back to the **specific power** — the rated power per + square metre of swept area, 335 W/m² in the median over 387 operating + machines — with the tower following the rotor at `40 + 0.75 d`, and the entry + is tagged `estimated` so the file says which of its numbers were surveyed. + The tag is worth having: the same 112 m rotor sits on a 94 m tower on the + windy coast and on a 140 m one in the Hunsrück, where the machine has to reach + over a forest. + + `height=*` is deliberately **not** read although more turbines carry it than + carry `height:hub`. Where a mapper wrote both, it is the tip height and the + two agree to the metre (119 + 112/2 = 175); where it stands alone it is as + often the hub height — an MM82 tagged `height=59` has a hub of 59 and a tip of + 100. A tag that means two things cannot be turned into one number. + + *Which way they face is not a datum and cannot be one.* A turbine yaws into + the wind and keeps no direction of its own, so the import writes the + prevailing German westerly (250°) for every turbine of a box: a park whose + machines all face one way is right at every moment, one whose machines face at + random is wrong at all of them. The live answer is already in the world — + `sim_core::weather::Weather::bearing` and `wind` are a function of the + scenario clock, shared by every client — so a nacelle that follows the wind + and a rotor that turns with it cost nothing over the network when the models + arrive. + + *In the world* a turbine is `wind_turbines:` on the line file + (`WindTurbineSource`): the position, the hub height, the rotor diameter, the + machine's name, its MaStR number and the tags. It is **scenery with moving + parts** — a scene instance on the ground like a placed hut, not a flattened + instance in the vegetation like a mast, because its nacelle yaws and its + rotor turns (`content::terrain::GeoObject`, `Scenery::add_geo`). + + **The models (`mods/wind`, `tools/wind/`)** are generated like the masts: + `wind.json` is the catalogue — four size classes, the German fleet's own + generations at the register's median dimensions (50 m rotor on 65 m, 80 on + 95, 115 on 125, 150 on 140) — and `build_wind.mjs` turns it into ten builds: + the box nacelle of Vestas, Nordex, Senvion, GE and Siemens, Enercon's drop + with the seven green rings of its tower foot, and a lattice tower under the + two small classes for a Fuhrländer of the nineties. The blade is a loft + through NACA sections — round at the bolt circle, widest at a quarter of the + length, twisted eighteen degrees at the root to nothing at the tip, the + pitch axis at thirty per cent of the chord — the nacelle a rounded box or a + body of revolution, the tower a tapered tube with its section flanges and a + door, or four battered legs with X bracing. Four levels of detail cut on + the blade's outer chord (three pixels, a pixel and a quarter, then the + tower foot under two pixels), culled at eight kilometres, and a lamp that + grows with the level so it survives as a pixel of red. The materials are + one painted coating — RAL 7035 kept bright, orange peel and rain streaks + and chalking in the roughness — plus the masts' concrete and zinc, with the + machine's own marks in the vertex colours: the AVV day marking (red, white, + red on the tips, ending on a ring; a red band round the tower at forty + metres), the leading-edge erosion, the dirt at the foot, the green rings. + `--check` asserts every face is wound the way its normals point (the + masts' centre-of-piece test cannot judge a coned blade) and every moved + node is there; `--preview` draws every build, a close-up of the head, and + each hand-over pair at the distance it happens at. + + *They move.* The model's `nacelle` and `rotor` are nodes, the levels hang + under them, and `world_render::wind` moves them by name: the rotor at the + speed the wind at hub height gives it (a tip-speed ratio of seven up to the + rated speed off the node's extras, idling under the cut-in, stopped over + the cut-out, the wind grown from ten metres to the hub by the Hellmann + law), the nacelle yawed to the wind's bearing at half a degree a second + with a dead band, and the lamps blinked on the scenario clock — one second + on, half off, every machine of a park in step, as the regulation asks. The + lamp is the model's own emissive material, forty times a red, and it blooms + the way a signal lens does. It cost an afternoon to believe that: every + screenshot of it came out dark, through a whole ladder of experiments that + blamed the emissive, the weather material swap, the bindless slab and the + exposure weight in turn — until the blink's own log showed the capture frame + sitting in the lamp's half second *off* every time (`--frames 235` lands at + the same tick of the scenario clock run after run), and a pixel scan for + pure red that could never match a lit lamp, which blooms to pink-white. Two + confounds, no bug. What the ladder did establish on the way: the emissive + alpha reaches the shader as the exposure weight it should be, and + `ExtendedMaterial` is not bindless when its extension is not. The + weather is a shared function of the clock, so nothing is sent; the rotor's + phase accumulates locally, which is not state because nobody can compare + two clients' blade angles. A steady breeze stands in where there is no sky, + so the editor's preview turns too. + + *Scenery objects now level.* A `VisibilityRange` on a scene's root reaches + no mesh below it, so every level of a hand-placed mast was drawn at once — + `scatter::apply_scene_lods` walks a spawned scene and puts each mesh's band + on the mesh, from the object's own `lod_distances` or the renderer's, + which is what lets a 200 m machine be drawn to eight kilometres and a hut + to three. The example line got a park of five to show every build. + + *The dialog* has filters rather than a list, like the road import: whether to + ask the register at all (on — it is the difference between a third of the + machines knowing their size and all of them) and whether the farmyard + Kleinwindanlagen under a 20 m rotor come too (off — they are many and they are + furniture). Headless the same import is `import-module --no-wind`, + `--small-turbines`, `--no-register`. + + Fixed on the way: **the route editor binary did not build.** A stray + `#[cfg(test)]` on the `use content::route::{…}` of `tools.rs` made every type + it names test-only, so `cargo build -p route-editor` failed with 49 errors + while `cargo test` — which compiles the same crate *with* `cfg(test)` — passed. + - **Artist vegetation of Central Europe (2026-09-02, `mods/trees`, `tools/trees/`):** the old ez-tree procedural catalogue and its texture/geometry generators were removed. The original 28 logical tree and large-shrub species and all 84 stable object ids use modified models from diff --git a/crates/content/src/bin/import-module.rs b/crates/content/src/bin/import-module.rs index 27e3e2ae..2863d0e8 100644 --- a/crates/content/src/bin/import-module.rs +++ b/crates/content/src/bin/import-module.rs @@ -4,7 +4,8 @@ //! //! ```text //! import-module --line mods/example/lines/boerde.ron -//! [--no-fields] [--no-roads] [--no-power] +//! [--no-fields] [--no-roads] [--no-power] [--no-wind] +//! [--small-turbines] [--no-register] //! [--tracks] [--narrow] [--refresh-fields] //! [--dgm ] [--fetch-dgm nrw] [--zone 32] [--cell 10] //! [--no-fit-track] [--list-dgm-tiles] @@ -15,6 +16,10 @@ //! into the module; hand-drawn fields stay. //! * **Roads** — Overpass over the envelope's box. Replaces the road list: //! the module is being rebuilt, so the previous import's roads go with it. +//! * **Wind turbines** — Overpass for where they stand, the +//! Marktstammdatenregister for what they are (`--no-register` to skip the +//! second question, `--small-turbines` to keep the farmyard ones). Replaces +//! the turbine list. //! * **Heights** — the corridor's terrain tiles are sampled out of a DGM //! delivery into `/heights//` (one ESRI ASCII grid each) and //! the line records them, so the module carries its ground. With @@ -25,7 +30,9 @@ use content::TerrainBuilder; use content::import::dgm::{HeightTile, TerrainSource}; -use content::route::{EdgeStart, HeightSource, LineSource, PowerLineSource, RoadSource}; +use content::route::{ + EdgeStart, HeightSource, LineSource, PowerLineSource, RoadSource, WindTurbineSource, +}; use fields::{Area, Clip, CropTable, FieldCache, ImportOptions, ImportReport}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -43,6 +50,7 @@ fn main() -> ExitCode { if args.is_empty() || args.iter().any(|a| a == "--help") { eprintln!( "Usage: import-module --line [--no-fields] [--no-roads] [--no-power]\n\ + \x20 [--no-wind] [--small-turbines] [--no-register]\n\ \x20 [--tracks] [--narrow] [--refresh-fields]\n\ \x20 [--dgm ] [--fetch-dgm nrw] [--zone 32] [--cell 10]\n\ \x20 [--no-fit-track] [--list-dgm-tiles]" @@ -64,6 +72,9 @@ fn main() -> ExitCode { let do_fields = !set("--no-fields"); let do_roads = !set("--no-roads"); let do_power = !set("--no-power"); + let do_wind = !set("--no-wind"); + let small_turbines = set("--small-turbines"); + let register = !set("--no-register"); let tracks = set("--tracks"); let narrow = set("--narrow"); let refresh_fields = set("--refresh-fields"); @@ -158,6 +169,23 @@ fn main() -> ExitCode { } } + if do_wind { + let Some(bbox) = envelope_bbox(&line) else { + eprintln!("Error: the line has no envelope to import inside"); + return ExitCode::FAILURE; + }; + match import_wind(bbox, small_turbines, register) { + Ok((turbines, matched)) => { + print_wind_report(&turbines, matched); + line.wind_turbines = turbines; + } + Err(e) => { + eprintln!("Error: the wind turbine import failed: {e}"); + return ExitCode::FAILURE; + } + } + } + // The corridor's terrain tiles — the same set the editor's DGM panel // cuts and the runtime's terrain streaming builds. let tiles = TerrainBuilder::new(&net, Vec::new(), options).corridor_keys(); @@ -356,6 +384,59 @@ fn import_power(bbox: (f64, f64, f64, f64)) -> Result, Stri content::import::parse_power_lines(&json).map_err(|e| e.to_string()) } +/// The wind turbines of the envelope's box: OpenStreetMap for where they +/// stand, the register for what they are — the same two questions the editor's +/// import asks, in the same order. +fn import_wind( + bbox: (f64, f64, f64, f64), + small: bool, + register: bool, +) -> Result<(Vec, content::RegisterMatch), String> { + let query = content::import::wind_query(bbox.0, bbox.1, bbox.2, bbox.3); + let config = fields::RequestConfig::default(); + let json = fields::osm::fetch_raw(&query, &config).map_err(|e| e.to_string())?; + let mut turbines = content::import::parse_wind_turbines(&json).map_err(|e| e.to_string())?; + + let mut matched = content::RegisterMatch::default(); + if register { + let units = fields::mastr::fetch_wind(bbox.0, bbox.1, bbox.2, bbox.3, &config) + .map_err(|e| e.to_string())?; + matched = content::wind::match_register(&mut turbines, &units); + } + if !small { + turbines.retain(|t| !content::wind::is_small(t)); + } + Ok((turbines, matched)) +} + +/// Prints the wind turbine import's summary — the machines, because that is +/// what says whether the register was asked the right question. +fn print_wind_report(turbines: &[WindTurbineSource], matched: content::RegisterMatch) { + let mut machines: BTreeMap<&str, usize> = BTreeMap::new(); + for turbine in turbines { + let name = if turbine.model.is_empty() { + "(unknown machine)" + } else { + turbine.model.as_str() + }; + *machines.entry(name).or_default() += 1; + } + let estimated = turbines + .iter() + .filter(|t| t.tags.iter().any(|tag| tag == "estimated")) + .count(); + eprintln!( + "Wind turbines: {} turbine(s), {} named by the register, {estimated} sized by their power, \ + {} standing register unit(s) with no turbine on the map", + turbines.len(), + matched.matched, + matched.spare + ); + for (name, count) in &machines { + eprintln!(" {name}: {count}"); + } +} + /// Prints the overhead line import's summary — the mast types and how many of /// each stand on the module, which is what says whether the type choice came /// out right before anybody starts the editor. diff --git a/crates/content/src/compose.rs b/crates/content/src/compose.rs index d0904b00..b5ffab3b 100644 --- a/crates/content/src/compose.rs +++ b/crates/content/src/compose.rs @@ -392,6 +392,12 @@ fn merge_module(merged: &mut LineSource, module: &LineSource, off: ModuleOffsets merged .power_lines .extend(module.power_lines.iter().cloned()); + // Wind turbines the same way: geo-positioned, and one that stands between + // two modules is imported by both and put on a tile by whichever builds + // the ground under it. + merged + .wind_turbines + .extend(module.wind_turbines.iter().cloned()); // Terrain strokes likewise; they keep their order, so a stroke of a later // module wins where two modules shape the same ground. merged.terrain.extend(module.terrain.iter().cloned()); diff --git a/crates/content/src/demo.rs b/crates/content/src/demo.rs index 8a30c8eb..c3018d57 100644 --- a/crates/content/src/demo.rs +++ b/crates/content/src/demo.rs @@ -4,7 +4,7 @@ use crate::route::{ DeviceSource, EdgeSource, EdgeStart, GeoPoint, LineSource, NodeSource, SectionSource, - SignalSource, TreeSource, WaterPoint, WaterSource, YardSource, + SignalSource, TreeSource, WaterPoint, WaterSource, WindTurbineSource, YardSource, }; use sim_core::interlock::BlockMarkerPayload; use sim_core::interlock::{SignalKind, SignalSystem}; @@ -167,6 +167,32 @@ fn demo_trees() -> Vec { /// Builds the example line: 3 km straight, 1 km curve, 3 km climb. /// +/// A small wind park north of the first straight — what the wind turbine +/// import puts into a real module, placed by hand here so the demo shows +/// every build the `wind` mod has: the box nacelle, Enercon's drop, and a +/// 1990s machine on a lattice tower. All of them face the prevailing westerly +/// until the weather turns them (`world_render::wind`). +fn demo_turbines() -> Vec { + let machine = |lat: f64, lon: f64, hub: f64, rotor: f64, model: &str| { + crate::wind::source_from( + lat, + lon, + hub, + rotor, + model.to_string(), + String::new(), + false, + ) + }; + vec![ + machine(52.0035, 10.0035, 98.0, 71.0, "Enercon E-70 E4"), + machine(52.0042, 10.0090, 95.0, 90.0, "Vestas V90"), + machine(52.0058, 10.0060, 100.0, 90.0, "Vestas V90"), + machine(52.0075, 10.0120, 120.0, 117.0, "Nordex N117"), + machine(52.0030, 9.9975, 65.0, 48.0, "Fuhrländer FL 600"), + ] +} + /// Signalling: distant signal at km 1.0 and main signal at km 2.0 (end of block), /// plus the three PZB magnets. From the third section on there is a line cable (LZB). /// @@ -343,6 +369,7 @@ pub fn musterbahn() -> LineSource { }, ], trees: demo_trees(), + wind_turbines: demo_turbines(), // A lake south of the first straight — the stand-in for the water // import, so the demo shows a body of water without an extract. Its // surface is laid over the terrain when the tiles are built, like the diff --git a/crates/content/src/import/mod.rs b/crates/content/src/import/mod.rs index 402f09b7..b2a93765 100644 --- a/crates/content/src/import/mod.rs +++ b/crates/content/src/import/mod.rs @@ -10,8 +10,8 @@ pub mod fit; pub mod osm; pub use osm::{ - parse_forests, parse_markers, parse_power_lines, parse_roads, parse_water, power_query, - roads_query, + parse_forests, parse_markers, parse_power_lines, parse_roads, parse_water, parse_wind_turbines, + power_query, roads_query, wind_query, }; use crate::route::{EdgeSource, EdgeStart, GeoPoint, LineSource, NodeSource}; diff --git a/crates/content/src/import/osm.rs b/crates/content/src/import/osm.rs index af0c4934..3dba6df8 100644 --- a/crates/content/src/import/osm.rs +++ b/crates/content/src/import/osm.rs @@ -14,7 +14,9 @@ //! line (a few thousand nodes); a PBF reader would only be necessary if whole federal //! states had to be read in. -use crate::route::{CenterLine, MarkerSource, RoadSource, RoadSurface, WaterSource}; +use crate::route::{ + CenterLine, MarkerSource, RoadSource, RoadSurface, WaterSource, WindTurbineSource, +}; use serde::Deserialize; use std::collections::HashMap; @@ -1238,6 +1240,185 @@ fn pick_type(design: Option<&str>, volts: Option, railway: bool, minor: boo } } +/// The Overpass QL for the wind turbines of a box. +/// +/// Both spellings are asked for: `generator:source=wind` is the tag that says +/// what drives the generator, `generator:method=wind_turbine` the one that says +/// how, and a turbine in the wild carries either or both. Ways come along +/// because a few turbines are mapped as the circle of their foundation rather +/// than as a point, and `(._;>;)` pulls the nodes those circles are made of in. +pub fn wind_query(min_lat: f64, min_lon: f64, max_lat: f64, max_lon: f64) -> String { + // Overpass takes its box south, west, north, east. + let bbox = format!("{min_lat:.6},{min_lon:.6},{max_lat:.6},{max_lon:.6}"); + format!( + "[out:json][timeout:120];(\ + node[\"generator:source\"=\"wind\"]({bbox});\ + way[\"generator:source\"=\"wind\"]({bbox});\ + node[\"generator:method\"=\"wind_turbine\"]({bbox});\ + way[\"generator:method\"=\"wind_turbine\"]({bbox});\ + );(._;>;);out body;" + ) +} + +/// The wind turbines of an Overpass extract. +/// +/// What OSM reliably has is the **position**: somebody stood at the foot of the +/// tower. What it has less often is the machine — over two German boxes of a +/// thousand turbines, `manufacturer` and `model` were on a third to a half of +/// them, `height:hub` on a fifth and `rotor:diameter` on one in fourteen. So +/// what is read here is everything the mapper wrote, and the gaps are left for +/// the register to fill ([`crate::wind::match_register`]); where nothing fills +/// them, the rated power gives the dimensions ([`crate::wind::estimate`]) and +/// the turbine is tagged `estimated` so the file says so. +/// +/// `ref:mastr` is the tag that matters most and reads like nothing: +/// half the turbines carry their number in the Marktstammdatenregister, which +/// is the machine's identity and turns the match with the register from a guess +/// at a distance into a lookup. +// ponytail: `height=*` is deliberately not read, although it is on more +// turbines than `height:hub` is. Where a mapper wrote both, `height` is the tip +// height and the two agree to the metre (119 + 112/2 = 175). Where only +// `height` is there, it is as often the hub height: an MM82 tagged `height=59` +// has a hub of 59 and a tip of 100, and an E-101 tagged `height=135` stands on +// a 135 m tower. A tag that means two things cannot be turned into one number, +// and a hub height that is 40 m out is a turbine of the wrong generation. +pub fn parse_wind_turbines(json: &str) -> Result, OsmError> { + let response: OverpassResponse = + serde_json::from_str(json).map_err(|e| OsmError::Json(e.to_string()))?; + + let mut nodes: HashMap = HashMap::new(); + for e in &response.elements { + if let (Some(lat), Some(lon)) = (e.lat, e.lon) { + nodes.insert(e.id, (lat, lon)); + } + } + + let mut out = Vec::new(); + for e in &response.elements { + if !is_wind_turbine(&e.tags) { + continue; + } + // A turbine mapped as its foundation becomes the middle of it — the + // same trade the marker import makes with a platform. + let Some((lat, lon)) = position(e, &nodes) else { + continue; + }; + + let hub = metres(e.tags.get("height:hub")).unwrap_or(0.0); + let rotor = metres(e.tags.get("rotor:diameter")).unwrap_or(0.0); + let power = power_kw(e.tags.get("generator:output:electricity")).unwrap_or(0.0); + // The estimate fills whichever of the two the mapper left out; a + // turbine both of whose numbers are surveyed is not estimated at all. + let (guessed_hub, guessed_rotor) = crate::wind::estimate(power); + let estimated = hub <= 0.0 || rotor <= 0.0; + let rotor = if rotor > 0.0 { rotor } else { guessed_rotor }; + let hub = if hub > 0.0 { hub } else { guessed_hub }; + + out.push(crate::wind::source_from( + lat, + lon, + hub, + rotor, + machine(&e.tags), + e.tags.get("ref:mastr").cloned().unwrap_or_default(), + estimated, + )); + } + Ok(out) +} + +/// Whether an element's tags say it is a wind turbine. A `power=plant` area +/// carrying `plant:source=wind` is the wind farm around them, not a machine, +/// and is left where it is. +fn is_wind_turbine(tags: &HashMap) -> bool { + let generator = tags.get("power").map(String::as_str) == Some("generator") + || tags.contains_key("generator:source") + || tags.contains_key("generator:method"); + generator + && (tags.get("generator:source").map(String::as_str) == Some("wind") + || tags.get("generator:method").map(String::as_str) == Some("wind_turbine")) +} + +/// Where an element stands: its own coordinates, or the middle of the way's +/// nodes. +fn position(element: &Element, nodes: &HashMap) -> Option<(f64, f64)> { + if let (Some(lat), Some(lon)) = (element.lat, element.lon) { + return Some((lat, lon)); + } + // A closed way repeats its first node at the end; counting it twice would + // pull the middle towards that corner. + let mut ids = element.nodes.as_slice(); + if ids.len() > 1 && ids.first() == ids.last() { + ids = &ids[..ids.len() - 1]; + } + let ring: Vec<(f64, f64)> = ids.iter().filter_map(|id| nodes.get(id).copied()).collect(); + if ring.is_empty() { + return None; + } + let n = ring.len() as f64; + Some(( + ring.iter().map(|p| p.0).sum::() / n, + ring.iter().map(|p| p.1).sum::() / n, + )) +} + +/// The machine's name out of `manufacturer=*` and `model=*` — `Enercon E-115 +/// EP3`. A model that already carries the manufacturer does not get it twice, +/// and either tag alone is still worth writing down. +fn machine(tags: &HashMap) -> String { + let make = tags.get("manufacturer").map(String::as_str).unwrap_or(""); + let model = tags.get("model").map(String::as_str).unwrap_or(""); + let (make, model) = (make.trim(), model.trim()); + if make.is_empty() { + return model.to_string(); + } + if model.is_empty() { + return make.to_string(); + } + let head = format!("{make} "); + if model.len() > head.len() && model[..head.len()].eq_ignore_ascii_case(&head) { + return model.to_string(); + } + format!("{make} {model}") +} + +/// A tag that names a length in metres: `112`, `112 m`, `112.5`. The unit is +/// written about as often as it is left out, and it is always metres. +fn metres(value: Option<&String>) -> Option { + let text = value?.trim(); + let number: String = text + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == ',') + .collect(); + number + .replace(',', ".") + .parse::() + .ok() + .filter(|v| *v > 0.0) +} + +/// The rated power a `generator:output:electricity` names [kW]. The tag is +/// `4.2 MW` as often as `3200 kW`, and `yes` often enough to be worth passing +/// over rather than reading as a number. +fn power_kw(value: Option<&String>) -> Option { + let text = value?.trim(); + let number: String = text + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == ',') + .collect(); + let amount = number.replace(',', ".").parse::().ok()?; + let unit = text[number.len()..].trim().to_ascii_lowercase(); + let factor = match unit.as_str() { + "mw" => 1_000.0, + "kw" | "" => 1.0, + "w" => 0.001, + // A unit nobody writes on a wind turbine: better no number than a + // number that is out by a thousand. + _ => return None, + }; + Some(amount * factor).filter(|v| *v > 0.0) +} + #[cfg(test)] mod tests { use super::*; @@ -1676,4 +1857,90 @@ mod tests { assert!(query.contains("(._;>;)"), "the nodes come too"); assert!(query.contains("52.000000,10.000000,52.100000,10.100000")); } + + /// A turbine as the mappers of Dithmarschen write one, one with nothing but + /// its power, one mapped as its foundation, and a wind farm outline that is + /// not a machine. + #[test] + fn wind_turbines_come_out_of_overpass_json() { + let json = r#"{"elements": [ + {"type": "node", "id": 1, "lat": 54.2683, "lon": 9.0135, "tags": { + "power": "generator", "generator:source": "wind", + "generator:method": "wind_turbine", "generator:type": "horizontal_axis", + "manufacturer": "Enercon", "model": "E-70 E4", + "generator:output:electricity": "2.3 MW", + "height:hub": "64", "rotor:diameter": "71 m", + "ref:mastr": "SEE945374201878"}}, + {"type": "node", "id": 2, "lat": 54.2, "lon": 9.0, "tags": { + "power": "generator", "generator:source": "wind", + "generator:output:electricity": "3450 kW"}}, + {"type": "node", "id": 10, "lat": 54.10, "lon": 9.10}, + {"type": "node", "id": 11, "lat": 54.10, "lon": 9.12}, + {"type": "node", "id": 12, "lat": 54.12, "lon": 9.12}, + {"type": "node", "id": 13, "lat": 54.12, "lon": 9.10}, + {"type": "way", "id": 20, "nodes": [10, 11, 12, 13, 10], "tags": { + "power": "generator", "generator:source": "wind"}}, + {"type": "way", "id": 21, "nodes": [10, 11, 12, 13, 10], "tags": { + "power": "plant", "plant:source": "wind"}} + ]}"#; + let turbines = parse_wind_turbines(json).expect("parses"); + // The farm outline is not a machine; the foundation ring is one. + assert_eq!(turbines.len(), 3); + + let surveyed = &turbines[0]; + assert_eq!(surveyed.model, "Enercon E-70 E4"); + assert_eq!(surveyed.mastr, "SEE945374201878"); + assert_eq!(surveyed.hub_height, 64.0); + // The unit is written on the tag as often as it is left off. + assert_eq!(surveyed.rotor_diameter, 71.0); + assert_eq!(surveyed.yaw_deg, crate::wind::PREVAILING_BEARING); + assert_eq!(surveyed.tags, vec!["wea-80"]); + // An Enercon of the 2 MW class stands on the Enercon build of it. + assert_eq!(surveyed.object, "wind:wea_80_enercon"); + + // Nothing but a rated power: the dimensions are worked out and the + // file says so. + let guessed = &turbines[1]; + assert!(guessed.tags.iter().any(|t| t == "estimated")); + assert!((100.0..120.0).contains(&guessed.rotor_diameter)); + assert!(guessed.model.is_empty()); + + // The foundation ring becomes its middle. + let ring = &turbines[2]; + assert!((ring.lat - 54.11).abs() < 1e-9); + assert!((ring.lon - 9.11).abs() < 1e-9); + + // A turbine-free extract is empty, not an error. + assert_eq!(parse_wind_turbines(r#"{"elements": []}"#), Ok(vec![])); + } + + /// The tag values the German extracts actually carry. + #[test] + fn the_turbine_tags_are_read_as_they_are_written() { + assert_eq!(power_kw(Some(&"4.2 MW".to_string())), Some(4200.0)); + assert_eq!(power_kw(Some(&"3200 kW".to_string())), Some(3200.0)); + assert_eq!(power_kw(Some(&"600kW".to_string())), Some(600.0)); + assert_eq!(power_kw(Some(&"2000".to_string())), Some(2000.0)); + // "yes" says a turbine produces electricity, which was never in doubt. + assert_eq!(power_kw(Some(&"yes".to_string())), None); + assert_eq!(metres(Some(&"112 m".to_string())), Some(112.0)); + assert_eq!(metres(Some(&"122.5".to_string())), Some(122.5)); + assert_eq!(metres(Some(&"unknown".to_string())), None); + // The manufacturer is not said twice. + let mut tags = HashMap::new(); + tags.insert("manufacturer".to_string(), "Vestas".to_string()); + tags.insert("model".to_string(), "Vestas V112".to_string()); + assert_eq!(machine(&tags), "Vestas V112"); + } + + /// The query asks for both spellings of a turbine, and for the nodes of a + /// turbine mapped as an area. + #[test] + fn the_wind_query_asks_for_both_spellings() { + let query = wind_query(52.0, 10.0, 52.1, 10.1); + assert!(query.contains("generator:source\"=\"wind")); + assert!(query.contains("generator:method\"=\"wind_turbine")); + assert!(query.contains("(._;>;)"), "the ways' nodes come too"); + assert!(query.contains("52.000000,10.000000,52.100000,10.100000")); + } } diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index d9e8d9f0..fad8af00 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -16,6 +16,7 @@ pub mod scenarios; pub mod terrain; pub mod vehicles; pub mod water; +pub mod wind; pub use buildings::{ BakedBuilding, BuildingPreset, BuildingSource, BuildingSpec, BuildingUse, FacadeMaterial, @@ -32,10 +33,13 @@ pub use people::{ }; pub use power::{ConductorPatch, PowerLines}; pub use roads::{RoadPatch, Roads}; -pub use route::{CompiledLine, FieldSource, LineSource, PowerLineSource, TreeSource}; +pub use route::{ + CompiledLine, FieldSource, LineSource, PowerLineSource, TreeSource, WindTurbineSource, +}; pub use scenarios::{musterbahn_day, re_4711, to_musterstadt}; pub use terrain::{ Buildings, Scenery, SceneryInstance, TerrainBuilder, TerrainEdits, TerrainOptions, TerrainStats, TerrainTile, TileKey, Tree, Vegetation, }; pub use water::{WaterPatch, Waters}; +pub use wind::{RegisterMatch, WindPreset}; diff --git a/crates/content/src/route.rs b/crates/content/src/route.rs index 9449bd27..98f873fe 100644 --- a/crates/content/src/route.rs +++ b/crates/content/src/route.rs @@ -558,6 +558,67 @@ impl PowerLineSource { } } +/// A wind turbine: where it stands, and which machine it is. +/// +/// The two numbers a viewer perceives are the ones stored — the hub height and +/// the rotor diameter. Everything else about a turbine looks the same from a +/// train: a white tube, a nacelle, three blades. So the file carries the +/// dimensions rather than a type key, the way [`RoadSource`] carries a width +/// rather than a road class, and a builder who knows better edits two numbers. +/// +/// Where they come from is written in [`tags`](Self::tags): OpenStreetMap +/// surveys where a turbine stands, the Marktstammdatenregister knows what it +/// is, and the import puts the two together (see [`crate::wind`]). +/// +/// Scenery, like the overhead lines: no state, nothing to replicate, and both +/// clients of a multiplayer run build the same turbines out of the same line +/// file. The nacelle turning into the wind is a function of the shared weather +/// (`sim_core::weather::Weather::bearing`), not of anything sent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WindTurbineSource { + /// Where it stands [deg]; the height comes from the terrain. + pub lat: f64, + pub lon: f64, + /// Hub height over ground [m] — the height of the rotor's axis, which is + /// what a wind turbine's height means everywhere it is written down. + pub hub_height: f64, + /// Rotor diameter [m]. + pub rotor_diameter: f64, + /// The 3D object from a mod (`":"`). **Empty means nothing is + /// placed** — which is what the import writes until the turbine models + /// ship; the entries are in the file, on the right spot and with the right + /// dimensions, and the day the objects exist [`crate::wind::PRESETS`] names + /// them and they stand up. + #[serde(default)] + pub object: String, + /// Which way the nacelle looks [deg, clockwise from north, 0 = north] — + /// the direction the rotor faces *into*. + /// + /// Nothing surveys this and nothing can: a turbine yaws into the wind and + /// keeps no direction of its own. What the import writes is the prevailing + /// wind of the region ([`crate::wind::PREVAILING_BEARING`]), the same for + /// every turbine of a box — a park whose machines all face one way is + /// right at every moment, one whose machines face at random is wrong at + /// all of them. + #[serde(default)] + pub yaw_deg: f64, + /// Manufacturer and type as the sources name it (`Enercon E-115 EP3`); + /// empty where neither knew. Free text: the register writes the same + /// machine three ways, so it is a label, not a key. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub model: String, + /// The unit's number in the Marktstammdatenregister (`SEE945374201878`), + /// so the machine can be looked up and the next import recognises it. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mastr: String, + /// Free-form tags, lower-case kebab like everywhere else. The import + /// records the size class it matched (`wea-115`), `mastr` where the + /// register answered for the machine, and `estimated` where the dimensions + /// are worked out from the rated power rather than known. + #[serde(default)] + pub tags: Vec, +} + /// A road: the centre line OSM maps a street with, and the width, surface and /// markings that turn it into a carriageway. /// @@ -1415,6 +1476,11 @@ pub struct LineSource { /// conductors between them when the tiles are built (see [`crate::power`]). #[serde(default)] pub power_lines: Vec, + /// Wind turbines (see [`WindTurbineSource`]) — imported from OpenStreetMap + /// and the Marktstammdatenregister. Each one stands on the terrain as an + /// instance, like a mast of an overhead line does (see [`crate::wind`]). + #[serde(default)] + pub wind_turbines: Vec, /// Terrain brush strokes on top of the elevation data (see /// [`TerrainEditSource`]). #[serde(default)] @@ -1479,6 +1545,7 @@ impl Default for LineSource { waters: Vec::new(), roads: Vec::new(), power_lines: Vec::new(), + wind_turbines: Vec::new(), terrain: Vec::new(), heights: Vec::new(), sections: Vec::new(), diff --git a/crates/content/src/terrain.rs b/crates/content/src/terrain.rs index 5e92a8e5..f31d7b8a 100644 --- a/crates/content/src/terrain.rs +++ b/crates/content/src/terrain.rs @@ -199,7 +199,35 @@ pub struct SceneryInstance { /// Index into [`Scenery::objects`]. pub object: u16, /// Index of the placement in the line file — what the editor selects. + /// [`GeoObject::INDEX`] for an object that is not a line placement. pub index: u32, + /// Uniform scale on the object's own size — one for a placed object, the + /// machine's size over its class's for a wind turbine. + pub scale: f32, +} + +/// A scenery object that stands at a point on the earth rather than beside +/// the track: a wind turbine ([`crate::wind::placements`]). It takes the same +/// path through the tiles as an [`ObjectSource`] does — a scene instance, +/// culled and levelled by its object's own distances — and stands on the +/// ground wherever that is. +#[derive(Debug, Clone, PartialEq)] +pub struct GeoObject { + /// The mod object (`":"`). + pub object: String, + /// Where it stands [deg]; the height comes from the terrain. + pub lat: f64, + pub lon: f64, + /// Which way the model's front looks [deg, clockwise from north]. + pub yaw_deg: f64, + /// Uniform scale on the object's own size. + pub scale: f64, +} + +impl GeoObject { + /// What a geo-positioned object carries as its placement index: it is + /// nobody's entry in `objects:`, and the editor must not select one there. + pub const INDEX: u32 = u32::MAX; } /// One tree instance on a tile — a hand-placed [`TreeSource`] or one grown out @@ -451,7 +479,9 @@ impl Vegetation { /// those and there is no reason to build it a second time. So the masts /// come in here, and everything downstream — bucketing, streaming, /// instanced draws, levels of detail — treats a Donaumast as it treats a - /// spruce (see [`crate::power::masts`]). + /// spruce (see [`crate::power::masts`]). A wind turbine is **not** one of + /// these: its rotor turns and its nacelle yaws, and a thing with moving + /// parts is a scene ([`Scenery::from_line`]). pub fn from_line(line: &LineSource, zone: u8) -> Self { let mut sources = line.trees.clone(); sources.extend(crate::power::masts(&line.power_lines)); @@ -579,11 +609,45 @@ struct PlacedObject { snap: bool, object: u16, index: u32, + scale: f64, } impl Scenery { + /// The line's placed objects **and** its wind turbines — the turbines + /// are geo-positioned scenery, and they join here so that the app and the + /// editor get them through the one path there is. pub fn from_line(line: &LineSource, net: &TrackNetwork, zone: u8) -> Self { - Self::from_parts(&line.objects, net, zone) + let mut scenery = Self::from_parts(&line.objects, net, zone); + scenery.add_geo(&crate::wind::placements(&line.wind_turbines), zone); + scenery + } + + /// Adds objects that stand at a point on the earth: on the ground there, + /// the front turned to the bearing they name, scaled as they say. + pub fn add_geo(&mut self, objects: &[GeoObject], zone: u8) { + for placement in objects { + let (lat, lon) = (placement.lat.to_radians(), placement.lon.to_radians()); + let base = geo::to_ecef(lat, lon, 0.0); + let frame = EnuFrame::at(base); + // Clockwise from north, seen from above: 90° is east. + let (sin, cos) = placement.yaw_deg.to_radians().sin_cos(); + let dir = frame.north * cos + frame.east * sin; + let (e, n) = geo::to_utm(lat, lon, zone); + let Some(object) = intern(&mut self.objects, &placement.object) else { + continue; + }; + self.placed.push(PlacedObject { + pos: DVec2::new(e, n), + base, + up: frame.up, + dir, + height: 0.0, + snap: true, + object, + index: GeoObject::INDEX, + scale: placement.scale, + }); + } } pub fn from_parts(placements: &[ObjectSource], net: &TrackNetwork, zone: u8) -> Self { @@ -616,6 +680,7 @@ impl Scenery { object: intern(&mut objects, &placement.object) .unwrap_or_else(|| intern(&mut objects, "").unwrap_or(0)), index: index as u32, + scale: 1.0, }) }) .collect(); @@ -1721,6 +1786,7 @@ fn scatter_objects( rotation: model_rotation(frame, object.dir, object.up).to_array(), object: object.object, index: object.index, + scale: object.scale as f32, } }) .collect() diff --git a/crates/content/src/wind.rs b/crates/content/src/wind.rs new file mode 100644 index 00000000..9b84a73b --- /dev/null +++ b/crates/content/src/wind.rs @@ -0,0 +1,577 @@ +//! Wind turbines: where they stand, what they are, and how big to build them. +//! +//! A [`crate::route::WindTurbineSource`] is a point on the ground plus the two +//! numbers a viewer perceives — the hub height and the rotor diameter. The +//! machine's name comes with it, but only as a label: from a train, a 2 MW +//! Enercon and a 2 MW Vestas are a white tube with three blades on it, and what +//! tells them apart at that distance is how tall and how wide they are. +//! +//! **Two sources, and each answers what the other cannot.** OpenStreetMap has +//! the position, surveyed by people who walked past it, and about a third of +//! the machines' names; the Bundesnetzagentur's Marktstammdatenregister has the +//! manufacturer, the type, the hub height and the rotor diameter of every unit +//! in the country, and a position from the permit. So the import takes the +//! geometry from OSM and the machine from the register, matched by the +//! `ref:mastr` the mappers write or, failing that, by distance +//! ([`match_register`]). In a test box of 78 turbines, 75 carried the reference +//! and the nearest-unit match agreed with every one of them. +//! +//! **A turbine is a scenery object, not a tree.** A mast rides in with the +//! vegetation because it never moves; a turbine's nacelle yaws with the wind +//! and its rotor turns, and a thing with moving parts has to be a scene with +//! named nodes rather than a flattened instance. So [`placements`] hands the +//! turbines to the tile pipeline as geo-positioned scenery +//! ([`crate::terrain::GeoObject`]), the same path a hut or a signal box takes, +//! and `world_render::wind` moves the nodes by name. +//! +//! **The models are `mods/wind`**, generated from `tools/wind/wind.json`: one +//! per size class and build, four levels of detail each. [`PRESETS`] is a copy +//! of the catalogue's dimensions, so a class picks the model built nearest to +//! the machine and the placement scales it to the machine; the build follows +//! the maker — Enercon's drop nacelle and green tower foot, a lattice tower +//! under a Fuhrländer, the box nacelle everyone else builds ([`object_for`]). +//! +//! Nothing here is simulated. A wind turbine is scenery: no state, nothing to +//! replicate, and both clients of a multiplayer run build the same turbines out +//! of the same line file. Even the movement is not sent — a rotor turns because +//! the weather says so (`sim_core::weather::Weather`), and the weather is +//! already shared. + +use crate::route::WindTurbineSource; +use crate::terrain::GeoObject; +use fields::mastr::WindUnit; + +/// Which way the nacelles look [deg from north, clockwise] — the direction the +/// rotor faces into, which is the direction the wind comes *from*. +/// +/// Nothing surveys this. A turbine yaws into the wind and keeps no direction of +/// its own, so there is no true value to import; what there is, is a +/// prevailing wind, and over Germany it is a westerly to south-westerly. 250° +/// is the middle of that. +/// +/// It is the same value for every turbine of an import on purpose. Wind is a +/// weather-scale thing: every machine within sight of a train stands in the +/// same air and points the same way, and a park whose rotors face at random is +/// the one thing a viewer reads as wrong immediately. +pub const PREVAILING_BEARING: f64 = 250.0; + +/// How far a turbine may be from a register unit and still be the same machine +/// [m]. +/// +/// The two positions are a survey and a permit drawing, and in a test box of 78 +/// turbines they differed by 1.9 m in the median and 16 m at the ninth decile, +/// with one outlier at 98 m. German turbines stand hundreds of metres apart — +/// the rotors would otherwise take each other's wind — so a hundred metres is +/// wide enough for the outlier and far too narrow to reach the neighbour. +const MATCH_RADIUS: f64 = 100.0; + +/// The rotor a machine has to reach to count as a wind turbine of the kind a +/// landscape is made of [m]. +/// +/// Below it is a Kleinwindanlage: the mast in a farmyard or beside a workshop, +/// a few tens of kilowatts and under thirty metres to the tip. There are many +/// of them, they are furniture rather than landscape, and a module usually +/// wants the ones that stand on the horizon — so the import leaves them out +/// unless it is asked for them. The smallest machine in the register's coastal +/// box is a 50 kW one with a 15 m rotor. +pub const SMALL_ROTOR: f64 = 20.0; + +/// Whether a turbine is one of the small ones — see [`SMALL_ROTOR`]. +pub fn is_small(turbine: &WindTurbineSource) -> bool { + turbine.rotor_diameter < SMALL_ROTOR +} + +/// A size class of turbine: what the model is built at, and which machines land +/// on it. +/// +/// The classes are the German fleet's own generations, and the dimensions are +/// the medians measured in the register over three regions (the Dithmarschen +/// coast, the Magdeburger Börde and the Hunsrück): the 1990s machines around a +/// 50 m rotor, the 2000s workhorses around 80 m, the 2010s around 115 m and +/// what is being built now around 150 m and up. `tools/wind/wind.json` is the +/// same table, and the models are built at exactly these numbers. +#[derive(Debug, Clone, Copy)] +pub struct WindPreset { + /// The class id, as it goes into the source's tags (`wea-115`). + pub id: &'static str, + /// The object stem (`"wind:wea_115"`); the build's suffix goes on the end + /// (see [`object_for`]). + pub object: &'static str, + /// Hub height the model is built at [m]. + pub hub: f64, + /// Rotor diameter the model is built at [m]. + pub rotor: f64, + /// The largest rotor diameter that still lands on this class [m]; the last + /// class takes everything above. + pub up_to: f64, + /// Whether the class is built on a lattice tower as well — only the small + /// generations were. + pub lattice: bool, +} + +/// The size classes, smallest first — `mods/wind`, one file per class and +/// build. +pub const PRESETS: &[WindPreset] = &[ + WindPreset { + id: "wea-50", + object: "wind:wea_50", + hub: 65.0, + rotor: 50.0, + up_to: 60.0, + lattice: true, + }, + WindPreset { + id: "wea-80", + object: "wind:wea_80", + hub: 95.0, + rotor: 80.0, + up_to: 100.0, + lattice: true, + }, + WindPreset { + id: "wea-115", + object: "wind:wea_115", + hub: 125.0, + rotor: 115.0, + up_to: 130.0, + lattice: false, + }, + WindPreset { + id: "wea-150", + object: "wind:wea_150", + hub: 140.0, + rotor: 150.0, + up_to: f64::INFINITY, + lattice: false, + }, +]; + +/// The build of a class a machine gets, by who made it. +/// +/// From a train the makers differ in one thing each: Enercon's nacelle is a +/// drop and its tower foot is ringed in green, and a Fuhrländer of the +/// nineties stands on a lattice tower. Everyone else — Vestas, Nordex, +/// Senvion, GE, Siemens — builds a box on a tube, and the box is the default +/// for a machine nobody could name. +pub fn object_for(class: &WindPreset, model: &str) -> String { + let name = model.to_lowercase(); + let build = if name.contains("enercon") { + "enercon" + } else if class.lattice && name.contains("fuhrl") { + "gitter" + } else { + "standard" + }; + format!("{}_{build}", class.object) +} + +/// The class of an id. +pub fn preset(id: &str) -> Option<&'static WindPreset> { + PRESETS.iter().find(|p| p.id == id) +} + +/// The class a rotor of this size lands on. The rotor decides, not the tower: +/// two machines with the same rotor are the same generation of machine however +/// high the site made them build it. +pub fn class_for(rotor_diameter: f64) -> &'static WindPreset { + PRESETS + .iter() + .find(|p| rotor_diameter <= p.up_to) + .unwrap_or(&PRESETS[PRESETS.len() - 1]) +} + +/// The dimensions a turbine of this rated power has [m], `(hub, rotor)` — the +/// fallback for a machine neither source gave numbers for. +/// +/// The rotor comes out of the **specific power**, the rated power per square +/// metre of swept area, which is what a turbine is designed around: over 387 +/// operating machines in the register it is 335 W/m² in the median and moves +/// between 290 and 480 across the whole fleet, from the 1990s 600 kW machines +/// to today's 6 MW ones. The tower then follows the rotor — `40 + 0.75 d` is +/// the middle of the three regional fits, which run from `29 + 0.64 d` on the +/// windy coast, where a short tower will do, to `60 + 0.70 d` in the Hunsrück, +/// where the machine has to reach over a forest. +/// +/// The spread of that is real: the same 112 m rotor sits at 94 m on the coast +/// and at 140 m in the low mountains. It is also why the register is asked at +/// all — with it, this is the answer for the odd unit that has no numbers, and +/// without it, it is the answer for most of them. +pub fn estimate(power_kw: f64) -> (f64, f64) { + const SPECIFIC_POWER: f64 = 335.0; + let rotor = if power_kw > 0.0 { + (4.0 * power_kw * 1000.0 / (std::f64::consts::PI * SPECIFIC_POWER)).sqrt() + } else { + // Nothing known at all: the machine the German landscape is fullest + // of, a 2 MW class turbine of the 2000s. + 80.0 + }; + (40.0 + 0.75 * rotor, rotor) +} + +/// A turbine source stamped from what the sources said — what the OSM import +/// produces and [`match_register`] corrects. +/// +/// `estimated` says the dimensions are worked out from the rated power rather +/// than known, and it goes into the tags so the file says which of its numbers +/// were surveyed. +pub fn source_from( + lat: f64, + lon: f64, + hub_height: f64, + rotor_diameter: f64, + model: String, + mastr: String, + estimated: bool, +) -> WindTurbineSource { + let class = class_for(rotor_diameter); + let mut tags = vec![class.id.to_string()]; + if estimated { + tags.push("estimated".to_string()); + } + WindTurbineSource { + lat, + lon, + hub_height, + rotor_diameter, + object: object_for(class, &model), + yaw_deg: PREVAILING_BEARING, + model, + mastr, + tags, + } +} + +/// What [`match_register`] made of the register's answer. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RegisterMatch { + /// Turbines the register named a machine for. + pub matched: usize, + /// Standing units of the register that no turbine claimed — the ones + /// OpenStreetMap has not mapped yet, or that stand outside the box the + /// turbines were read from. + pub spare: usize, +} + +/// Fills in what the register knows about the turbines: the machine's name, its +/// hub height and its rotor diameter, and with them the size class and the +/// object. +/// +/// A turbine that carries a `ref:mastr` from OpenStreetMap is matched on it and +/// on nothing else — the mapper read the number off the tower. The rest take +/// the nearest unit still free within [`MATCH_RADIUS`], and only a unit that is +/// still standing: a decommissioned one is why the register has a turbine the +/// map does not, not what the map is looking at. +/// +/// A turbine the register cannot answer for keeps what it came with, estimate +/// and all. +pub fn match_register(turbines: &mut [WindTurbineSource], units: &[WindUnit]) -> RegisterMatch { + let mut taken = vec![false; units.len()]; + let mut matched = 0; + + // The references first, so a proximity match cannot take a unit that a + // mapper has already pinned to another turbine. + for turbine in turbines.iter_mut().filter(|t| !t.mastr.is_empty()) { + if let Some(i) = units.iter().position(|u| u.mastr == turbine.mastr) + && !taken[i] + { + taken[i] = true; + apply(turbine, &units[i]); + matched += 1; + } + } + + for turbine in turbines.iter_mut() { + if turbine.tags.iter().any(|t| t == "mastr") { + continue; + } + let best = units + .iter() + .enumerate() + .filter(|(i, u)| !taken[*i] && u.status.standing()) + .map(|(i, u)| (metres(turbine.lat, turbine.lon, u.lat, u.lon), i)) + .filter(|(d, _)| *d <= MATCH_RADIUS) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((_, i)) = best { + taken[i] = true; + apply(turbine, &units[i]); + matched += 1; + } + } + + let spare = units + .iter() + .zip(&taken) + .filter(|(u, taken)| !**taken && u.status.standing()) + .count(); + RegisterMatch { matched, spare } +} + +/// Writes a register unit onto a turbine: the machine's name and number always, +/// the dimensions where the register has them, and with them the class and the +/// object the class names. +fn apply(turbine: &mut WindTurbineSource, unit: &WindUnit) { + turbine.mastr = unit.mastr.clone(); + let name = match (unit.manufacturer.as_str(), unit.model.as_str()) { + ("", "") => String::new(), + ("", model) => model.to_string(), + (make, "") => make.to_string(), + (make, model) => format!("{make} {model}"), + }; + if !name.is_empty() { + turbine.model = name; + } + let known = unit.hub_height > 0.0 && unit.rotor_diameter > 0.0; + if known { + turbine.hub_height = unit.hub_height; + turbine.rotor_diameter = unit.rotor_diameter; + turbine.tags.retain(|t| t != "estimated"); + } + let class = class_for(turbine.rotor_diameter); + turbine.object = object_for(class, &turbine.model); + turbine.tags.retain(|t| preset(t).is_none() && t != "mastr"); + turbine.tags.insert(0, class.id.to_string()); + turbine.tags.push("mastr".to_string()); +} + +/// The line's wind turbines, as geo-positioned scenery. +/// +/// A turbine without an object is passed over — a hand-edited file may say so +/// on purpose. The scale is the one that misses both dimensions by the same +/// share rather than getting one right and the other badly wrong: a placement +/// carries a single uniform scale, and a machine is never exactly its class — +/// a 101 m rotor on a 140 m tower is a class of 115 m and 125 m. So the +/// geometric mean of the two ratios decides ([`scale_of`]). +pub fn placements(list: &[WindTurbineSource]) -> Vec { + list.iter() + .filter(|t| !t.object.is_empty()) + .map(|t| GeoObject { + object: t.object.clone(), + lat: t.lat, + lon: t.lon, + yaw_deg: t.yaw_deg, + scale: scale_of(t), + }) + .collect() +} + +/// How much bigger than its class a turbine is — see [`turbines`]. +pub fn scale_of(turbine: &WindTurbineSource) -> f64 { + let class = class_for(turbine.rotor_diameter); + let hub = if turbine.hub_height > 0.0 { + turbine.hub_height / class.hub + } else { + 1.0 + }; + let rotor = if turbine.rotor_diameter > 0.0 { + turbine.rotor_diameter / class.rotor + } else { + 1.0 + }; + (hub * rotor).sqrt() +} + +/// The distance between two points [m]. Flat-earth over the hundred metres a +/// match may span, which is exact to a millimetre there. +fn metres(lat_a: f64, lon_a: f64, lat_b: f64, lon_b: f64) -> f64 { + const DEG: f64 = 111_320.0; + let mean = ((lat_a + lat_b) / 2.0).to_radians(); + let east = (lon_b - lon_a) * mean.cos() * DEG; + let north = (lat_b - lat_a) * DEG; + east.hypot(north) +} + +#[cfg(test)] +mod tests { + use super::*; + use fields::mastr::Status; + + fn unit(mastr: &str, lat: f64, lon: f64, hub: f64, rotor: f64, status: Status) -> WindUnit { + WindUnit { + mastr: mastr.to_string(), + lat, + lon, + manufacturer: "Enercon".into(), + model: "E-115 EP3".into(), + hub_height: hub, + rotor_diameter: rotor, + power_kw: 3000.0, + status, + park: String::new(), + } + } + + #[test] + fn the_rotor_picks_the_class() { + assert_eq!(class_for(44.0).id, "wea-50"); + assert_eq!(class_for(60.0).id, "wea-50"); + assert_eq!(class_for(82.0).id, "wea-80"); + assert_eq!(class_for(112.0).id, "wea-115"); + assert_eq!(class_for(149.0).id, "wea-150"); + assert_eq!(class_for(240.0).id, "wea-150"); + // A machine of no known size is still a machine. + assert_eq!(class_for(0.0).id, "wea-50"); + } + + #[test] + fn a_rated_power_gives_a_size_worth_believing() { + // The register's own numbers for these machines: a 2 MW class turbine + // has a rotor of 70 to 82 m, a 3.45 MW one 112 m, a 5.7 MW one 149 m. + let (hub, rotor) = estimate(2000.0); + assert!((70.0..90.0).contains(&rotor), "2 MW rotor {rotor}"); + assert!((90.0..110.0).contains(&hub), "2 MW hub {hub}"); + let (_, rotor) = estimate(3450.0); + assert!((100.0..120.0).contains(&rotor), "3.45 MW rotor {rotor}"); + let (_, rotor) = estimate(5700.0); + assert!((135.0..155.0).contains(&rotor), "5.7 MW rotor {rotor}"); + // Nothing known: the machine the country is fullest of. + assert_eq!(estimate(0.0).1, 80.0); + } + + #[test] + fn the_register_names_the_machine_by_reference() { + let mut turbines = vec![source_from( + 52.0, + 10.0, + 95.0, + 80.0, + String::new(), + "SEE1".into(), + true, + )]; + // The unit sits far away — the reference is what matches it, not the + // distance, because a mapper read the number off the tower. + let units = vec![unit("SEE1", 52.05, 10.05, 149.0, 115.0, Status::Operating)]; + let report = match_register(&mut turbines, &units); + assert_eq!(report.matched, 1); + assert_eq!(report.spare, 0); + let turbine = &turbines[0]; + assert_eq!(turbine.model, "Enercon E-115 EP3"); + assert_eq!(turbine.hub_height, 149.0); + assert_eq!(turbine.rotor_diameter, 115.0); + assert_eq!(turbine.tags, vec!["wea-115", "mastr"]); + } + + #[test] + fn without_a_reference_the_nearest_standing_unit_answers() { + let mut turbines = vec![source_from( + 52.0, + 10.0, + 95.0, + 80.0, + String::new(), + String::new(), + true, + )]; + let units = vec![ + // Twenty metres away, but taken down: the map is not looking at it. + unit("SEE-old", 52.0002, 10.0, 65.0, 48.0, Status::Decommissioned), + // Forty metres away and turning. + unit("SEE-now", 52.0004, 10.0, 125.0, 112.0, Status::Operating), + // A kilometre away — the next turbine's business, not this one's. + unit("SEE-far", 52.01, 10.0, 125.0, 112.0, Status::Operating), + ]; + let report = match_register(&mut turbines, &units); + assert_eq!(report.matched, 1); + assert_eq!(turbines[0].mastr, "SEE-now"); + assert_eq!(turbines[0].rotor_diameter, 112.0); + // The one standing unit nobody claimed; the decommissioned one is not + // counted, because it is not there. + assert_eq!(report.spare, 1); + } + + #[test] + fn a_unit_is_claimed_once() { + let mut turbines = vec![ + source_from(52.0, 10.0, 95.0, 80.0, String::new(), String::new(), true), + source_from( + 52.0002, + 10.0, + 95.0, + 80.0, + String::new(), + String::new(), + true, + ), + ]; + let units = vec![unit( + "SEE-1", + 52.0001, + 10.0, + 125.0, + 112.0, + Status::Operating, + )]; + let report = match_register(&mut turbines, &units); + assert_eq!(report.matched, 1); + assert_eq!(turbines[0].mastr, "SEE-1"); + assert!(turbines[1].mastr.is_empty()); + // The one that found nothing keeps what it came with. + assert!(turbines[1].tags.iter().any(|t| t == "estimated")); + } + + #[test] + fn the_maker_picks_the_build() { + let big = preset("wea-115").expect("class"); + assert_eq!(object_for(big, "Enercon E-101"), "wind:wea_115_enercon"); + assert_eq!(object_for(big, "Vestas V112"), "wind:wea_115_standard"); + assert_eq!(object_for(big, ""), "wind:wea_115_standard"); + // A lattice tower is a thing of the small generations only. + let small = preset("wea-50").expect("class"); + assert_eq!(object_for(small, "Fuhrländer FL 600"), "wind:wea_50_gitter"); + assert_eq!( + object_for(big, "Fuhrländer FL 2500"), + "wind:wea_115_standard" + ); + } + + #[test] + fn a_turbine_is_placed_as_scenery() { + let turbines_in = vec![source_from( + 52.0, + 10.0, + 125.0, + 112.0, + "Vestas V112".into(), + String::new(), + false, + )]; + // The placement is a geo-positioned scenery object — the scale off + // the class, the yaw into the wind. + let placed = placements(&turbines_in); + assert_eq!(placed.len(), 1); + assert_eq!(placed[0].object, "wind:wea_115_standard"); + assert_eq!(placed[0].yaw_deg, PREVAILING_BEARING); + assert!( + (placed[0].scale - 0.987).abs() < 0.01, + "{}", + placed[0].scale + ); + + // A file that names no object places nothing. + let mut bare = turbines_in.clone(); + bare[0].object.clear(); + assert!(placements(&bare).is_empty()); + } + + #[test] + fn a_machine_bigger_than_its_class_is_drawn_bigger() { + let small = source_from(52.0, 10.0, 95.0, 80.0, String::new(), String::new(), false); + let tall = source_from( + 52.0, + 10.0, + 140.0, + 100.0, + String::new(), + String::new(), + false, + ); + assert!(scale_of(&tall) > scale_of(&small)); + // The mean of the two ratios, not one of them: 140/95 is 1.47 and + // 100/80 is 1.25, and neither alone is the answer. + assert!( + (scale_of(&tall) - 1.356).abs() < 0.01, + "{}", + scale_of(&tall) + ); + } +} diff --git a/crates/content/tests/wind_turbines.rs b/crates/content/tests/wind_turbines.rs new file mode 100644 index 00000000..13d5b80f --- /dev/null +++ b/crates/content/tests/wind_turbines.rs @@ -0,0 +1,165 @@ +//! Acceptance test of the wind turbines: a line file with `wind_turbines:` in +//! it reads back whole, and the entries reach the pipeline that stands them +//! up — as scenery, each one naming a model the `wind` mod actually ships. +//! +//! The unit tests in `content::wind` check the pieces — the classes, the +//! register match, the scale. This one checks that the pieces are wired to the +//! pipeline, which is the part that silently does nothing when a builder step +//! is forgotten: a turbine has to reach [`content::terrain::Scenery`] the way a +//! placed hut does, and the object it names has to exist on disk. + +use content::route::{LineSource, WindTurbineSource}; +use content::terrain::Scenery; + +/// One machine of each generation, as an import writes them. +fn turbines() -> Vec { + vec![ + // A 1990s machine on a lattice tower. + content::wind::source_from( + 52.0, + 10.0, + 65.0, + 44.0, + "Fuhrländer FL 600".into(), + String::new(), + false, + ), + // The 2000s workhorse. + content::wind::source_from( + 52.001, + 10.0, + 95.0, + 82.0, + "REpower MM82".into(), + String::new(), + false, + ), + // What is being built now, and the one drop-shaped nacelle. + content::wind::source_from( + 52.002, + 10.0, + 149.0, + 138.0, + "Enercon E-138 EP3".into(), + String::new(), + false, + ), + ] +} + +/// A line file written with turbines reads back with all of them, and the +/// numbers survive the round trip — a module is the only place they are kept. +#[test] +fn the_turbines_survive_the_line_file() { + let line = LineSource { + wind_turbines: turbines(), + ..LineSource::default() + }; + let text = ron::ser::to_string_pretty(&line, ron::ser::PrettyConfig::default()) + .expect("a line serialises"); + let read: LineSource = ron::from_str(&text).expect("and reads back"); + + assert_eq!(read.wind_turbines.len(), 3); + let mm82 = &read.wind_turbines[1]; + assert_eq!(mm82.model, "REpower MM82"); + assert_eq!(mm82.hub_height, 95.0); + assert_eq!(mm82.rotor_diameter, 82.0); + assert_eq!(mm82.tags, vec!["wea-80"]); + assert_eq!(mm82.object, "wind:wea_80_standard"); + // The nacelle looks into the prevailing wind, and every machine of one + // import looks the same way. + assert!( + read.wind_turbines + .iter() + .all(|t| t.yaw_deg == content::wind::PREVAILING_BEARING) + ); +} + +/// Every turbine of a line becomes a scenery object on the terrain, and every +/// object it names is a file the `wind` mod ships. +#[test] +fn every_turbine_is_scenery_the_mod_can_show() { + let line = LineSource { + wind_turbines: turbines(), + ..LineSource::default() + }; + // Any track will do: the turbines are geo-positioned and never ask it. + let net = content::musterbahn() + .compile() + .expect("the example line compiles") + .net; + let scenery = Scenery::from_line(&line, &net, 32); + assert_eq!( + scenery.objects(), + [ + "wind:wea_50_gitter", + "wind:wea_80_standard", + "wind:wea_150_enercon" + ] + ); + + let objects = concat!(env!("CARGO_MANIFEST_DIR"), "/../../mods/wind/objects"); + for name in scenery.objects() { + let stem = name.strip_prefix("wind:").expect("mod-qualified object"); + let path = std::path::Path::new(objects).join(format!("{stem}.ron")); + assert!( + path.exists(), + "{name} has no object file at {}", + path.display() + ); + let text = std::fs::read_to_string(&path).expect("readable"); + let object: track_model::TrackObject = ron::from_str(&text).expect("parses"); + assert_eq!(object.lod_distances.len(), 4, "{name}: four levels"); + let model = std::path::Path::new(objects) + .join("..") + .join("..") + .join(&object.model); + assert!( + model.exists(), + "{name}: model {} is missing", + model.display() + ); + } +} + +/// Every model the mod ships carries the two nodes the game moves, and the +/// rotor says how big and how fast it is. +#[test] +fn every_model_has_its_moving_parts() { + let assets = concat!(env!("CARGO_MANIFEST_DIR"), "/../../mods/wind/assets"); + let mut models = 0; + for entry in std::fs::read_dir(assets).expect("the wind mod is in the repository") { + let path = entry.expect("entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("gltf") { + continue; + } + models += 1; + let text = std::fs::read_to_string(&path).expect("readable"); + let gltf: serde_json::Value = serde_json::from_str(&text).expect("json"); + let nodes = gltf["nodes"].as_array().expect("nodes"); + let named = |name: &str| nodes.iter().find(|n| n["name"] == name); + assert!( + named("nacelle").is_some(), + "{}: no nacelle node", + path.display() + ); + let rotor = named("rotor").unwrap_or_else(|| panic!("{}: no rotor node", path.display())); + let extras = &rotor["extras"]; + assert!(extras["rotor_diameter"].as_f64().unwrap_or(0.0) > 10.0); + assert!(extras["rated_rpm"].as_f64().unwrap_or(0.0) > 5.0); + assert!(extras["hub_height"].as_f64().unwrap_or(0.0) > 30.0); + for level in 0..4 { + assert!( + named(&format!("rotor_LOD{level}")).is_some(), + "{}: rotor level {level}", + path.display() + ); + assert!( + named(&format!("turm_LOD{level}")).is_some(), + "{}: tower level {level}", + path.display() + ); + } + } + assert_eq!(models, 10, "four classes, ten builds"); +} diff --git a/crates/fields/src/lib.rs b/crates/fields/src/lib.rs index d5d44e7c..74d23932 100644 --- a/crates/fields/src/lib.rs +++ b/crates/fields/src/lib.rs @@ -24,6 +24,13 @@ //! ([`geometry`]) and hand back [`FieldFeature`]s. Nothing here writes to a //! line — the editor shows what came back and the user commits it. //! +//! The crate is where the register clients live, and the field registers are +//! not the only one: [`mastr`] asks the Bundesnetzagentur's +//! Marktstammdatenregister what wind turbine stands at a point, because +//! OpenStreetMap surveys where they stand and the register knows what they are +//! (`content::wind`). [`osm`] is here for the same reason — a fetcher belongs +//! with the other fetchers. +//! //! No Bevy and no ECS: this is a fetch-and-convert library, the same way //! [`imagery`](../imagery/index.html) is, and the editor is what hooks it up. @@ -33,6 +40,7 @@ pub mod crops; pub mod geometry; pub mod import; pub mod land; +pub mod mastr; pub mod model; pub mod osm; pub mod phenology; @@ -45,6 +53,7 @@ pub use crops::{CropClass, CropTable}; pub use import::{Area, Clip, ImportOptions, ImportProgress, ImportReport, Stage}; pub use land::Land; pub use land::{Access, Level as DataLevel, Licence, Service}; +pub use mastr::{Status as UnitStatus, WindUnit}; pub use model::{FieldFeature, Level}; pub use phenology::{Growth, Stage as GrowthStage}; pub use wfs::{RequestConfig, ServiceError}; diff --git a/crates/fields/src/mastr.rs b/crates/fields/src/mastr.rs new file mode 100644 index 00000000..92fd7210 --- /dev/null +++ b/crates/fields/src/mastr.rs @@ -0,0 +1,417 @@ +//! What actually stands there: the Marktstammdatenregister. +//! +//! OpenStreetMap knows *where* a wind turbine stands — surveyed by people who +//! walked past it — but rarely *what* it is. Over two German boxes of a +//! thousand turbines the mappers wrote `manufacturer` on a third to a half of +//! them, `model` on about the same, `height:hub` on a fifth and +//! `rotor:diameter` on one in fourteen. A turbine without a hub height and a +//! rotor diameter is a guess at the two numbers a viewer actually perceives. +//! +//! The Bundesnetzagentur's register has both, for every unit in the country: +//! every generating plant in Germany has to be registered with its +//! manufacturer, type designation, hub height, rotor diameter, rated power, +//! commissioning date and coordinates (§ 3 MaStRV), and the register publishes +//! that. In the box the numbers above come from, all 105 units carried a hub +//! height and a rotor diameter. +//! +//! What is asked here is the *extended public unit data*, the same JSON the +//! register's own web front end reads. It is open, it takes no key, and it +//! filters on the WGS84 coordinates — so a module's envelope box is one query. +//! Compare that with the field registers of [`crate::wfs`]: the shape of the +//! request differs, the deal is the same. +//! +//! **Licence.** The register is published as open data (dl-de/by-2-0, +//! Bundesnetzagentur, Marktstammdatenregister). A module built on it carries +//! the source note, like a module built on a state's DGM does. +//! +//! Nothing here decides anything: this fetches rows and hands them over. +//! Matching them to what OSM surveyed, and turning a machine into something to +//! look at, is `content::wind`'s business. + +use crate::wfs::{RequestConfig, ServiceError, encode}; +use serde::Deserialize; + +/// The register's extended public data on generating units — what its own grid +/// view reads. +pub const REGISTER: &str = "https://www.marktstammdatenregister.de/MaStR/Einheit/EinheitJson/GetErweiterteOeffentlicheEinheitStromerzeugung"; + +/// The register's own key for wind as the energy carrier. The filter takes the +/// key, not the word, and the keys are the register's stable ones (`2497` is +/// wind, `2495` solar). +const WIND: &str = "2497"; + +/// Rows per request. The register answers 500 in about a second and some two +/// megabytes; a module's box holds far fewer, and the page loop below is for +/// the one that does not. +const PAGE: usize = 500; + +/// How many pages are ever asked for. Ten of them is five thousand turbines, +/// which no module envelope holds — the cap is there so a filter that fails to +/// narrow anything cannot walk the whole country. +const MAX_PAGES: usize = 10; + +/// What the register says the state of a unit is. +/// +/// The numbers are the register's own (`Betriebs-Status`), which is why they +/// are matched rather than the German words beside them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Approved and not built yet — nothing stands there. + Planned, + /// Turning. + Operating, + /// Shut down for the time being; the machine is still up. + Suspended, + /// Taken down for good. + Decommissioned, + /// A code the register has added since. + Unknown, +} + +impl Status { + fn of(id: i64) -> Self { + match id { + 31 => Status::Planned, + 35 => Status::Operating, + 37 => Status::Suspended, + 38 => Status::Decommissioned, + _ => Status::Unknown, + } + } + + /// Whether the machine is physically there — which is the only question a + /// landscape asks. A unit shut down for the season still stands. + pub fn standing(self) -> bool { + matches!( + self, + Status::Operating | Status::Suspended | Status::Unknown + ) + } +} + +/// One wind turbine as the register holds it. +#[derive(Debug, Clone, PartialEq)] +pub struct WindUnit { + /// The unit's MaStR number (`SEE945374201878`) — what OSM's `ref:mastr` + /// carries, and what a builder can look the machine up under. + pub mastr: String, + /// Where the register puts it [deg]. + pub lat: f64, + pub lon: f64, + /// Manufacturer, cleaned of its company form (`ENERCON GmbH` → `Enercon`); + /// empty where the register says `Sonstige` or nothing. + pub manufacturer: String, + /// The type designation as the register holds it (`E-70 E4`, `V112`), + /// cleaned of a repeated manufacturer and of doubled spaces. Free text — + /// the same machine is `E70 E4`, `E-70/4` and `Enercon E-70` in three + /// entries — so it is a label, not a key. + pub model: String, + /// Hub height over ground [m]; 0 where the register has none. + pub hub_height: f64, + /// Rotor diameter [m]; 0 where the register has none. + pub rotor_diameter: f64, + /// Rated power [kW] — the register's gross figure. + pub power_kw: f64, + pub status: Status, + /// Name of the wind farm the unit belongs to; empty for a lone turbine. + pub park: String, +} + +/// One row of the register's answer. Everything is optional: the grid serves +/// one row shape for solar roofs, biogas plants and wind turbines alike, so +/// most of a wind unit's columns are null on everything else and the other way +/// round. +#[derive(Debug, Deserialize)] +struct Row { + #[serde(rename = "MaStRNummer")] + mastr: Option, + #[serde(rename = "Breitengrad")] + lat: Option, + #[serde(rename = "Laengengrad")] + lon: Option, + #[serde(rename = "HerstellerWindenergieanlageBezeichnung")] + manufacturer: Option, + #[serde(rename = "Typenbezeichnung")] + model: Option, + #[serde(rename = "NabenhoeheWindenergieanlage")] + hub_height: Option, + #[serde(rename = "RotordurchmesserWindenergieanlage")] + rotor_diameter: Option, + #[serde(rename = "Bruttoleistung")] + power_kw: Option, + #[serde(rename = "BetriebsStatusId")] + status: Option, + #[serde(rename = "WindparkName")] + park: Option, +} + +#[derive(Debug, Deserialize)] +struct Answer { + #[serde(rename = "Data")] + data: Vec, + /// How many rows the filter matches in total, however many this page holds. + #[serde(rename = "Total")] + total: Option, +} + +/// The URL for one page of the wind turbines in a box. +/// +/// The filter language is the register's own: +/// `field~operator~'value'~and~field~operator~'value'`, with the fields named +/// as the front end shows them — umlauts, spaces and all, which is why every +/// value goes through [`encode`]. +pub fn wind_url( + min_lat: f64, + min_lon: f64, + max_lat: f64, + max_lon: f64, + page: usize, + page_size: usize, +) -> String { + let filter = format!( + "Energieträger~eq~'{WIND}'\ + ~and~Koordinate: Breitengrad (WGS84)~gt~'{min_lat:.6}'\ + ~and~Koordinate: Breitengrad (WGS84)~lt~'{max_lat:.6}'\ + ~and~Koordinate: Längengrad (WGS84)~gt~'{min_lon:.6}'\ + ~and~Koordinate: Längengrad (WGS84)~lt~'{max_lon:.6}'" + ); + format!( + "{REGISTER}?filter={}&page={page}&pageSize={page_size}", + encode(&filter) + ) +} + +/// Every wind turbine the register holds in a box, whatever its state — a unit +/// that has been taken down is worth knowing about, because it is why OSM has +/// a turbine there and the register does not, or the other way round. +pub fn fetch_wind( + min_lat: f64, + min_lon: f64, + max_lat: f64, + max_lon: f64, + config: &RequestConfig, +) -> Result, ServiceError> { + let mut out = Vec::new(); + for page in 1..=MAX_PAGES { + let url = wind_url(min_lat, min_lon, max_lat, max_lon, page, PAGE); + let (units, rows, total) = parse_page(&get(&url, config)?)?; + out.extend(units); + // The count the register sends is what the *filter* matches, and the + // rows are what this page held — the loop stops on either, and on + // neither the number of units, because a page can be short of units + // and full of rows when some carried no coordinates. + let done = match total { + Some(total) => page * PAGE >= total, + None => rows < PAGE, + }; + if done || rows == 0 { + break; + } + } + Ok(out) +} + +/// Reads one page of the register's answer: the units it holds and how many +/// the filter matches in all. +/// +/// A row without coordinates or without a MaStR number is dropped — there is +/// nothing to put on the ground and nothing to match it by. +pub fn parse_wind(json: &str) -> Result<(Vec, Option), ServiceError> { + let (units, _, total) = parse_page(json)?; + Ok((units, total)) +} + +/// The same, plus how many rows the page held before the ones with nothing to +/// place were dropped — which is what [`fetch_wind`]'s page loop counts. +fn parse_page(json: &str) -> Result<(Vec, usize, Option), ServiceError> { + let answer: Answer = + serde_json::from_str(json).map_err(|e| ServiceError::NotGeoJson(e.to_string()))?; + let rows = answer.data.len(); + let units = answer + .data + .into_iter() + .filter_map(|row| { + let manufacturer = clean_manufacturer(row.manufacturer.as_deref().unwrap_or("")); + Some(WindUnit { + mastr: row.mastr.filter(|s| !s.is_empty())?, + lat: row.lat?, + lon: row.lon?, + model: clean_model(row.model.as_deref().unwrap_or(""), &manufacturer), + manufacturer, + hub_height: row.hub_height.unwrap_or(0.0).max(0.0), + rotor_diameter: row.rotor_diameter.unwrap_or(0.0).max(0.0), + power_kw: row.power_kw.unwrap_or(0.0).max(0.0), + status: Status::of(row.status.unwrap_or(0)), + park: row.park.unwrap_or_default(), + }) + }) + .collect(); + Ok((units, rows, answer.total)) +} + +/// The manufacturer without its company form: the register writes +/// `ENERCON GmbH`, `Vestas Deutschland GmbH` and `REpower Systems SE` for what +/// a person calls Enercon, Vestas and REpower. `Sonstige` is the register's +/// "other", which says nothing, so it becomes nothing. +/// +/// The capitalisation is fixed as well — `ENERCON` is a logo, not a spelling, +/// and `Enercon` is what OpenStreetMap's mappers write, so the two sources +/// agree on the name. Only a word shouted in full is turned down; `GE` is too +/// short to be a shout and `REpower` was never one. +fn clean_manufacturer(raw: &str) -> String { + // Stripped until nothing comes off any more: the forms stack, and + // `VENSYS Energy AG` only loses its `Energy` once the `AG` is gone. + let mut name = raw.trim(); + loop { + let before = name; + for tail in [ + " GmbH & Co. KG", + " GmbH", + " AG", + " SE", + " KG", + " B.V.", + " A/S", + " Deutschland", + " Systems", + " Energy", + ] { + name = name.strip_suffix(tail).unwrap_or(name).trim_end(); + } + if name == before { + break; + } + } + let name = name.trim(); + if name.is_empty() || name.eq_ignore_ascii_case("Sonstige") { + return String::new(); + } + if name.len() > 2 && name.chars().all(|c| !c.is_lowercase()) { + let mut chars = name.chars(); + let head = chars.next().unwrap_or_default(); + return format!("{head}{}", chars.as_str().to_lowercase()); + } + name.to_string() +} + +/// The type designation, tidied: the doubled spaces the register is full of +/// collapsed, and a repeated manufacturer taken off the front, so +/// `Vestas V112` beside a manufacturer of `Vestas` is `V112` and the display +/// name does not say it twice. +fn clean_model(raw: &str, manufacturer: &str) -> String { + let mut model = raw.split_whitespace().collect::>().join(" "); + if !manufacturer.is_empty() { + let head = format!("{manufacturer} "); + if model.len() > head.len() && model[..head.len()].eq_ignore_ascii_case(&head) { + model = model[head.len()..].to_string(); + } + } + model +} + +/// Fetches a URL, refusing to read past [`RequestConfig::max_bytes`] — the same +/// guard [`crate::wfs`] fetches under, and for the same reason: a filter that +/// slips would otherwise start sending the register. +fn get(url: &str, config: &RequestConfig) -> Result { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(config.timeout)) + .user_agent(&config.user_agent) + .build() + .into(); + let mut response = agent + .get(url) + .call() + .map_err(|e| ServiceError::Network(e.to_string()))?; + let mut body = Vec::new(); + let read = std::io::Read::take(response.body_mut().as_reader(), config.max_bytes as u64 + 1); + std::io::copy(&mut std::io::BufReader::new(read), &mut body) + .map_err(|e| ServiceError::Network(e.to_string()))?; + if body.len() > config.max_bytes { + return Err(ServiceError::TooMuch); + } + String::from_utf8(body).map_err(|e| ServiceError::NotGeoJson(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One row as the register sent it, cut down to the columns that are read. + const SAMPLE: &str = r#"{"Data":[ + {"MaStRNummer":"SEE945374201878","Breitengrad":54.268364,"Laengengrad":9.013525, + "HerstellerWindenergieanlageBezeichnung":"ENERCON GmbH","Typenbezeichnung":"E-70 E4", + "NabenhoeheWindenergieanlage":65.0,"RotordurchmesserWindenergieanlage":71.0, + "Bruttoleistung":2300.0,"BetriebsStatusId":35,"WindparkName":"Hemme Jarrenwisch 9"}, + {"MaStRNummer":"SEE902056632382","Breitengrad":54.206056,"Laengengrad":9.005182, + "HerstellerWindenergieanlageBezeichnung":"Vestas Deutschland GmbH", + "Typenbezeichnung":"Vestas V112","NabenhoeheWindenergieanlage":119.0, + "RotordurchmesserWindenergieanlage":112.0,"Bruttoleistung":3450.0, + "BetriebsStatusId":38,"WindparkName":null}, + {"MaStRNummer":"SEE000000000000","Breitengrad":null,"Laengengrad":null, + "HerstellerWindenergieanlageBezeichnung":"Sonstige","Typenbezeichnung":null, + "BetriebsStatusId":31} + ],"Total":2}"#; + + #[test] + fn the_registers_rows_come_out_as_turbines() { + let (units, total) = parse_wind(SAMPLE).expect("parses"); + assert_eq!(total, Some(2)); + // The row without coordinates is dropped: there is nowhere to put it. + assert_eq!(units.len(), 2); + + let enercon = &units[0]; + assert_eq!(enercon.mastr, "SEE945374201878"); + assert_eq!(enercon.manufacturer, "Enercon"); + // The doubled space the register writes is collapsed. + assert_eq!(enercon.model, "E-70 E4"); + assert_eq!(enercon.hub_height, 65.0); + assert_eq!(enercon.rotor_diameter, 71.0); + assert_eq!(enercon.status, Status::Operating); + assert!(enercon.status.standing()); + + let vestas = &units[1]; + assert_eq!(vestas.manufacturer, "Vestas"); + // The manufacturer is not said twice. + assert_eq!(vestas.model, "V112"); + assert_eq!(vestas.status, Status::Decommissioned); + assert!(!vestas.status.standing()); + assert!(vestas.park.is_empty()); + } + + #[test] + fn the_company_form_is_not_part_of_the_name() { + assert_eq!(clean_manufacturer("ENERCON GmbH"), "Enercon"); + assert_eq!(clean_manufacturer("Vestas Deutschland GmbH"), "Vestas"); + assert_eq!(clean_manufacturer("REpower Systems SE"), "REpower"); + assert_eq!(clean_manufacturer("Nordex Energy GmbH"), "Nordex"); + assert_eq!(clean_manufacturer("VENSYS Energy AG"), "Vensys"); + assert_eq!(clean_manufacturer("GE Wind Energy GmbH"), "GE Wind"); + // The register's "other" is not a manufacturer. + assert_eq!(clean_manufacturer("Sonstige"), ""); + assert_eq!(clean_manufacturer(""), ""); + } + + #[test] + fn the_box_is_what_the_filter_asks_for() { + let url = wind_url(52.0, 10.0, 52.1, 10.2, 1, 500); + assert!(url.starts_with(REGISTER)); + assert!(url.ends_with("&page=1&pageSize=500")); + // Everything of the filter is encoded — the field names carry spaces, + // brackets and umlauts, and an unencoded one is a 400. + let filter = url + .split_once("?filter=") + .and_then(|(_, rest)| rest.split('&').next()) + .expect("has a filter"); + assert!(!filter.contains(' ')); + assert!(filter.contains("2497")); + assert!(filter.contains("52.000000")); + assert!(filter.contains("10.200000")); + } + + #[test] + fn an_answer_without_rows_is_not_an_error() { + let (units, total) = parse_wind(r#"{"Data":[],"Total":0}"#).expect("parses"); + assert!(units.is_empty()); + assert_eq!(total, Some(0)); + } +} diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index c7a5647b..c755d9f6 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -2808,6 +2808,26 @@ pylon-masttrafo-20kv = Masttransformatorstation (20/0.4 kV) pylon-holzmast-nsp = Niederspannungsmast, Holz (400 V) pylon-fernmeldemast-bahn = Streckenfernmeldemast +# Windenergieanlagen (Streckeneditor: Datei ▸ Windräder importieren…). Wo sie +# stehen, kommt aus OpenStreetMap, was dort steht, aus dem +# Marktstammdatenregister der Bundesnetzagentur. +action-import-wind = Windräder importieren… +wind-import-title = Windräder importieren +wind-import-intro = Fragt OpenStreetMap, wo die Windräder im Modulumschreibungs-Polygon stehen, und das Marktstammdatenregister, was dort jeweils steht — Hersteller, Typ, Nabenhöhe und Rotordurchmesser, die OpenStreetMap für etwa ein Drittel kennt und das Register für alle. Alles bleibt danach editierbar. +wind-import-register = Register fragen +wind-import-register-hint = Eine zweite Abfrage beim Marktstammdatenregister der Bundesnetzagentur, zugeordnet über die MaStR-Nummer oder über die Entfernung. Ohne sie werden die meisten Anlagen allein aus ihrer Leistung abgeschätzt. +wind-import-small = Kleinwindanlagen mitnehmen +wind-import-small-hint = Die Hofanlagen unter 20 m Rotordurchmesser. Viele, klein und selten das, worauf es einem Modul ankommt. +wind-import-no-models = Die Windradmodelle kommen noch: Die Anlagen werden mit ihren Maßen ins Modul geschrieben, aufs Gelände gestellt wird bis dahin nichts. +wind-import-fetching = OpenStreetMap wird gefragt +wind-import-parsing = Antwort wird gelesen +wind-import-asking-register = Marktstammdatenregister wird gefragt +wind-import-found = { $turbines } Windräder gefunden, { $named } vom Register benannt +wind-import-spare = { $count } weitere stehen im Register, aber nicht auf der Karte — sie bleiben weg +wind-import-machine = { $model } — Nabe { $hub } m, Rotor { $rotor } m +wind-import-unknown-machine = Anlage unbekannt +status-wind-imported = { $count } Windräder importiert — Maße und Anlage stehen im Modul, die Modelle kommen noch + # Luftbildauswertung mit einem lokalen Modell (Streckeneditor: Datei ▸ Aus # Luftbild erkennen… und das Werkzeug „KI-Bereich“). Die Modelle selbst liegen # nicht bei — ai.ron sagt, wo die Gewichte erwartet werden. diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index a9ff40b9..3af33797 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -2805,6 +2805,26 @@ pylon-masttrafo-20kv = Masttransformatorstation — Pole-mounted transformer, 20 pylon-holzmast-nsp = Niederspannungsmast, Holz — Low-voltage pole, wood, 400 V pylon-fernmeldemast-bahn = Streckenfernmeldemast — Lineside telegraph pole +# Wind turbines (route editor: File ▸ Import wind turbines…). Where they stand +# comes from OpenStreetMap, what they are from the Bundesnetzagentur's +# Marktstammdatenregister. +action-import-wind = Import wind turbines… +wind-import-title = Import wind turbines +wind-import-intro = Asks OpenStreetMap where the turbines of the module envelope stand, and the Marktstammdatenregister what each machine is — manufacturer, type, hub height and rotor diameter, which OpenStreetMap has for about a third of them and the register for all of them. Everything stays editable afterwards. +wind-import-register = Ask the register +wind-import-register-hint = A second request to the Bundesnetzagentur's Marktstammdatenregister, matched by the MaStR number or by distance. Without it, most turbines are sized from their rated power alone. +wind-import-small = Small turbines too +wind-import-small-hint = The farmyard machines under a 20 m rotor. Many, small, and rarely what a module is after. +wind-import-no-models = The turbine models are still to come: the machines are written into the module with their dimensions, and nothing stands on the terrain until the models are there. +wind-import-fetching = Asking OpenStreetMap +wind-import-parsing = Reading the answer +wind-import-asking-register = Asking the Marktstammdatenregister +wind-import-found = { $turbines } turbines found, { $named } named by the register +wind-import-spare = { $count } more stand in the register but not on the map — they are left out +wind-import-machine = { $model } — hub { $hub } m, rotor { $rotor } m +wind-import-unknown-machine = Machine unknown +status-wind-imported = { $count } wind turbines imported — dimensions and machine are in the module; the models are still to come + # Reading the imagery with a local model (route editor: File ▸ Detect from # imagery…, and the AI area tool). The models themselves are not shipped — # `ai.ron` says where each one's weights are expected. diff --git a/crates/route-editor/src/main.rs b/crates/route-editor/src/main.rs index 8dcdff96..714411ee 100644 --- a/crates/route-editor/src/main.rs +++ b/crates/route-editor/src/main.rs @@ -5,7 +5,7 @@ //! ```text //! trainsim-route-editor [line.ron] [--imagery ] [--frames N] [--height M] //! [--window WxH] [--drawer [objects|signal-types|signal-models|track-types]] -//! [--ai ] [--detect] [--at KM] +//! [--ai ] [--detect] [--import-wind] [--at KM] //! [--detect-run [--corridor M] [--keep-clear M] [--model ] //! [--stand ]] //! ``` @@ -37,6 +37,7 @@ mod tools; mod ui; mod view; mod walkways; +mod wind; use bevy::asset::RenderAssetUsages; use bevy::prelude::*; @@ -359,6 +360,8 @@ pub struct Request { pub import_roads: bool, /// Open the overhead line import dialog (menu, see [`power`]). pub import_power: bool, + /// Open the wind turbine import dialog (menu, see [`wind`]). + pub import_wind: bool, /// Open the dialog that reads the imagery with a local model (menu, see /// [`ai`]). pub detect_imagery: bool, @@ -391,6 +394,8 @@ fn main() { // reason `--drawer` opens the content drawer: a screenshot run has no // keyboard and no mouse, and a dialog nobody can open cannot be looked at. let detect = args.iter().any(|a| a == "--detect"); + // `--import-wind` opens the wind turbine import for the same reason. + let import_wind = args.iter().any(|a| a == "--import-wind"); // `--detect-run` does not open the dialog, it *is* the dialog: the run // along the track, committed, and the line written back. For a module // being rebuilt from its sources by a script, beside `import-module`. @@ -523,6 +528,11 @@ fn main() { .init_resource::() .init_resource::() .init_resource::() + .insert_resource(if import_wind { + wind::WindImport::opened() + } else { + wind::WindImport::default() + }) // Not through `Request`: `overlay_control` takes that whole resource once // per frame, and it runs before the pass the dialogs are drawn in — a flag // set at start-up would be gone before anyone read it. @@ -549,6 +559,7 @@ fn main() { fields::draw, roads::draw, power::draw, + wind::draw, ai::draw, ) .chain(), diff --git a/crates/route-editor/src/tools.rs b/crates/route-editor/src/tools.rs index 0d78d746..5ceae36d 100644 --- a/crates/route-editor/src/tools.rs +++ b/crates/route-editor/src/tools.rs @@ -23,7 +23,6 @@ use bevy::world_serialization::WorldAsset; use content::LineSource; use content::TerrainOptions; use content::import::alignment::{CantRules, ramp_cant}; -#[cfg(test)] use content::route::{ DeviceSource, EdgeSource, EdgeStart, FlankSource, GeoPoint, MarkerSource, NodeSource, ObjectSource, SignalSource, TerrainEdit, TerrainEditSource, TreeSource, diff --git a/crates/route-editor/src/ui.rs b/crates/route-editor/src/ui.rs index c095d163..cd9d069e 100644 --- a/crates/route-editor/src/ui.rs +++ b/crates/route-editor/src/ui.rs @@ -1117,6 +1117,7 @@ pub(crate) fn new_line( waters: vec![], roads: vec![], power_lines: vec![], + wind_turbines: vec![], terrain: vec![], heights: vec![], sections: vec![], @@ -1209,6 +1210,10 @@ fn menu_bar( ui.close(); request.import_power = true; } + if ui.button(t!("action-import-wind")).clicked() { + ui.close(); + request.import_wind = true; + } if ui.button(t!("action-detect-imagery")).clicked() { ui.close(); request.detect_imagery = true; diff --git a/crates/route-editor/src/wind.rs b/crates/route-editor/src/wind.rs new file mode 100644 index 00000000..e8af42f3 --- /dev/null +++ b/crates/route-editor/src/wind.rs @@ -0,0 +1,455 @@ +//! Wind turbines in the editor: the import. +//! +//! Like the road and the overhead line imports, and for the same reason — a +//! module in the Börde or in Dithmarschen has dozens of them, they stand on +//! the horizon of every shot, and both OpenStreetMap and the +//! Marktstammdatenregister have surveyed every one. So the import asks +//! Overpass for the module envelope's turbines +//! ([`content::import::parse_wind_turbines`]) and then the register for what +//! those machines are ([`fields::mastr`]), matches the two +//! ([`content::wind::match_register`]) and writes the result into the line. +//! +//! **The dialog has filters, not a list.** Two questions decide what an import +//! is worth: whether the farmyard machines come too — many, small, and rarely +//! what a module is after — and whether the register is asked at all, which +//! costs a second request and answers for the machine where OpenStreetMap's +//! mappers left the tags empty. Both are on the form before the start; the +//! report afterwards is the summary and the decision, like everywhere else. +//! +//! **Nothing stands up yet.** The turbine models are still to come, so every +//! entry lands in the line file with an empty object and the tile pipeline +//! passes over it (see [`content::wind`]). What the import writes is the whole +//! truth about each machine — where it stands, how high its hub is, how wide +//! its rotor, which machine it is and its number in the register — and the day +//! the models ship, `content::wind::PRESETS` names them and the turbines stand +//! up without another import. + +use crate::Line; +use crate::tools::{EditorState, Selection}; +use bevy::prelude::*; +use bevy_egui::{EguiContexts, egui}; +use content::RegisterMatch; +use content::route::WindTurbineSource; +use editor_ui::{colors, space}; +use fields::RequestConfig; +use i18n::t; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Mutex}; + +/// Width of the import dialog [px]. +const DIALOG: f32 = 460.0; + +/// What the import asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindOptions { + /// Take the small machines too (see [`content::wind::SMALL_ROTOR`]) — the + /// mast in a farmyard rather than the turbine on the horizon. Off by + /// default: they are many and they are furniture. + pub small: bool, + /// Ask the Marktstammdatenregister what the machines are. On by default — + /// it is the difference between a third of the turbines knowing their + /// dimensions and all of them. + pub register: bool, +} + +impl Default for WindOptions { + fn default() -> Self { + Self { + small: false, + register: true, + } + } +} + +/// What one import found. +struct Report { + turbines: Vec, + /// What the register answered for — zero when it was not asked. + matched: RegisterMatch, +} + +/// A running import: the thread's channels and the switch that stops it. +struct Job { + /// Behind a mutex so the dialog works as a Bevy resource — a `Receiver` + /// is `Send` but not `Sync`. + progress: Mutex>, + result: Mutex>>, + stop: Arc, +} + +/// The import dialog, and whatever it has found. +#[derive(Resource)] +pub struct WindImport { + pub open: bool, + pub options: WindOptions, + job: Option, + /// The last thing the thread said, redrawn every frame. + stage: &'static str, + /// The finished import, waiting for Commit. + report: Option, + /// What went wrong, if the dialog has something to say. + message: String, +} + +impl Default for WindImport { + fn default() -> Self { + Self { + open: false, + options: WindOptions::default(), + job: None, + stage: "wind-import-fetching", + report: None, + message: String::new(), + } + } +} + +impl WindImport { + /// Up from the first frame — what `--import-wind` inserts, so a screenshot + /// run can look at the dialog it has no keyboard to open. + pub fn opened() -> Self { + Self { + open: true, + ..Default::default() + } + } +} + +/// The envelope's box, south-west to north-east — what the queries ask for. +fn envelope_bbox(line: &Line) -> Option<(f64, f64, f64, f64)> { + let corners = &line.source.envelope; + (corners.len() >= 3).then(|| { + ( + corners.iter().map(|p| p.lat).fold(f64::MAX, f64::min), + corners.iter().map(|p| p.lon).fold(f64::MAX, f64::min), + corners.iter().map(|p| p.lat).fold(f64::MIN, f64::max), + corners.iter().map(|p| p.lon).fold(f64::MIN, f64::max), + ) + }) +} + +/// Starts the import on a thread of its own: Overpass for the positions, the +/// register for the machines, and the match between them. +fn start(dialog: &mut WindImport, bbox: (f64, f64, f64, f64)) { + let query = content::import::wind_query(bbox.0, bbox.1, bbox.2, bbox.3); + let config = RequestConfig::default(); + let options = dialog.options; + + let (progress_out, progress) = std::sync::mpsc::channel(); + let (result_out, result) = std::sync::mpsc::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let flag = stop.clone(); + std::thread::spawn(move || { + let _ = progress_out.send("wind-import-fetching"); + let json = match fields::osm::fetch_raw(&query, &config) { + Ok(json) => json, + Err(e) => { + let _ = result_out.send(Err(e.to_string())); + return; + } + }; + if flag.load(Ordering::Relaxed) { + let _ = result_out.send(Ok(Report { + turbines: Vec::new(), + matched: RegisterMatch::default(), + })); + return; + } + let _ = progress_out.send("wind-import-parsing"); + let mut turbines = match content::import::parse_wind_turbines(&json) { + Ok(turbines) => turbines, + Err(e) => { + let _ = result_out.send(Err(e.to_string())); + return; + } + }; + + // The register is the second question, and the one that answers what + // the machines are. A turbine the register cannot place keeps what + // OpenStreetMap said about it. + let mut matched = RegisterMatch::default(); + if options.register && !flag.load(Ordering::Relaxed) { + let _ = progress_out.send("wind-import-asking-register"); + match fields::mastr::fetch_wind(bbox.0, bbox.1, bbox.2, bbox.3, &config) { + Ok(units) => matched = content::wind::match_register(&mut turbines, &units), + Err(e) => { + let _ = result_out.send(Err(e.to_string())); + return; + } + } + } + if !options.small { + turbines.retain(|t| !content::wind::is_small(t)); + } + let _ = result_out.send(Ok(Report { turbines, matched })); + }); + + dialog.report = None; + dialog.message.clear(); + dialog.job = Some(Job { + progress: Mutex::new(progress), + result: Mutex::new(result), + stop, + }); +} + +/// The import dialog. Its own system, like [`crate::power`] — `ui::draw` is +/// already at Bevy's system-parameter limit. +pub fn draw( + mut contexts: EguiContexts, + mut dialog: ResMut, + mut line: ResMut, + mut state: ResMut, + mut overlay: ResMut, + mut request: ResMut, + mut themed: Local, +) -> Result { + // `ui::draw` installs the theme on the very first pass and draws nothing + // itself; the font families it registers are only bound from the next one. + // A dialog that is up on that first pass — which `--import-wind` makes it — + // has to sit that pass out as well: a heading in a family that is not there + // yet is a panic inside egui, not a fallback. The same guard the imagery + // dialog carries for `--detect`. + if !*themed { + *themed = true; + return Ok(()); + } + // The menu asks through the request, like every other menu entry. + if request.import_wind { + request.import_wind = false; + dialog.open = true; + } + if !dialog.open { + return Ok(()); + } + let ctx = contexts.ctx_mut()?.clone(); + + // Whatever the thread has said since the last frame; the channels sit + // behind mutexes, so the reads are copies. + if dialog.job.is_some() { + let mut finished_report: Option> = None; + let mut failed = false; + let mut stage_out: Option<&'static str> = None; + if let Some(job) = &dialog.job { + let mut stage = dialog.stage; + if let Ok(progress) = job.progress.lock() { + while let Ok(next) = progress.try_recv() { + stage = next; + } + } + if let Ok(result) = job.result.lock() { + match result.try_recv() { + Ok(report) => finished_report = Some(report), + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => failed = true, + } + } else { + failed = true; + } + stage_out = Some(stage); + } + if let Some(stage_out) = stage_out { + dialog.stage = stage_out; + } + if let Some(report) = finished_report { + dialog.job = None; + match report { + Ok(report) => dialog.report = Some(report), + Err(e) => dialog.message = e, + } + } else if failed { + // The thread died without an answer. + dialog.job = None; + dialog.message = t!("field-import-failed"); + } + } + + let mut close = false; + egui::Window::new(t!("wind-import-title")) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.viewport_rect().center()) + .show(&ctx, |ui| { + ui.set_width(DIALOG); + let dialog: &mut WindImport = &mut dialog; + if dialog.job.is_some() { + running(ui, dialog); + } else if dialog.report.is_some() { + close |= finished(ui, dialog, &mut line, &mut state, &mut overlay); + } else { + close |= settings(ui, dialog, &line); + } + }); + if close { + dialog.open = false; + dialog.report = None; + dialog.message.clear(); + } + Ok(()) +} + +/// The form: what to import and whom to ask. Shown before the first run and +/// after a commit. +fn settings(ui: &mut egui::Ui, dialog: &mut WindImport, line: &Line) -> bool { + ui.label(t!("wind-import-intro")); + ui.add_space(space::S); + + if line.source.envelope.len() < 3 { + ui.colored_label(colors::WARN, t!("field-import-no-envelope")); + } + + ui.add_space(space::S); + editor_ui::form_grid("wind-import-form") + .num_columns(2) + .show(ui, |ui| { + crate::ui::row(ui, "wind-import-register", |ui| { + ui.checkbox(&mut dialog.options.register, ""); + }); + crate::ui::row(ui, "wind-import-small", |ui| { + ui.checkbox(&mut dialog.options.small, ""); + }); + }); + + ui.add_space(space::S); + ui.colored_label(colors::TEXT_SECONDARY, t!("wind-import-no-models")); + + if !dialog.message.is_empty() { + ui.add_space(space::S); + ui.colored_label(colors::ERROR, &dialog.message); + } + + ui.add_space(space::M); + let mut close = false; + let ready = line.source.envelope.len() >= 3; + ui.horizontal(|ui| { + let start_button = ui.add_enabled(ready, egui::Button::new(t!("field-import-start"))); + if !ready { + start_button + .clone() + .on_disabled_hover_text(t!("field-import-no-envelope")); + } + if start_button.clicked() + && ready + && let Some(bbox) = envelope_bbox(line) + { + start(dialog, bbox); + } + if ui.button(t!("action-cancel")).clicked() { + close = true; + } + }); + close +} + +/// While it runs: the bar, what is happening, and Stop. +fn running(ui: &mut egui::Ui, dialog: &mut WindImport) { + let bar = egui::ProgressBar::new(0.0).animate(true); + ui.add(bar.desired_width(DIALOG)); + ui.add_space(space::XS); + ui.label(t!(dialog.stage)); + ui.add_space(space::M); + if let Some(job) = &dialog.job + && ui.button(t!("field-import-stop")).clicked() + { + job.stop.store(true, Ordering::Relaxed); + } +} + +/// The summary, and the decision. The list is by machine, not by turbine: what +/// a builder wants to see before committing is whether the register recognised +/// the park — twenty-four Enercon E-115 in one line is an answer, twenty-four +/// unknown machines is a reason to look again. +fn finished( + ui: &mut egui::Ui, + dialog: &mut WindImport, + line: &mut Line, + state: &mut EditorState, + overlay: &mut crate::overlay::Overlay, +) -> bool { + let Some(report) = &dialog.report else { + return false; + }; + let turbines = &report.turbines; + ui.label( + egui::RichText::new(t!( + "wind-import-found", + turbines = turbines.len(), + named = report.matched.matched + )) + .color(colors::TEXT_STRONG), + ); + if report.matched.spare > 0 { + ui.colored_label( + colors::TEXT_SECONDARY, + t!("wind-import-spare", count = report.matched.spare), + ); + } + + ui.add_space(space::S); + egui::ScrollArea::vertical() + .max_height(160.0) + .show(ui, |ui| { + editor_ui::form_grid("wind-import-machines") + .num_columns(2) + .min_col_width(0.0) + .show(ui, |ui| { + // By machine, and within a machine by how many there are: + // the park a module is really about is the biggest row. + let mut counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for turbine in turbines { + let name = if turbine.model.is_empty() { + t!("wind-import-unknown-machine") + } else { + let hub = format!("{:.0}", turbine.hub_height); + let rotor = format!("{:.0}", turbine.rotor_diameter); + t!( + "wind-import-machine", + model = turbine.model.clone(), + hub = hub, + rotor = rotor + ) + }; + *counts.entry(name).or_insert(0) += 1; + } + for (name, count) in counts { + ui.label( + egui::RichText::new(count.to_string()).color(colors::TEXT_SECONDARY), + ); + ui.label(name); + ui.end_row(); + } + }); + }); + + ui.add_space(space::M); + let has_turbines = !turbines.is_empty(); + let (mut close, mut apply, mut again) = (false, false, false); + ui.horizontal(|ui| { + apply = ui + .add_enabled(has_turbines, egui::Button::new(t!("field-import-commit"))) + .clicked(); + again = ui.button(t!("field-import-again")).clicked(); + close = ui.button(t!("action-cancel")).clicked(); + }); + if apply { + let count = turbines.len(); + line.source.wind_turbines.extend(turbines.clone()); + // The turbines ride in with the vegetation, so the corridor's scatter + // has to be laid out again — the same invalidation the overhead line + // import asks for. Nothing is drawn while the models are missing, but + // the day they are there this is what puts them on the tiles. + line.needs_rebuild = true; + line.terrain_change = crate::terrain::TerrainChange::all(); + line.dirty = true; + overlay.status = t!("status-wind-imported", count = count); + state.selection = Selection::None; + return true; + } + if again { + dialog.report = None; + } + close +} diff --git a/crates/world-render/Cargo.toml b/crates/world-render/Cargo.toml index dbd313f1..3ccdffdc 100644 --- a/crates/world-render/Cargo.toml +++ b/crates/world-render/Cargo.toml @@ -11,6 +11,8 @@ sim-core = { workspace = true } world-coords = { workspace = true } fields = { workspace = true } glam = { workspace = true } +# The turbine's rotor node says how big and how fast it is in its glTF extras. +serde_json = "1" # `jpeg`: the road and ground textures are compiled-in JPEGs — without the # feature the asset loader rejects them and every material waits forever. # `dds`: the vehicle atlases ship as block-compressed DDS *with a mip chain*. diff --git a/crates/world-render/src/lib.rs b/crates/world-render/src/lib.rs index 797f5af0..f53b29fa 100644 --- a/crates/world-render/src/lib.rs +++ b/crates/world-render/src/lib.rs @@ -47,6 +47,7 @@ pub mod sky; pub mod track; pub mod water; pub mod weather; +pub mod wind; pub mod windscreen; pub use buildings::{BuildingAssets, BuildingIndex, spawn_buildings}; @@ -64,8 +65,8 @@ pub use people::{ pub use plants::{FieldPlants, PlantMaterials, update_field_plants}; pub use roads::{RoadDraw, RoadMaterial, RoadMaterials, RoadSurfaceMark, spawn_roads}; pub use scatter::{ - OBJECT_CULL, PendingTrees, Scattered, SceneryIndex, TREE_CULL, TreeModels, Wood, WorldCatalog, - cull_distant_woods, materialise_trees, + OBJECT_CULL, PendingTrees, Scattered, SceneLods, SceneryIndex, TREE_CULL, TreeModels, Wood, + WorldCatalog, cull_distant_woods, materialise_trees, }; pub use track::{GAUGE, RailMaterial, spawn_track}; pub use water::{WaterMaterial, WaterMaterials, WaterSurface, spawn_waters}; @@ -120,7 +121,17 @@ impl Plugin for WorldRenderPlugin { ( switch_night_nodes, materialise_trees, + scatter::apply_scene_lods, scatter::cull_distant_woods, + // The turbines: bound by node name as their scenes come + // in, then turned by the weather every frame. + ( + wind::bind_parts, + wind::turn_rotors, + wind::yaw_nacelles, + wind::blink_lights, + ) + .chain(), farmland::follow_date, // The standing crop follows the same calendar as the // paint under it: the material turns with the day, and a diff --git a/crates/world-render/src/scatter.rs b/crates/world-render/src/scatter.rs index cbf5d05c..ff35ddd8 100644 --- a/crates/world-render/src/scatter.rs +++ b/crates/world-render/src/scatter.rs @@ -75,7 +75,7 @@ pub struct WorldCatalog { /// installed mod object. trees: Vec>, /// Indexed by [`SceneryInstance::object`]. - objects: Vec>>, + objects: Vec>, /// Indexed by [`PersonInstance::character`]. people: Passengers, /// Placeholder conifer and broadleaf, coloured by vertex so one white @@ -118,9 +118,12 @@ impl WorldCatalog { let objects = object_names .iter() .map(|name| match registry.get(name) { - Some(object) => Some(assets.load( - GltfAssetLabel::Scene(0).from_asset(asset_path(season.model_of(object))), - )), + Some(object) => Some(SceneryModel { + scene: assets.load( + GltfAssetLabel::Scene(0).from_asset(asset_path(season.model_of(object))), + ), + bands: Arc::from(object.lod_distances.as_slice()), + }), None => { warn!("scenery: unknown object {name:?} — placeholder shown"); None @@ -147,6 +150,14 @@ impl WorldCatalog { } } +/// One scenery object of the catalogue: its scene, and the object's own +/// level-of-detail table — empty for the renderer's bands and [`OBJECT_CULL`]. +#[derive(Clone)] +struct SceneryModel { + scene: Handle, + bands: Arc<[f32]>, +} + /// One species of the catalogue: the glTF its levels are read out of and the /// distances they hand over at. The distances come with the mod object, so a /// bush and a fir are drawn to the range each is worth. @@ -563,17 +574,21 @@ pub fn spawn_scatter( tile.with_children(|parent| { for object in objects { let transform = Transform::from_translation(Vec3::from(object.pos)) - .with_rotation(Quat::from_array(object.rotation)); + .with_rotation(Quat::from_array(object.rotation)) + .with_scale(Vec3::splat(object.scale)); let scene = catalog .objects .get(object.object as usize) .and_then(|s| s.clone()); match scene { - Some(scene) => { + Some(SceneryModel { scene, bands }) => { + // The levels are put on the meshes once the scene has + // spawned (`apply_scene_lods`); a range on the root alone + // reaches none of them. parent.spawn(( WorldAssetRoot(scene), transform, - VisibilityRange::abrupt(0.0, OBJECT_CULL), + SceneLods(bands), SceneryIndex(object.index), WalkwayHost { people: catalog.people.clone(), @@ -621,6 +636,72 @@ pub fn spawn_scatter( }); } +/// The level-of-detail table of a scenery object, waiting on the root of its +/// scene for the hierarchy to spawn: the object's own distances, or empty for +/// the renderer's bands and [`OBJECT_CULL`]. +#[derive(Component, Clone)] +pub struct SceneLods(pub Arc<[f32]>); + +/// The levels have been put on the meshes. +#[derive(Component)] +pub struct LodsApplied; + +/// Puts the visibility ranges on the meshes of a spawned scenery scene. +/// +/// A [`VisibilityRange`] reaches the entity it sits on and nothing below it, +/// so a range on the scene's root reached no mesh at all — every level of a +/// mast placed by hand was drawn at once, out to no distance in particular. +/// The hierarchy is walked once it is there: a mesh under a node named +/// `_LOD` gets that level's band, a mesh under no level is drawn out to the +/// cull distance, the same rule the people and the trees follow. +pub fn apply_scene_lods( + mut commands: Commands, + roots: Query<(Entity, &SceneLods), Without>, + children: Query<&Children>, + names: Query<&Name>, + meshes: Query<(), With>, +) { + for (root, lods) in &roots { + // The scene spawns some frames after the entity. + let Ok(kids) = children.get(root) else { + continue; + }; + let mut stack: Vec<(Entity, Option)> = kids.iter().map(|e| (e, None)).collect(); + let mut found: Vec<(Entity, Option)> = Vec::new(); + while let Some((entity, inherited)) = stack.pop() { + let level = names + .get(entity) + .ok() + .and_then(|name| lod_level(name.as_str())) + .or(inherited); + if meshes.contains(entity) { + found.push((entity, level)); + } + if let Ok(kids) = children.get(entity) { + stack.extend(kids.iter().map(|e| (e, level))); + } + } + let mut levels: Vec = found.iter().filter_map(|(_, l)| *l).collect(); + levels.sort_unstable(); + levels.dedup(); + let cull = lods.0.last().copied().unwrap_or(OBJECT_CULL); + for (entity, level) in &found { + let (start, end) = match level.and_then(|l| levels.iter().position(|x| *x == l)) { + Some(rank) => { + let table: &[f32] = if lods.0.is_empty() { &[] } else { &lods.0 }; + let (start, end) = band(rank, levels.len(), table); + (start, end.min(cull)) + } + None => (0.0, cull), + }; + commands + .entity(*entity) + .insert(VisibilityRange::abrupt(start, end)); + } + commands.entity(root).insert(LodsApplied); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/world-render/src/wind.rs b/crates/world-render/src/wind.rs new file mode 100644 index 00000000..2cccaa84 --- /dev/null +++ b/crates/world-render/src/wind.rs @@ -0,0 +1,456 @@ +//! Wind turbines in motion: the rotor turns, the nacelle yaws, the lamp blinks. +//! +//! A turbine model (`mods/wind`, `tools/wind`) is a scene with two moving +//! nodes — `nacelle` at hub height and `rotor` in front of it — and, where the +//! machine is tall enough to carry the aviation marking, a `blink` node under +//! its `_NIGHT` lamps. Nothing in the line file says how they move; the +//! **weather** does, and the weather is a shared function of the scenario +//! clock (`sim_core::weather`), so two clients of a multiplayer run see the +//! same park turning the same way without a byte crossing the network. +//! +//! What moves and why: +//! +//! - **The rotor** turns at the speed the wind at hub height gives it: a +//! tip-speed ratio of about seven below rated power, the rated speed above +//! it, idling under the cut-in wind and stopped over the cut-out. The speed +//! follows the target over a few seconds — a rotor has inertia — and the +//! phase accumulates locally. It is not state: nobody can compare the +//! blade angle of one client with another's, so nothing is sent. +//! - **The nacelle** yaws to the wind's bearing, slowly and with a dead band, +//! the way a yaw drive does. The line file's own bearing is where it starts. +//! - **The lamp** (Feuer W, rot) blinks on the scenario clock — one second on, +//! half a second off — so every machine of a park blinks in step, which is +//! what the regulation asks of a real park and what the eye expects. +//! +//! The rotor's size and rated speed come off its node's glTF extras, so a +//! model that is scaled at placement is read at the size it stands at. + +use bevy::gltf::GltfExtras; +use bevy::prelude::*; + +use crate::sky::Sky; + +/// The wind below which a rotor only idles [m/s], and above which it is +/// stopped and feathered. +const CUT_IN: f32 = 3.0; +const CUT_OUT: f32 = 25.0; +/// The idling speed under the cut-in wind [rpm] — a rotor that is free wheels. +const IDLE_RPM: f32 = 1.5; +/// Blade tip speed over wind speed below rated power. Six to eight on every +/// modern machine; seven is the middle of the fleet. +const TIP_SPEED_RATIO: f32 = 7.0; +/// How fast the rotor speed follows its target [s] — the inertia of a rotor +/// weighing tens of tonnes. +const SPIN_UP: f32 = 8.0; +/// The wind's growth with height over open country: the Hellmann exponent of +/// farmland with hedges. The ten-metre wind the weather reports is half again +/// as strong a hundred metres up. +const HELLMANN: f32 = 0.2; +/// How fast a yaw drive turns the nacelle [deg/s], and the error it tolerates +/// before it bothers [deg]. +const YAW_RATE: f32 = 0.5; +const YAW_DEAD_BAND: f32 = 4.0; +/// The lamp's blink: one second on, half a second off (Feuer W, rot). +const BLINK_PERIOD: f64 = 1.5; +const BLINK_ON: f64 = 1.0; + +/// The rotor node of a turbine: what it is, and how it is turning. +#[derive(Component, Debug, Clone)] +pub struct Rotor { + /// The node's own rotation in the model — the axis tilt — that the spin + /// composes with. + pub base: Quat, + /// Rotor radius [m] and hub height [m] as the model was built; the + /// placement's scale is read off the transform. + pub radius: f32, + pub hub_height: f32, + /// The speed at rated power [rad/s]. + pub rated: f32, + /// Where the blades are [rad] and how fast they go [rad/s]. + pub angle: f32, + pub omega: f32, + /// A little slower or faster than the neighbour: no two machines of a park + /// ever turn in step. + pub trim: f32, +} + +/// The nacelle node of a turbine — what yaws. +#[derive(Component, Debug, Clone, Copy)] +pub struct Nacelle; + +/// The `blink` node under a turbine's lamps. +#[derive(Component, Debug, Clone, Copy)] +pub struct ObstructionLight; + +/// What the rotor node's extras say about the machine. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RotorSpec { + pub rotor_diameter: f32, + pub rated_rpm: f32, + pub hub_height: f32, +} + +/// Reads the rotor's extras (`{"rotor_diameter": 80, "rated_rpm": 18, +/// "hub_height": 95}`). A node without them is not a turbine's rotor. +pub fn parse_rotor(extras: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(extras).ok()?; + let number = |key: &str| value.get(key)?.as_f64().map(|v| v as f32); + let spec = RotorSpec { + rotor_diameter: number("rotor_diameter")?, + rated_rpm: number("rated_rpm")?, + hub_height: number("hub_height")?, + }; + (spec.rotor_diameter > 0.0 && spec.rated_rpm > 0.0 && spec.hub_height > 0.0).then_some(spec) +} + +/// Finds the moving nodes of freshly spawned turbine scenes by name. +pub fn bind_parts( + mut commands: Commands, + fresh: Query<(Entity, &Name, &Transform, Option<&GltfExtras>), Added>, +) { + for (entity, name, transform, extras) in &fresh { + match name.as_str() { + "rotor" => { + let Some(spec) = extras.and_then(|e| parse_rotor(&e.value)) else { + continue; + }; + // The phase off the entity id, which is arbitrary — what + // matters is that two turbines of a park do not start + // together, and that a rebuilt tile does not restart them + // visibly in step. + let seed = + entity.to_bits().wrapping_mul(2_654_435_761) as u32 as f32 / u32::MAX as f32; + commands.entity(entity).try_insert(Rotor { + base: transform.rotation, + radius: spec.rotor_diameter / 2.0, + hub_height: spec.hub_height, + rated: spec.rated_rpm * std::f32::consts::TAU / 60.0, + angle: seed * std::f32::consts::TAU, + omega: 0.0, + trim: 0.97 + 0.06 * ((seed * 7.0).fract()), + }); + } + "nacelle" => { + commands.entity(entity).try_insert(Nacelle); + } + "blink" => { + commands + .entity(entity) + .try_insert((ObstructionLight, Visibility::Inherited)); + } + _ => {} + } + } +} + +/// The wind at hub height from the ten-metre wind the weather reports [m/s]. +pub fn wind_at(wind_10m: f32, hub_height: f32) -> f32 { + wind_10m * (hub_height.max(10.0) / 10.0).powf(HELLMANN) +} + +/// The speed a rotor of `radius` settles at in this wind [rad/s]. +/// +/// Below the cut-in it idles, above the cut-out it is stopped — the blades +/// are feathered and the brake is on — and in between the tip runs at seven +/// times the wind until the machine reaches its rated speed, where the pitch +/// control holds it. +pub fn rotor_target(wind: f32, radius: f32, rated: f32) -> f32 { + if wind >= CUT_OUT { + 0.0 + } else if wind < CUT_IN { + IDLE_RPM * std::f32::consts::TAU / 60.0 * (wind / CUT_IN).clamp(0.0, 1.0) + } else { + (TIP_SPEED_RATIO * wind / radius.max(1.0)).min(rated) + } +} + +/// Turns every rotor: the speed towards what the wind asks, the angle on. +pub fn turn_rotors( + time: Res