From a775299fa35770d0812883cf2b30a1327870d8c4 Mon Sep 17 00:00:00 2001 From: Nils van Lueck Date: Wed, 2 Sep 2026 17:49:47 +0200 Subject: [PATCH] feat!: require every track to name a track type - remove the built-in default type and the reserved "default" name; an edge without track_type now fails to compile (CompileError::MissingTrackType) and is flagged by the rule check - content built-ins, the OSM importer (--track-type) and the route editor's lay tool write example:hauptbahn as an authoring default - track_type_at returns an Option; consumers fall back on welded-mainline roughness/reverb --- MODS.md | 18 ++- STATUS.md | 6 +- crates/app/src/audio.rs | 9 +- crates/app/src/main.rs | 2 +- crates/content/src/area_tests.rs | 13 +- crates/content/src/bin/import-line.rs | 11 +- crates/content/src/compose.rs | 5 +- crates/content/src/demo.rs | 10 +- crates/content/src/import/mod.rs | 11 +- crates/content/src/route.rs | 186 ++++++++++++++++++-------- crates/i18n/locales/de/main.ftl | 5 +- crates/i18n/locales/en/main.ftl | 5 +- crates/mod-runtime/src/lib.rs | 35 ++++- crates/route-editor/src/ai.rs | 4 +- crates/route-editor/src/areas.rs | 31 ++--- crates/route-editor/src/main.rs | 11 +- crates/route-editor/src/tools.rs | 42 ++++-- crates/route-editor/src/ui.rs | 60 ++++----- crates/track-model/src/network.rs | 91 ++++++++----- crates/track-model/src/track_type.rs | 13 +- crates/world-render/src/track/mod.rs | 34 +++-- mods/example/lines/boerde.ron | 4 +- mods/mastparade/lines/audit.ron | 2 +- mods/mastparade/lines/parade.ron | 2 +- mods/mastparade/mod.ron | 4 +- 25 files changed, 406 insertions(+), 208 deletions(-) diff --git a/MODS.md b/MODS.md index 1f113ad..29f3549 100644 --- a/MODS.md +++ b/MODS.md @@ -1289,7 +1289,7 @@ what it says and invents nothing: depth_map: Some("example/assets/track/ballast_disp.jpg"), // height map -> parallax relief occlusion_map: Some("example/assets/track/ballast_ao.jpg"),// ambient occlusion between the stones texture_scale: 2.2, // metres one repeat of those four covers, along and across - color: (0.32, 0.30, 0.28), // untextured fallback; the route editor tints sections its own way + color: (0.32, 0.30, 0.28), // colour of a type that names no texture; the editor tints sections its own way roughness: 1.0, // scales the rolling noise; jointed track > 1, slab track < 1 reverb: 0.0, // how much the surroundings ring: 0 = open line, 1 = tunnel max_speed: 250.0, // superstructure limit [km/h], caps the line's speed profile @@ -1366,13 +1366,20 @@ linear; the loader is told so and does not gamma-decode them. A type that names textures is skinned in its `color`. A line assigns types per edge as steps over the arc length, so one edge changes its -superstructure section by section; the reserved name `"default"` returns to the built-in -default type: +superstructure section by section: ```ron track_type: [(0.0, "example:hauptbahn"), (3000.0, "example:altbau")], ``` +**Every track has to name one.** There is no built-in type and no reserved +`"default"` name: what a track is built of is part of the module, not of the +engine, and a module that leaves it out does not compile — the compiler reports +`MissingTrackType()` and the route editor's rule check says the same thing +before the run. A type from another mod is a dependency like any other; name that +mod in `depends:`. This is deliberate: a silent fallback is how a line ends up on +untextured grey ballast without anybody being told. + `max_speed` merges into the speed profile every consumer already reads (AI, LZB, HUD, scoring); `roughness` reaches the sound table as the `Roughness` quantity (the default rolling entries carry a volume factor on it, see Sounds); `reverb` drives the reverb the @@ -1381,8 +1388,9 @@ or a deep cutting sits around 0.3 … 0.6. Modelling the room on the track type on the terrain is the same trade `roughness` makes: a line says where its tunnels are by assigning the type, and nothing has to trace geometry at run time. The route editor edits the sections in the selection panel (a color chip per section, the map tints the track ribbon -to match) and its rule check flags names no installed mod has, and LZB types on a line -that places no line conductor. +to match) and its rule check flags tracks that name no type at all, names no installed +mod has, and LZB types on a line that places no line conductor. The lay tool arms itself +with the first installed type, so track drawn in the editor carries one from the start. ### Electrification diff --git a/STATUS.md b/STATUS.md index 6096e32..7c84335 100644 --- a/STATUS.md +++ b/STATUS.md @@ -33,8 +33,10 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and **Track types** (superstructure classes, `track_types/*.ron` in a mod): texture, color, roughness, how much its surroundings ring (`reverb`, 0 = open line, 1 = tunnel), superstructure speed limit and an LZB flag — assigned per edge as a step - profile over `s`, so one edge changes its type section by section, with the reserved - name `"default"` returning to the built-in type. The mod runtime resolves the names + profile over `s`, so one edge changes its type section by section. **Every track has + to name one**: there is no built-in type and no reserved `"default"` name, and a + module that leaves it out does not compile (`CompileError::MissingTrackType`) — + a silent fallback is how a line ends up on untextured grey ballast unnoticed. The mod runtime resolves the names after compile (like signal types) and merges `max_speed` into the one speed profile AI, LZB, HUD and scoring already read; the app **builds the track the type describes** (`world_render::track`, rebuilt 2026-08-31 against the DB InfraGO dimensions and split diff --git a/crates/app/src/audio.rs b/crates/app/src/audio.rs index 556da32..06ab1d3 100644 --- a/crates/app/src/audio.rs +++ b/crates/app/src/audio.rs @@ -476,10 +476,13 @@ pub fn update_audio( SoundState::sample(vehicle, &cab, protection, audio.previous.get(&(t, v)), dt); // The sampler deliberately sees no track and no weather — both are filled in // here, where net and world state live. + // Track without a type is track nobody has laid a superstructure + // on — it cannot happen on a compiled line, and what rolls over it + // rolls as smoothly as welded main line. state.roughness = sim .net .track_type_at(vehicle.pos.edge, vehicle.pos.s) - .roughness; + .map_or(1.0, |ty| ty.roughness); // The rain quantity is how hard it falls, not whether it does: a // drizzle is not a downpour with the volume turned down. let weather = sim.weather.now; @@ -530,10 +533,10 @@ pub fn update_audio( .trains .get(player.0) .and_then(|train| train.vehicles.first()) - .map(|vehicle| { + .and_then(|vehicle| { sim.net .track_type_at(vehicle.pos.edge, vehicle.pos.s) - .reverb + .map(|ty| ty.reverb) }) .unwrap_or(0.0); audio diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 621f786..ab16592 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2049,7 +2049,7 @@ fn update_precipitation( .0 .net .track_type_at(vehicle.pos.edge, vehicle.pos.s) - .reverb + .map_or(0.0, |ty| ty.reverb) .clamp(0.0, 1.0) as f32; // The wind the drops actually meet: the weather's own — swaying in strength // and direction with the gusts, because a curtain of rain never stands at one diff --git a/crates/content/src/area_tests.rs b/crates/content/src/area_tests.rs index 3d22176..fa2bcca 100644 --- a/crates/content/src/area_tests.rs +++ b/crates/content/src/area_tests.rs @@ -27,7 +27,7 @@ fn line() -> LineSource { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }], @@ -139,7 +139,7 @@ fn an_area_can_switch_the_wire_and_the_superstructure() { let inside = net.edges()[0].track_type.at(1500.0); let outside = net.edges()[0].track_type.at(500.0); assert_ne!(inside, outside); - assert_eq!(outside, 0, "outside the area, the default type"); + assert_eq!(outside, 0, "outside the area, the track's own type"); assert_eq!(net.types()[inside as usize].name, "example:nebenbahn"); } @@ -200,7 +200,7 @@ fn removing_a_track_takes_its_spans_with_it() { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }); @@ -234,7 +234,12 @@ fn a_span_is_stored_the_way_round_it_was_marked() { #[test] fn the_rule_check_finds_a_marking_that_does_not_reach_the_line() { use std::collections::BTreeMap; - let types = BTreeMap::new(); + // The line is laid with the starter type; the registry answers it, so the + // check reports the markings and not a missing mod. + let types = BTreeMap::from([( + STARTER_TRACK_TYPE.to_string(), + track_model::TrackType::placeholder(STARTER_TRACK_TYPE), + )]); let objects = BTreeMap::new(); // A marking with no properties yet: useful while working, worth saying out loud. diff --git a/crates/content/src/bin/import-line.rs b/crates/content/src/bin/import-line.rs index b57aaa5..19f3219 100644 --- a/crates/content/src/bin/import-line.rs +++ b/crates/content/src/bin/import-line.rs @@ -2,7 +2,8 @@ //! //! ```text //! import-line line.json [--dgm dgm.xyz --epsg 25832] [--name "Musterbahn"] -//! [--sample 20] [--smoothing 3] [--out line.ron] +//! [--sample 20] [--smoothing 3] [--track-type example:hauptbahn] +//! [--out line.ron] //! ``` use content::import::dgm::TerrainSource; @@ -14,7 +15,8 @@ fn main() -> ExitCode { if args.is_empty() || args[0] == "--help" { eprintln!( "Usage: import-line [--dgm --epsg <25832>] \ - [--name ] [--sample ] [--smoothing ] [--no-snap] [--max-cant ] [--out ]" + [--name ] [--sample ] [--smoothing ] [--no-snap] \ + [--max-cant ] [--track-type ] [--out ]" ); return ExitCode::from(2); } @@ -86,6 +88,11 @@ fn main() -> ExitCode { if let Some(v) = flag("--start-way").and_then(|v| v.parse().ok()) { options.start_way = Some(v); } + // What the imported track is built of. Every edge is written out with it — + // a line that names no type does not compile. + if let Some(v) = flag("--track-type") { + options.track_type = v; + } let (line, report) = match import_line(&osm_json, grid.as_ref(), &options) { Ok(v) => v, diff --git a/crates/content/src/compose.rs b/crates/content/src/compose.rs index 0de5af8..2b084de 100644 --- a/crates/content/src/compose.rs +++ b/crates/content/src/compose.rs @@ -468,7 +468,8 @@ fn shift_payload(kind: &DeviceKind, payload: &str, off: ModuleOffsets) -> String mod tests { use super::*; use crate::route::{ - BoundarySource, DeviceSource, EdgeSource, GeoPoint, SectionSource, SignalSource, + BoundarySource, DeviceSource, EdgeSource, GeoPoint, STARTER_TRACK_TYPE, SectionSource, + SignalSource, }; use sim_core::interlock::{SignalKind, SignalSystem}; use track_model::{Segment, TrackPosition}; @@ -493,7 +494,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![(0.0, 120.0)], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }], diff --git a/crates/content/src/demo.rs b/crates/content/src/demo.rs index fba7d48..253b050 100644 --- a/crates/content/src/demo.rs +++ b/crates/content/src/demo.rs @@ -164,6 +164,10 @@ fn demo_trees() -> Vec { /// /// 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). +/// +/// It names its superstructure like any other line does +/// ([`crate::route::STARTER_TRACK_TYPE`]) — there is no built-in type, so a +/// demo line that said nothing would stand on no ballast at all. pub fn musterbahn() -> LineSource { let magnet = |p: &MagnetPayload| ron::to_string(p).unwrap(); @@ -189,7 +193,7 @@ pub fn musterbahn() -> LineSource { grade: vec![], cant: vec![], speed: vec![(0.0, 160.0)], - track_type: vec![], + track_type: vec![(0.0, crate::route::STARTER_TRACK_TYPE.to_string())], electrification: Vec::new(), formation: true, }, @@ -205,7 +209,7 @@ pub fn musterbahn() -> LineSource { grade: vec![], cant: vec![(0.0, 0.0), (200.0, 80.0), (800.0, 0.0)], speed: vec![(0.0, 130.0)], - track_type: vec![], + track_type: vec![(0.0, crate::route::STARTER_TRACK_TYPE.to_string())], electrification: Vec::new(), formation: true, }, @@ -217,7 +221,7 @@ pub fn musterbahn() -> LineSource { grade: vec![(0.0, 0.0), (500.0, 8.0), (2500.0, 0.0)], cant: vec![], speed: vec![(0.0, 160.0)], - track_type: vec![], + track_type: vec![(0.0, crate::route::STARTER_TRACK_TYPE.to_string())], electrification: Vec::new(), formation: true, }, diff --git a/crates/content/src/import/mod.rs b/crates/content/src/import/mod.rs index 4f22ddc..402f09b 100644 --- a/crates/content/src/import/mod.rs +++ b/crates/content/src/import/mod.rs @@ -36,6 +36,11 @@ pub struct ImportOptions { /// What the imported line is electrified with (an id of /// [`track_model::PowerSystem`], or `"none"`). pub electrification: String, + /// What the imported track is built of (`":"` of a + /// `track_types/*.ron`). Every edge is written out with it: there is no + /// built-in type, so an import that named none would produce a module + /// that does not compile. + pub track_type: String, /// Permitted speed when OSM does not give one [km/h]. pub default_speed: f64, /// Height used when no DGM is available [m]. @@ -52,6 +57,7 @@ impl Default for ImportOptions { max_edge_length: 2_000.0, geoid_offset: 46.0, electrification: track_model::PowerSystem::Ac15kv.id().to_string(), + track_type: crate::route::STARTER_TRACK_TYPE.to_string(), default_speed: 100.0, default_height: 100.0, start_way: None, @@ -183,7 +189,10 @@ pub fn import_line( grade: shift_profile(&fitted.grade, offset, len), cant: shift_profile(&fitted.cant, offset, len), speed: shift_profile(&fitted.speed, offset, len), - track_type: vec![], + // The superstructure belongs to the track the same way the wire + // does, and it is not optional: every imported edge is written out + // with the type the import was given. + track_type: vec![(0.0, options.track_type.clone())], // The wire belongs to the track: every imported edge starts out // saying so itself, so it can be changed one edge at a time. electrification: vec![(0.0, options.electrification.clone())], diff --git a/crates/content/src/route.rs b/crates/content/src/route.rs index 63c2205..28eb54d 100644 --- a/crates/content/src/route.rs +++ b/crates/content/src/route.rs @@ -718,7 +718,8 @@ pub struct EdgeSource { pub speed: Vec<(f64, f64)>, /// Track type (`":"`, see `track_types/*.ron`) as steps /// `(s, name)` — one edge changes its superstructure section by section. - /// Empty = the default type. + /// **Required**: there is no built-in type a track falls back on, and an + /// empty list is [`CompileError::MissingTrackType`]. #[serde(default)] pub track_type: Vec<(f64, String)>, /// What hangs over this track as steps `(s, system)` — `"ac-15kv"`, @@ -989,8 +990,8 @@ pub struct TrackAreaSource { /// Longitudinal gradient [‰]. #[serde(default)] pub grade: Option, - /// Track type (`":"`, or `"default"`) — model and texture of the - /// superstructure. + /// Track type (`":"`) laid over the stretch; `None` leaves the + /// track's own type alone. #[serde(default)] pub track_type: Option, /// Electrification (an id of [`track_model::PowerSystem`], or `"none"`). @@ -1003,8 +1004,12 @@ pub struct TrackAreaSource { /// to know what it is laying over. pub const DEFAULT_SPEED: f64 = 160.0; -/// The reserved name of the built-in track type. -pub const DEFAULT_TRACK_TYPE: &str = "default"; +/// The track type the built-in content and the importers write into a module +/// they create. This is an authoring default, not a fallback: it lands **in +/// the file**, where the module's author sees it and can change it — nothing +/// stands in for a type a file leaves out (see +/// [`CompileError::MissingTrackType`]). +pub const STARTER_TRACK_TYPE: &str = "example:hauptbahn"; /// Half-width a marked area is painted with by default [m] — comfortably wider than the /// 1.5 m of the track ribbon, so a painted stretch reads as laid over the track. @@ -1463,6 +1468,9 @@ pub enum CompileError { UnknownDevice(u32), /// An edge refers to an edge that has not been compiled yet. ForwardReference(u32), + /// An edge names no track type. There is no built-in one: what the track + /// is built of is part of the module, not of the engine. + MissingTrackType(u32), } /// Which parcels stand on one another, and by how much. @@ -1564,6 +1572,9 @@ pub enum RuleIssue { SignalDeviceMismatch { signal: u32 }, /// Boundary whose node is missing or not a `Buffer`. BoundaryInvalid { boundary: u32 }, + /// Edge names no track type at all — the line will not compile until it + /// does, because nothing stands in for one. + MissingTrackType { edge: u32 }, /// Edge names a track type the registry does not know. UnknownTrackType { edge: u32 }, /// A marked area covers a track that does not exist, or a stretch beyond its end. @@ -2870,9 +2881,12 @@ impl LineSource { .any(|d| d.kind == DeviceKind::LineConductor); for (i, e) in self.edges.iter().enumerate() { let edge = i as u32; + if e.track_type.is_empty() { + issues.push(RuleIssue::MissingTrackType { edge }); + } if e.track_type .iter() - .any(|(_, name)| name != "default" && !types.contains_key(name)) + .any(|(_, name)| !types.contains_key(name)) { issues.push(RuleIssue::UnknownTrackType { edge }); } @@ -2910,7 +2924,7 @@ impl LineSource { if area .track_type .as_ref() - .is_some_and(|name| name != DEFAULT_TRACK_TYPE && !types.contains_key(name)) + .is_some_and(|name| !types.contains_key(name)) { issues.push(RuleIssue::AreaUnknownTrackType { area: index }); } @@ -3009,21 +3023,17 @@ impl LineSource { .collect(); // Edges in source order; `Continue` may only refer backwards. - // Track-type names are interned per line: index 0 stays the default - // type — the reserved name `"default"` addresses it, so a section can - // return to it mid-edge — and the specs behind the other names come - // from the mod runtime later (`TrackNetwork::apply_track_types`), - // like signal types. + // Track-type names are interned per line in the order they are first + // named — no index is reserved, because there is no built-in type to + // reserve one for. The specs behind the names come from the mod + // runtime later (`TrackNetwork::apply_track_types`), like signal types. let mut type_names: Vec = Vec::new(); let intern = |names: &mut Vec, name: &str| -> u32 { - if name == "default" { - return 0; - } match names.iter().position(|n| n == name) { - Some(i) => i as u32 + 1, + Some(i) => i as u32, None => { names.push(name.to_string()); - names.len() as u32 + names.len() as u32 - 1 } } }; @@ -3099,6 +3109,12 @@ impl LineSource { edge = edge.with_speed(StepProfile::new(speed)); } + // What the track is built of is the module's to say. Without it + // there is nothing to draw a bed, sleepers or a rail section from, + // and silently laying a grey stand-in is what this refuses to do. + let Some((_, own_type)) = e.track_type.first() else { + return Err(CompileError::MissingTrackType(i as u32)); + }; let type_spans: Vec<(f64, f64, String)> = self .areas .iter() @@ -3110,19 +3126,20 @@ impl LineSource { .filter_map(move |span| Some((span.from, span.to, name.clone()?))) }) .collect(); - let types = overlay_steps( - &e.track_type, - DEFAULT_TRACK_TYPE.to_string(), - &type_spans, - length, - ); - if !types.is_empty() { - let steps = types - .iter() - .map(|(s, name)| (*s, intern(&mut type_names, name))) - .collect(); - edge = edge.with_track_type(StepProfile::new(steps)); - } + // The edge's own profile is not empty (checked above), so the base + // default is never read — areas only ever lay over what is there. + let types = overlay_steps(&e.track_type, own_type.clone(), &type_spans, length); + let steps: Vec<(f64, u32)> = types + .iter() + .map(|(s, name)| (*s, intern(&mut type_names, name))) + .collect(); + edge = edge.with_track_type(if steps.is_empty() { + // An edge without length leaves the overlay no breakpoint to + // put a step on; what the track says at its start still holds. + StepProfile::constant(intern(&mut type_names, own_type)) + } else { + StepProfile::new(steps) + }); let power_spans: Vec<(f64, f64, String)> = self .areas @@ -3150,11 +3167,12 @@ impl LineSource { } edge_ids.push(net.add_edge(edge)); } - if !type_names.is_empty() { - let mut types = vec![TrackType::default()]; - types.extend(type_names.iter().map(|n| TrackType::placeholder(n))); - net.set_types(types); - } + net.set_types( + type_names + .iter() + .map(|n| TrackType::placeholder(n)) + .collect(), + ); net.set_default_electrification(track_model::electrification_from_id( &self.electrification, )); @@ -3340,6 +3358,16 @@ impl LineSource { mod tests { use super::*; + /// The type registry a rule check runs against: the one the Musterbahn is + /// laid with, so a check reports what the test is about rather than a + /// track type no installed mod answers. + fn type_registry() -> std::collections::BTreeMap { + std::collections::BTreeMap::from([( + STARTER_TRACK_TYPE.to_string(), + TrackType::placeholder(STARTER_TRACK_TYPE), + )]) + } + /// Two parcels standing on the same ground get a word from the rule /// check; two that merely share a boundary do not, because that is what a /// boundary is. @@ -3362,7 +3390,7 @@ mod tests { )) .unwrap() }; - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let objects = std::collections::BTreeMap::new(); // Sharing the eastern boundary exactly: neighbours, not a fault. @@ -3411,7 +3439,7 @@ mod tests { let back: LineSource = ron::from_str(&line.to_ron()).unwrap(); assert_eq!(back.walk_paths, line.walk_paths); assert_eq!(back.walk_areas, line.walk_areas); - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let objects = std::collections::BTreeMap::new(); assert!(line.check(&types, &objects).is_empty()); @@ -3637,7 +3665,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }); @@ -3755,7 +3783,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }); @@ -3799,7 +3827,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }); @@ -3861,7 +3889,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }); @@ -3996,7 +4024,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }, @@ -4008,7 +4036,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }, @@ -4020,7 +4048,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: Vec::new(), formation: true, }, @@ -4095,7 +4123,7 @@ mod tests { /// line, and two roads of the same name. #[test] fn check_flags_a_portal_in_the_middle_of_the_line() { - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let objects = std::collections::BTreeMap::new(); let mut line = musterbahn(); assert!(line.check(&types, &objects).is_empty()); @@ -4154,7 +4182,7 @@ mod tests { /// textbook finding the check exists for. #[test] fn check_flags_the_missing_1000hz_magnet() { - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let objects = std::collections::BTreeMap::new(); let mut line = musterbahn(); assert!( @@ -4189,6 +4217,51 @@ mod tests { assert!(issues.contains(&RuleIssue::DistantWithout1000Hz { signal: 0 })); } + /// What the track is built of is the module's to state: there is no + /// built-in type, so a track that names none does not compile, and the + /// rule check says so before the compile is ever run. + #[test] + fn a_track_without_a_type_does_not_compile() { + let mut line = musterbahn(); + line.edges[1].track_type.clear(); + assert_eq!( + line.compile().err(), + Some(CompileError::MissingTrackType(1)) + ); + + let issues = line.check(&type_registry(), &std::collections::BTreeMap::new()); + assert!(issues.contains(&RuleIssue::MissingTrackType { edge: 1 })); + // Only the track that says nothing is reported; its neighbours are laid. + assert!(!issues.contains(&RuleIssue::MissingTrackType { edge: 0 })); + + // Naming one is all it takes. + line.edges[1].track_type = vec![(0.0, STARTER_TRACK_TYPE.into())]; + line.compile().expect("compiles"); + assert!( + !line + .check(&type_registry(), &std::collections::BTreeMap::new()) + .iter() + .any(|i| matches!(i, RuleIssue::MissingTrackType { .. })) + ); + } + + /// An area lays its type over the track's own — it does not stand in for a + /// track that names none. + #[test] + fn an_area_does_not_make_up_for_a_track_without_a_type() { + let mut line = musterbahn(); + line.edges[0].track_type.clear(); + line.areas.push(TrackAreaSource { + track_type: Some("ex:hauptbahn".into()), + spans: vec![AreaSpan::new(0, 0.0, 3000.0)], + ..TrackAreaSource::default() + }); + assert_eq!( + line.compile().err(), + Some(CompileError::MissingTrackType(0)) + ); + } + /// Track types compile into an interned table plus per-edge index /// profiles; the specs come from the registry later. #[test] @@ -4203,11 +4276,14 @@ mod tests { .iter() .map(|t| t.name.as_str()) .collect(); - assert_eq!(names, ["default", "ex:hauptbahn", "ex:alt"]); - assert_eq!(compiled.net.edges()[0].track_type.at(0.0), 1); - assert_eq!(compiled.net.edges()[0].track_type.at(2600.0), 2); - assert_eq!(compiled.net.edges()[1].track_type.at(0.0), 0); - assert_eq!(compiled.net.edges()[2].track_type.at(0.0), 1); + // Interned in the order they are first named — no index is reserved, + // because there is no built-in type. Edge 1 keeps what the Musterbahn + // lays it with, and that name is interned where it first appears. + assert_eq!(names, ["ex:hauptbahn", "ex:alt", STARTER_TRACK_TYPE]); + assert_eq!(compiled.net.edges()[0].track_type.at(0.0), 0); + assert_eq!(compiled.net.edges()[0].track_type.at(2600.0), 1); + assert_eq!(compiled.net.edges()[1].track_type.at(0.0), 2); + assert_eq!(compiled.net.edges()[2].track_type.at(0.0), 0); // Splitting carries the type sections across, shifted by the cut. line.split_edge(0, 1500.0).expect("splits"); @@ -4249,7 +4325,7 @@ mod tests { assert_eq!(line.objects[1].edge, 2); line.compile().expect("still compiles"); - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let mut objects = std::collections::BTreeMap::new(); objects.insert( "ex:mast".to_string(), @@ -4278,7 +4354,7 @@ mod tests { /// whose conductor was never placed. #[test] fn check_flags_track_type_wiring() { - let mut types = std::collections::BTreeMap::new(); + let mut types = type_registry(); types.insert( "ex:lzb".to_string(), TrackType { @@ -4398,7 +4474,7 @@ mod tests { }], ..Default::default() }; - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let objects = std::collections::BTreeMap::new(); assert!( line.check(&types, &objects) @@ -4462,7 +4538,7 @@ mod tests { envelope: bow_tie, ..Default::default() }; - let types = std::collections::BTreeMap::new(); + let types = type_registry(); let objects = std::collections::BTreeMap::new(); assert!( line.check(&types, &objects) diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index 3182ad7..a1b2025 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -825,7 +825,7 @@ sel-edge-fixed = Übergangsbögen — die Stützpunkte dieses Gleises sind nicht sel-edge-formation = Schotterbett & Bahndamm sel-edge-formation-hint = Aus: das Gelände lässt dieses Gleis in Ruhe — kein Schotterbett, kein Damm, kein Schotterstreifen. Für Gleise auf eigenen Konstruktionen; teile das Gleis, wenn nur ein Stück schwebt sel-track-type = Gleisart -sel-track-type-none = Standard-Gleisart auf dem ganzen Gleis. +sel-track-type-none = Keine Gleisart — das Gleis muss eine nennen, es springt keine ein. sel-track-type-from = Ab dieser Position auf dem Gleis sel-track-type-hint = Jede Zeile: ab Position s gilt diese Gleisart — Textur, Rauigkeit und Oberbau-Geschwindigkeit kommen aus track_types/*.ron eines Mods sel-power = Elektrifizierung @@ -866,7 +866,7 @@ action-mark-more-hint = Der nächste Strich kommt zu diesem Bereich dazu, statt status-area-too-short = Zu kurz — am Gleis entlangziehen power-none = Ohne Fahrdraht action-add-power-section = Elektrifizierungsabschnitt hinzufügen -track-type-default = (Standard) +track-type-none-installed = Kein installierter Mod definiert eine Gleisart action-add-type-section = Gleisart-Abschnitt hinzufügen sel-switch = Weiche sel-switch-node = Knoten { $node } ({ $leg }), Umlaufzeit @@ -1168,6 +1168,7 @@ check-main-no-2000hz = Signal { $signal }: kein 2000-Hz-Magnet mit dem Hauptsign check-distant-no-next = Signal { $signal }: Vorsignal kündigt nichts an (next fehlt) check-signal-device = Signal { $signal }: Gerät fehlt oder ist kein Signalgerät check-boundary-invalid = Grenze { $boundary }: Knoten fehlt oder ist kein Prellbock +check-missing-track-type = Gleis { $edge }: nennt keine Gleisart — es springt keine ein, und die Strecke lässt sich nicht übersetzen check-unknown-track-type = Gleis { $edge }: nennt eine Gleisart, die kein installierter Mod hat check-area-off-track = Gleisbereich { $area }: ein Abschnitt liegt auf keinem Gleis oder hinter dessen Ende check-area-no-effect = Gleisbereich { $area }: deckt nichts ab oder setzt nichts — er erreicht das Modul nicht diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index 3982c3e..4ba517b 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -824,7 +824,7 @@ sel-edge-fixed = Transition curves — this track's support points are not edita sel-edge-formation = Ballast bed & embankment sel-edge-formation-hint = Off, the terrain leaves this track alone — no ballast bed mesh, no embankment, no gravel strip. For track you set on your own constructions; split the edge if only a stretch floats sel-track-type = Track type -sel-track-type-none = Default type over the whole track. +sel-track-type-none = No track type — the track has to name one, nothing stands in for it. sel-track-type-from = From this distance along the track sel-track-type-hint = Each row: from position s onwards this type applies — texture, roughness, superstructure speed limit come from track_types/*.ron of a mod sel-power = Electrification @@ -865,7 +865,7 @@ action-mark-more-hint = The next stroke joins this area instead of opening a new status-area-too-short = Too short — drag along the track power-none = No wire action-add-power-section = Add electrification section -track-type-default = (Standard) +track-type-none-installed = No installed mod defines a track type action-add-type-section = Add type section sel-switch = Switch sel-switch-node = Node { $node } ({ $leg }), throw time @@ -1167,6 +1167,7 @@ check-main-no-2000hz = Signal { $signal }: no 2000 Hz magnet is linked to the ma check-distant-no-next = Signal { $signal }: distant signal announces nothing (next missing) check-signal-device = Signal { $signal }: its device is missing or not a signal device check-boundary-invalid = Boundary { $boundary }: node is missing or not a buffer +check-missing-track-type = Track { $edge }: names no track type — nothing stands in for one, and the line will not compile check-unknown-track-type = Track { $edge }: names a track type no installed mod has check-area-off-track = Marked area { $area }: a stretch lies on no track, or past its end check-area-no-effect = Marked area { $area }: covers nothing, or sets nothing — it does not reach the module diff --git a/crates/mod-runtime/src/lib.rs b/crates/mod-runtime/src/lib.rs index 4d1e617..b1fbf10 100644 --- a/crates/mod-runtime/src/lib.rs +++ b/crates/mod-runtime/src/lib.rs @@ -682,6 +682,31 @@ mod tests { } } + /// Every line every installed mod ships compiles, and every track on it is + /// laid with a superstructure an installed mod actually defines. This is + /// the guard against the failure the built-in default type used to hide: a + /// module that said nothing about its track loaded and ran anyway, on + /// untextured grey ballast, and nothing anywhere said so. + #[test] + fn every_shipped_line_is_laid_with_a_track_type_that_resolves() { + let mods = example_mods(); + assert!(!mods.lines.is_empty()); + for (key, line) in &mods.lines { + for (i, edge) in line.edges.iter().enumerate() { + assert!( + !edge.track_type.is_empty(), + "{key} edge {i} names no track type" + ); + } + let mut net = line + .compile() + .unwrap_or_else(|e| panic!("{key}: {e:?}")) + .net; + let warnings = mods.apply_track_types(&mut net); + assert!(warnings.is_empty(), "{key}: {warnings:?}"); + } + } + /// The `people` mod is the generated roster (tools/characters/): every entry names a /// model that ships, both genders are there, and everyone has a role — the app /// picks the walker's body and the crowds out of exactly this. @@ -772,8 +797,14 @@ mod tests { assert!(warnings.is_empty(), "{warnings:?}"); let edge = track_model::EdgeId(0); - assert_eq!(net.track_type_at(edge, 1000.0).roughness, 1.0); - assert_eq!(net.track_type_at(edge, 3500.0).roughness, 1.4); + assert_eq!( + net.track_type_at(edge, 1000.0).map(|t| t.roughness), + Some(1.0) + ); + assert_eq!( + net.track_type_at(edge, 3500.0).map(|t| t.roughness), + Some(1.4) + ); // Altbau allows the 120 km/h the line runs — the profile is unchanged. assert_eq!(net.edges()[0].speed.at(3500.0), 120.0); diff --git a/crates/route-editor/src/ai.rs b/crates/route-editor/src/ai.rs index 7cb7773..76a5aea 100644 --- a/crates/route-editor/src/ai.rs +++ b/crates/route-editor/src/ai.rs @@ -1570,7 +1570,7 @@ fn finished_panel( mod tests { use super::*; use content::LineSource; - use content::route::{EdgeSource, EdgeStart, GeoPoint, NodeSource}; + use content::route::{EdgeSource, EdgeStart, GeoPoint, NodeSource, STARTER_TRACK_TYPE}; use track_model::Segment; fn detection(lat: f64, lon: f64, heading: f64) -> GeoDetection { @@ -2354,7 +2354,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }], diff --git a/crates/route-editor/src/areas.rs b/crates/route-editor/src/areas.rs index 83535d7..7e0c746 100644 --- a/crates/route-editor/src/areas.rs +++ b/crates/route-editor/src/areas.rs @@ -12,7 +12,7 @@ use crate::tools::{self, EditorState, Selection, Tool}; use crate::ui::{power_label, row}; use crate::{Focus, Line, TrackTypes}; use bevy_egui::egui; -use content::route::{DEFAULT_TRACK_TYPE, TrackAreaSource}; +use content::route::TrackAreaSource; use editor_ui::{colors, space}; use i18n::t; use world_coords::EcefPos; @@ -223,15 +223,14 @@ fn track_type_row( known: &[String], ) { let mut on = value.is_some(); - ui.checkbox(&mut on, "").on_hover_text(t!("area-set-hint")); + // Nothing to switch the superstructure to while no installed mod defines a + // track type — there is no built-in one to offer. + ui.add_enabled(!known.is_empty(), egui::Checkbox::without_text(&mut on)) + .on_hover_text(t!("area-set-hint")) + .on_disabled_hover_text(t!("track-type-none-installed")); ui.label(t!("area-track-type")); if on && value.is_none() { - *value = Some( - known - .first() - .cloned() - .unwrap_or_else(|| DEFAULT_TRACK_TYPE.into()), - ); + *value = known.first().cloned(); } else if !on { *value = None; } @@ -240,14 +239,8 @@ fn track_type_row( ui.small(t!("area-unset")); } Some(name) => { - let unknown = name != DEFAULT_TRACK_TYPE && !types.map.contains_key(name.as_str()); - let label = if name == DEFAULT_TRACK_TYPE { - t!("track-type-default") - } else { - name.clone() - }; - let mut text = egui::RichText::new(label); - if unknown { + let mut text = egui::RichText::new(name.clone()); + if !types.map.contains_key(name.as_str()) { // A name no installed mod answers — visible before the run. text = text.color(colors::ERROR); } @@ -255,12 +248,6 @@ fn track_type_row( .width(space::FIELD) .selected_text(text) .show_ui(ui, |ui| { - if ui - .selectable_label(name == DEFAULT_TRACK_TYPE, t!("track-type-default")) - .clicked() - { - *name = DEFAULT_TRACK_TYPE.into(); - } for entry in known { if ui.selectable_label(name == entry, entry).clicked() { *name = entry.clone(); diff --git a/crates/route-editor/src/main.rs b/crates/route-editor/src/main.rs index c7af568..b031047 100644 --- a/crates/route-editor/src/main.rs +++ b/crates/route-editor/src/main.rs @@ -648,6 +648,7 @@ fn setup( mut media: ResMut>, mut star_materials: ResMut>, mut moon_materials: ResMut>, + mut state: ResMut, ) { // Load the line. let (source, path) = match &line_path.0 { @@ -746,9 +747,13 @@ fn setup( }); commands.insert_resource(Origin(origin)); let mods_dir = std::path::Path::new("mods"); - commands.insert_resource(TrackTypes { - map: load_mod_ron(mods_dir, "track_types", track_model::TrackType::from_ron), - }); + let track_types = load_mod_ron(mods_dir, "track_types", track_model::TrackType::from_ron); + // Arm the lay tool with a type before the first piece is drawn: track is + // laid with one or not at all, and nothing stands in for it afterwards. + if state.lay.track_type.is_none() { + state.lay.track_type = track_types.keys().next().cloned(); + } + commands.insert_resource(TrackTypes { map: track_types }); commands.insert_resource(TrackObjects { map: load_mod_ron(mods_dir, "objects", track_model::TrackObject::from_ron), }); diff --git a/crates/route-editor/src/tools.rs b/crates/route-editor/src/tools.rs index 387700f..0b0031a 100644 --- a/crates/route-editor/src/tools.rs +++ b/crates/route-editor/src/tools.rs @@ -25,7 +25,7 @@ use content::TerrainOptions; use content::import::alignment::{CantRules, ramp_cant}; use content::route::{ DeviceSource, EdgeSource, EdgeStart, FlankSource, GeoPoint, MarkerSource, NodeSource, - ObjectSource, SignalSource, TerrainEdit, TerrainEditSource, TreeSource, + ObjectSource, STARTER_TRACK_TYPE, SignalSource, TerrainEdit, TerrainEditSource, TreeSource, }; use glam::{DQuat, DVec2, DVec3}; use i18n::t; @@ -369,8 +369,10 @@ pub const TOOL_GROUPS: [(&str, editor_ui::Icon, &[ToolEntry]); 7] = [ /// one already lying there. The lay, join and offset tools all read it. #[derive(Clone, Debug)] pub struct LayOptions { - /// Track type (`":"`); `None` = the default type. The content - /// drawer arms it, like a track picked from the browser. + /// Track type (`":"`). Nothing stands in for one, so the panel + /// arms this with the first installed type as soon as there is one, and a + /// piece laid while it is `None` is a piece the line refuses to compile. + /// The content drawer arms it too, like a track picked from the browser. pub track_type: Option, /// Permitted speed [km/h]; `None` = the line's default. pub speed: Option, @@ -4635,6 +4637,16 @@ pub fn placement_preview( mod tests { use super::*; + /// Lay options as the tool panel hands them over: armed with a track type. + /// Track is laid with one or not at all — a piece that names none is a + /// piece the line refuses to compile. + fn lay() -> LayOptions { + LayOptions { + track_type: Some(STARTER_TRACK_TYPE.into()), + ..LayOptions::default() + } + } + #[test] fn arc_to_point_hits_the_target() { // Quarter circle: east heading, target at (r, r) → radius r, turn 90° left. @@ -4697,6 +4709,7 @@ mod tests { ..Default::default() }; let mut state = EditorState { + lay: lay(), drawing: Some(drawing), ..Default::default() }; @@ -4731,6 +4744,7 @@ mod tests { drawing.click(Target::free(frame.to_ecef(DVec3::new(400.0, 0.0, 0.0)))); drawing.click(Target::free(frame.to_ecef(DVec3::new(800.0, 0.0, 0.0)))); let mut state = EditorState { + lay: lay(), drawing: Some(drawing), ..Default::default() }; @@ -4932,6 +4946,7 @@ mod tests { drawing.click(Target::free(frame.to_ecef(tangent * 400.0 + left * 60.0))); assert_eq!(drawing.segments.len(), 1); let mut state = EditorState { + lay: lay(), drawing: Some(drawing), ..Default::default() }; @@ -4994,6 +5009,7 @@ mod tests { // the fork behind them. drawing.click(Target::free(frame.to_ecef(-tangent * 400.0 + left * 60.0))); let mut state = EditorState { + lay: lay(), drawing: Some(drawing), ..Default::default() }; @@ -5117,7 +5133,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }], @@ -5165,7 +5181,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }); @@ -5176,7 +5192,7 @@ mod tests { let b = *ends.iter().find(|e| e.edge == 1 && !e.at_end).unwrap(); join_ends( &mut doc, - &LayOptions::default(), + &lay(), &crate::stake::StakeOptions::default(), a, b, @@ -5221,7 +5237,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }); @@ -5233,7 +5249,7 @@ mod tests { speed: 60.0, ..Default::default() }; - join_ends(&mut doc, &LayOptions::default(), &stake, a, b).expect("stakes out"); + join_ends(&mut doc, &lay(), &stake, a, b).expect("stakes out"); let compiled = doc.source.compile().expect("still compiles"); let connector = compiled.net.edges().last().unwrap(); // It lands on B's start, tangentially. The chain is planned in A's @@ -5276,7 +5292,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }); @@ -5286,7 +5302,7 @@ mod tests { let b = *ends.iter().find(|e| e.edge == 1 && !e.at_end).unwrap(); join_ends( &mut doc, - &LayOptions::default(), + &lay(), &crate::stake::StakeOptions::default(), a, b, @@ -5327,7 +5343,7 @@ mod tests { let mut doc = line_of(straight_east(600.0)); offset_edge(&mut doc, 0, -4.0).expect("parallel"); doc.net = doc.source.compile().unwrap().net; - crossover(&mut doc, &LayOptions::default(), 0, 200.0, 1, 190.0).expect("builds"); + crossover(&mut doc, &lay(), 0, 200.0, 1, 190.0).expect("builds"); let compiled = doc.source.compile().expect("still compiles"); let switches = doc .source @@ -5477,6 +5493,7 @@ mod tests { ..Default::default() }); let mut state = EditorState { + lay: lay(), drawing: Some(drawing), ..Default::default() }; @@ -5535,7 +5552,7 @@ mod tests { grade: vec![], cant: vec![], speed: vec![], - track_type: vec![], + track_type: vec![(0.0, STARTER_TRACK_TYPE.into())], electrification: vec![], formation: true, }); @@ -5550,6 +5567,7 @@ mod tests { }); assert!(drawing.to_end.is_some(), "the click lands on the end"); let mut state = EditorState { + lay: lay(), drawing: Some(drawing), ..Default::default() }; diff --git a/crates/route-editor/src/ui.rs b/crates/route-editor/src/ui.rs index 17f91f7..c5108ff 100644 --- a/crates/route-editor/src/ui.rs +++ b/crates/route-editor/src/ui.rs @@ -2203,20 +2203,20 @@ fn lay_rows(ui: &mut egui::Ui, state: &mut EditorState, types: &TrackTypes) { let lay = &mut state.lay; editor_ui::form_grid("lay-options").show(ui, |ui| { row(ui, "lay-type", |ui| { - let current = lay - .track_type - .clone() - .unwrap_or_else(|| t!("track-type-default")); + // Track is laid with a type or not at all: nothing stands in for + // one, so the tool arms itself with the first installed type and + // only says so out loud when no mod defines any. + if lay.track_type.is_none() { + lay.track_type = types.map.keys().next().cloned(); + } + let Some(current) = lay.track_type.clone() else { + ui.colored_label(colors::ERROR, t!("track-type-none-installed")); + return; + }; egui::ComboBox::from_id_salt("lay-type") .width(space::FIELD) .selected_text(current) .show_ui(ui, |ui| { - if ui - .selectable_label(lay.track_type.is_none(), t!("track-type-default")) - .clicked() - { - lay.track_type = None; - } for name in types.map.keys() { if ui .selectable_label(lay.track_type.as_ref() == Some(name), name) @@ -2786,17 +2786,16 @@ fn track_type_rows(ui: &mut egui::Ui, line: &mut Line, i: usize, length: f64, ty .track_type .iter() .map(|(_, name)| { - let index = match name.as_str() { - "default" => Some(0), - _ => line.net.types().iter().position(|t| &t.name == name), - }; + let index = line.net.types().iter().position(|t| &t.name == name); crate::type_color32(index.unwrap_or(0) as u32) }) .collect(); let known: Vec = types.map.keys().cloned().collect(); let edge = &mut line.source.edges[i]; if edge.track_type.is_empty() { - ui.small(t!("sel-track-type-none")); + // Not a default that quietly applies — a line that says nothing here + // does not compile at all. + ui.colored_label(colors::ERROR, t!("sel-track-type-none")); } let mut remove = None; editor_ui::form_grid(&format!("edge-types-{i}")) @@ -2806,14 +2805,8 @@ fn track_type_rows(ui: &mut egui::Ui, line: &mut Line, i: usize, length: f64, ty ui.label(egui::RichText::new("■").color(swatches[k])); editor_ui::field(ui, &mut step.0, 10.0, 0.0..=length, "m") .on_hover_text(t!("sel-track-type-from")); - let unknown = step.1 != "default" && !types.map.contains_key(&step.1); - let label = if step.1 == "default" { - t!("track-type-default") - } else { - step.1.clone() - }; - let mut text = egui::RichText::new(label); - if unknown { + let mut text = egui::RichText::new(step.1.clone()); + if !types.map.contains_key(&step.1) { // A name no installed mod answers — visible before the run. text = text.color(colors::ERROR); } @@ -2821,12 +2814,6 @@ fn track_type_rows(ui: &mut egui::Ui, line: &mut Line, i: usize, length: f64, ty .width(space::FIELD) .selected_text(text) .show_ui(ui, |ui| { - if ui - .selectable_label(step.1 == "default", t!("track-type-default")) - .clicked() - { - step.1 = "default".into(); - } for name in &known { if ui.selectable_label(&step.1 == name, name).clicked() { step.1 = name.clone(); @@ -2842,17 +2829,23 @@ fn track_type_rows(ui: &mut egui::Ui, line: &mut Line, i: usize, length: f64, ty if let Some(k) = remove { edge.track_type.remove(k); } + // Without an installed type there is nothing to add: the button says so + // instead of stamping a name nothing answers. if ui - .small_button(t!("action-add-type-section")) + .add_enabled( + !known.is_empty(), + egui::Button::new(t!("action-add-type-section")).small(), + ) .on_hover_text(t!("sel-track-type-hint")) + .on_disabled_hover_text(t!("track-type-none-installed")) .clicked() + && let Some(name) = known.first().cloned() { let s = edge .track_type .last() .map(|(s, _)| (s + 100.0).min(length)) .unwrap_or(0.0); - let name = known.first().cloned().unwrap_or_else(|| "default".into()); edge.track_type.push((s, name)); } } @@ -4152,7 +4145,9 @@ fn issue_target( .and_then(|b| tools::node_pos(&line.source, &line.net, b.node)), Selection::None, ), - RuleIssue::UnknownTrackType { edge } | RuleIssue::LzbTypeWithoutConductor { edge } => ( + RuleIssue::MissingTrackType { edge } + | RuleIssue::UnknownTrackType { edge } + | RuleIssue::LzbTypeWithoutConductor { edge } => ( line.net .edges() .get(*edge as usize) @@ -4270,6 +4265,7 @@ fn issue_text(issue: &RuleIssue) -> String { RuleIssue::BoundaryInvalid { boundary } => { t!("check-boundary-invalid", boundary = boundary) } + RuleIssue::MissingTrackType { edge } => t!("check-missing-track-type", edge = edge), RuleIssue::UnknownTrackType { edge } => t!("check-unknown-track-type", edge = edge), RuleIssue::AreaOffTrack { area } => t!("check-area-off-track", area = area), RuleIssue::AreaWithoutEffect { area } => t!("check-area-no-effect", area = area), diff --git a/crates/track-model/src/network.rs b/crates/track-model/src/network.rs index aaf7406..e442eb7 100644 --- a/crates/track-model/src/network.rs +++ b/crates/track-model/src/network.rs @@ -191,7 +191,10 @@ pub struct TrackEdge { pub cant: StepProfile, /// Permitted speed [km/h] over `s`. pub speed: StepProfile, - /// Track type over `s` — indices into [`TrackNetwork::types`], 0 = default. + /// Track type over `s` — indices into [`TrackNetwork::types`]. There is no + /// built-in type to fall back on: a line states what its track is built of + /// (`content::route::CompileError::MissingTrackType` is what omitting it + /// costs), and an index the table does not answer draws nothing. #[serde(default = "default_track_type")] pub track_type: StepProfile, /// What hangs over this edge, section by section. `None` = the edge says nothing and @@ -383,9 +386,10 @@ pub struct TrackNetwork { edges: Vec, nodes: Vec, devices: Vec, - /// Track types the edges' [`TrackEdge::track_type`] profiles index into; - /// index 0 is always the default type. Saves from before track types - /// deserialize into the default table. + /// Track types the edges' [`TrackEdge::track_type`] profiles index into, + /// in the order the line first names them. The table is exactly what the + /// line asked for — nothing is reserved, and an empty table means a + /// network nobody has laid any track type on. #[serde(default = "default_types")] types: Vec, /// What the line is electrified with where an edge says nothing. A line states this @@ -400,7 +404,7 @@ pub struct TrackNetwork { } fn default_types() -> Vec { - vec![TrackType::default()] + Vec::new() } fn default_electrification() -> Electrification { @@ -425,7 +429,8 @@ impl TrackNetwork { Self::default() } - /// The track-type table; [`TrackEdge::track_type`] indexes into it. + /// The track-type table; [`TrackEdge::track_type`] indexes into it. Empty + /// where no track type has been laid on this network at all. pub fn types(&self) -> &[TrackType] { &self.types } @@ -464,33 +469,32 @@ impl TrackNetwork { } } - /// Replaces the track-type table. Index 0 should stay a default type; - /// the compiler and [`Self::apply_track_types`] keep that invariant. + /// Replaces the track-type table with exactly what is given. pub fn set_types(&mut self, types: Vec) { - self.types = if types.is_empty() { - default_types() - } else { - types - }; + self.types = types; } - /// Track type in force at `(edge, s)`. - pub fn track_type_at(&self, edge: EdgeId, s: f64) -> &TrackType { - let index = self.edges[edge.index()].track_type.at(s) as usize; - &self.types[index.min(self.types.len() - 1)] + /// Track type in force at `(edge, s)` — `None` where the edge's index + /// answers to nothing, which on a compiled line cannot happen and on a + /// network assembled by hand means no track type was ever laid. + pub fn track_type_at(&self, edge: EdgeId, s: f64) -> Option<&TrackType> { + let index = self.edges.get(edge.index())?.track_type.at(s) as usize; + self.types.get(index) } /// Resolves the type table against a registry (`":"` → spec) /// and caps every edge's speed profile with its types' `max_speed` — the /// superstructure limit becomes part of the one profile every consumer - /// (AI, LZB, HUD, scoring) already reads. Index 0, the default type, is - /// never looked up. Returns a warning per unresolved name. + /// (AI, LZB, HUD, scoring) already reads. Every entry is looked up — there + /// is no built-in type that would be exempt. A name no installed mod + /// answers keeps [`TrackType::placeholder`] properties and returns a + /// warning; that is a missing mod, not a line that said nothing. pub fn apply_track_types( &mut self, resolve: impl Fn(&str) -> Option, ) -> Vec { let mut warnings = Vec::new(); - for ty in self.types.iter_mut().skip(1) { + for ty in self.types.iter_mut() { match resolve(&ty.name) { Some(mut spec) => { // The registry key is the addressable name; keep it. @@ -509,11 +513,16 @@ impl TrackNetwork { .steps() .iter() .map(|(s, index)| { - let index = (*index as usize).min(self.types.len() - 1); - (*s, self.types[index].max_speed) + // An index the table does not answer caps nothing — the + // renderer draws no track there either. + let cap = self + .types + .get(*index as usize) + .map_or(f64::MAX, |t| t.max_speed); + (*s, cap) }) .collect(); - // Nothing to cap: every type on this edge is at the never-caps default. + // Nothing to cap: no type on this edge sets a superstructure limit. if caps.iter().all(|(_, v)| *v >= 999.0) { continue; } @@ -764,38 +773,54 @@ mod tests { vec![Segment::straight(3000.0)], ) .with_speed(StepProfile::new(vec![(0.0, 160.0), (2500.0, 60.0)])) - // Default up to km 1, then a branch-line type. + // Main line up to km 1, then a branch-line type. .with_track_type(StepProfile::new(vec![(0.0, 0), (1000.0, 1)])), ); net.set_types(vec![ - TrackType::default(), + TrackType::placeholder("test:hauptbahn"), TrackType::placeholder("test:nebenbahn"), ]); - let warnings = net.apply_track_types(|name| { - (name == "test:nebenbahn").then(|| TrackType { + let warnings = net.apply_track_types(|name| match name { + // A type that never caps — what a main line is laid with. + "test:hauptbahn" => Some(TrackType::placeholder(name)), + "test:nebenbahn" => Some(TrackType { max_speed: 80.0, roughness: 1.4, ..TrackType::default() - }) + }), + _ => None, }); assert!(warnings.is_empty(), "{warnings:?}"); let edge = net.edge(EdgeId(0)); - assert_eq!(edge.speed.at(500.0), 160.0, "default type never caps"); + assert_eq!(edge.speed.at(500.0), 160.0, "a type that never caps"); assert_eq!(edge.speed.at(1500.0), 80.0, "superstructure caps the line"); assert_eq!(edge.speed.at(2600.0), 60.0, "the lower line speed survives"); - assert_eq!(net.track_type_at(EdgeId(0), 1500.0).roughness, 1.4); - assert_eq!(net.track_type_at(EdgeId(0), 500.0).roughness, 1.0); + assert_eq!( + net.track_type_at(EdgeId(0), 1500.0).map(|t| t.roughness), + Some(1.4) + ); + assert_eq!( + net.track_type_at(EdgeId(0), 500.0).map(|t| t.roughness), + Some(1.0) + ); // An unknown name keeps its placeholder and warns. net.set_types(vec![ - TrackType::default(), + TrackType::placeholder("test:hauptbahn"), TrackType::placeholder("test:fehlt"), ]); - let warnings = net.apply_track_types(|_| None); + let warnings = net.apply_track_types(|name| { + (name == "test:hauptbahn").then(|| TrackType::placeholder(name)) + }); assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("test:fehlt")); + + // A network nobody laid a type on answers with nothing at all — + // there is no built-in type standing by. + net.set_types(Vec::new()); + assert!(net.track_type_at(EdgeId(0), 500.0).is_none()); } #[test] diff --git a/crates/track-model/src/track_type.rs b/crates/track-model/src/track_type.rs index 2d42631..73120f9 100644 --- a/crates/track-model/src/track_type.rs +++ b/crates/track-model/src/track_type.rs @@ -3,9 +3,10 @@ //! //! A mod ships them as `track_types/*.ron`, addressed `":"` like //! signal types. A line assigns them per edge as a step profile over the arc -//! length, so one edge can change its type section by section. The network -//! stores resolved specs in [`TrackNetwork::types`]; index 0 is always the -//! default type. +//! length, so one edge can change its type section by section, and it has to +//! name one — there is no built-in type a track falls back on. The network +//! stores resolved specs in [`TrackNetwork::types`], in the order the line +//! first names them. //! //! The physical build the type describes — rail section, sleepers, ballast — //! lives in [`crate::oberbau`], in the dimensions the DB drawings give. @@ -80,8 +81,7 @@ pub struct TrackType { } fn default_color() -> (f32, f32, f32) { - // The ballast grey the app has always used — the default type must not - // change the look of a line without types. + // Ballast grey: what a type is skinned in that names no texture of its own. (0.32, 0.30, 0.28) } @@ -100,6 +100,9 @@ fn default_max_speed() -> f64 { 1000.0 } +/// The field defaults a `track_types/*.ron` may leave out — **not** a type +/// anything falls back on: a track names its type or does not compile +/// (`content::route::CompileError::MissingTrackType`). impl Default for TrackType { fn default() -> Self { Self { diff --git a/crates/world-render/src/track/mod.rs b/crates/world-render/src/track/mod.rs index fa4ca8d..94ef656 100644 --- a/crates/world-render/src/track/mod.rs +++ b/crates/world-render/src/track/mod.rs @@ -148,10 +148,18 @@ pub fn spawn_track( // rails stand; the bed there is the builder's to model. if edge.formation { for (s0, s1, index) in edge.track_type_runs() { - let ty = types.get(index as usize); - let mats = per_type.get(index as usize).unwrap_or(&per_type[0]); - let oberbau = ty.map_or_else(Oberbau::default, |ty| ty.oberbau.clone()); - let scale = ty.map_or(1.5, texture_scale); + // What the track is built of comes from its type and from + // nowhere else: a section whose index the table does not + // answer is drawn as nothing at all, rather than as a grey + // stand-in nobody asked for. + let (Some(ty), Some(mats)) = + (types.get(index as usize), per_type.get(index as usize)) + else { + warn!("track type {index} is not in the line's table — section not drawn"); + continue; + }; + let oberbau = ty.oberbau.clone(); + let scale = texture_scale(ty); for (a, b) in spans(s0, s1, BED_CHUNK) { let bed = if oberbau.sleeper == SleeperKind::Slab { @@ -192,7 +200,12 @@ pub fn spawn_track( } } - let (near, far) = rail::build(edge, &frame, rail_profile_of(net, edge)); + // The rail section is a property of the type too; without one there + // is no rail to roll. + let Some(profile) = rail_profile_of(net, edge) else { + continue; + }; + let (near, far) = rail::build(edge, &frame, profile); for (chunks, range) in [ (near, VisibilityRange::abrupt(0.0, rail::DETAIL_RANGE)), (far, VisibilityRange::abrupt(rail::DETAIL_RANGE, f32::MAX)), @@ -244,12 +257,11 @@ fn recentre(mut mesh: Mesh, offset: Vec3) -> Mesh { /// The edge's rail section — the type at `s = 0` decides, one edge keeps one /// section (a rail does not change profile mid-edge; type changes are bed -/// work anyway). -fn rail_profile_of(net: &TrackNetwork, edge: &TrackEdge) -> track_model::RailProfile { - edge.track_type_runs() - .first() - .and_then(|&(_, _, index)| net.types().get(index as usize)) - .map_or_else(Default::default, |ty| ty.oberbau.rail) +/// work anyway). `None` where the edge's type is not in the table, which on a +/// compiled line cannot happen. +fn rail_profile_of(net: &TrackNetwork, edge: &TrackEdge) -> Option { + let &(_, _, index) = edge.track_type_runs().first()?; + Some(net.types().get(index as usize)?.oberbau.rail) } /// The three materials one track type is drawn with. diff --git a/mods/example/lines/boerde.ron b/mods/example/lines/boerde.ron index 1d4dcbb..70c30a4 100644 --- a/mods/example/lines/boerde.ron +++ b/mods/example/lines/boerde.ron @@ -109,7 +109,9 @@ speed: [ (0.0, 120.0), ], - track_type: [], + // The Regeloberbau this line is laid with. A track states what it + // is built of — nothing stands in for a type that is left out. + track_type: [(0.0, "example:b70")], electrification: [ (0.0, "ac-15kv"), ], diff --git a/mods/mastparade/lines/audit.ron b/mods/mastparade/lines/audit.ron index f299028..09f0b89 100644 --- a/mods/mastparade/lines/audit.ron +++ b/mods/mastparade/lines/audit.ron @@ -14,7 +14,7 @@ grade: [(0.0, 0.0)], cant: [], speed: [(0.0, 120.0)], - track_type: [], + track_type: [(0.0, "example:hauptbahn")], electrification: [(0.0, "ac-15kv")], formation: true, ), diff --git a/mods/mastparade/lines/parade.ron b/mods/mastparade/lines/parade.ron index 7ab2e5d..4199883 100644 --- a/mods/mastparade/lines/parade.ron +++ b/mods/mastparade/lines/parade.ron @@ -21,7 +21,7 @@ grade: [(0.0, 0.0)], cant: [], speed: [(0.0, 120.0)], - track_type: [], + track_type: [(0.0, "example:hauptbahn")], electrification: [(0.0, "ac-15kv")], formation: true, ), diff --git a/mods/mastparade/mod.ron b/mods/mastparade/mod.ron index ca00241..f8d5ecf 100644 --- a/mods/mastparade/mod.ron +++ b/mods/mastparade/mod.ron @@ -6,6 +6,8 @@ version: "0.1.0", author: "Connected Rails", description: "All 33 pylon variants in a row beside 2 km of straight track, for comparison against the prototype.", - depends: ["pylons"], + // `example` for the track type its two lines are laid with — there is + // no built-in one, so a module names the mod its superstructure comes from. + depends: ["pylons", "example"], enabled: true, )