Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions .claude/skills/editor-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ colours, so an active button's icon turns with its fill. New icons go into
`crates/editor-ui/src/icon.rs` as unit-coordinate line segments; keep them to
the existing `Stroke` width so a row of them has one weight.

The exception is a symbol that has to be *recognised* rather than merely told
apart — the sun on the status bar's day rail, the calendar leaf on its date
button. Those are glyphs of the bundled **Phosphor** font (MIT, see
`THIRD_PARTY_LICENSES.md`), typed through `editor_ui::icon_font(size)`:
`RichText::new(egui_phosphor::regular::SUN).font(icon_font(14.0))`, or that
`FontId` in a `Painter::text`. Do not design a symbol by hand where a licensed
set has one.

`icon_font` is a **family of its own**, and it must stay that way. Phosphor
maps `a`–`z` as well as its symbols, so it can never sit in front of Inter in
the `Proportional` chain; Inter in turn carries private-use glyphs that answer
for the calendar and the carets before Phosphor ever would, so it can never sit
behind it either. A symbol drawn in `Proportional` therefore comes out as a
random shape, not as tofu — the failure is silent.

- `icon_button(ui, icon, active, tooltip)` — 26×22, pressed-in (`ACCENT_BG` +
`ACCENT_TEXT`) while active, so a pair reads as a choice rather than as two
commands. The tooltip is the only text an icon has: name the function *and*
Expand Down Expand Up @@ -111,7 +126,7 @@ the existing `Stroke` width so a row of them has one weight.
## Typography

Inter, bundled in `crates/editor-ui/fonts/` (OFL license file next to it).
Two weights only:
Two weights, plus the icon font (`icon_font`, see above) for symbols only:

- **Inter Regular** — everything (Body/Button 13 px, Small 11 px).
- **Inter SemiBold** — headings and titles only, via `editor_ui::semibold()`
Expand Down Expand Up @@ -265,7 +280,15 @@ The vehicle editor's left panel is the reference implementation
selected chip is a button, "1" is narrower than "0"), and a horizontal
gives each row its own x.
- Status bar: message left; on the right the path in `TEXT_SECONDARY` and the
unsaved marker in `colors::WARN`. The message carries its severity with it
unsaved marker in `colors::WARN`, and at the far right the frame rate,
entity and tile counts (`status-perf`, `TEXT_SECONDARY`) — the readouts are
laid out first, right to left, and the message gets what is left, truncated;
the other way round a long message runs under the readouts. Between the
drawer button and the message,
the route editor's bar carries `day_controls` — date, clock and the day on
its rail — because what they change is the picture on the map, not the
document; their widths live in `datetime.rs`, so the caller passes fields,
not sizes. The message carries its severity with it
(`Status::Info` / `Status::Error`) and a failure is drawn in `colors::ERROR`
— a load that did not happen must not read exactly like one that did. The
label is `.truncate()`d with the full text on hover, so a long path cannot
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/screenshot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ bricht es mit „cannot open shared object file" ab.
|---|---|
| `--screenshot <datei.png>` | Aufnahme des Fensters, danach Ende. Verzeichnis wird angelegt. |
| `--frames N` | Aufnahme erst nach N Frames (≈ N/60 Sekunden). Ohne Angabe: 60. |
| `--height M` | Nur Moduleditor: Starthöhe des Blickpunkts über der Strecke in Metern (Vorgabe 900). 60 zeigt Bäume und Objekte, 900 das Modul. |

`--frames` ist der einzige Hebel auf den Zeitpunkt: mehr Frames = mehr Simulationszeit vor dem
Bild (KI-Züge sind gefahren, Luftbildkacheln sind geladen). 300 Frames für geladenes Overlay,
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions MODS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1365,8 +1365,17 @@ them along like devices.
A placement with `snap_to_terrain: true` stands on the **terrain surface** instead of the
rail plane — `height` then measures from the ground. The strip beside the track is
blended toward rail height, so a snapped object next to the ballast still meets it. The
editor's selection panel has the checkbox; the app resolves the height, because only it
has the elevation data.
editor's selection panel has the checkbox, and both programs resolve the height the same
way: an object is placed by the terrain tile it stands on, so it streams in and out with
that tile and its feet meet the ground the tile actually has.

**Levels of detail** work as for vehicles and signals: nodes named `<name>_LOD0`,
`_LOD1`, … are shown by camera distance (the bands are 200, 700 and 1 500 m; the last
level a model ships runs to the cull distance — 2.5 km for trees, 3 km for objects).
A model without the suffix is one level, drawn up to that distance. This matters most
for trees: every tree of a wood is drawn as an **instance** of its model's mesh parts —
not as its own scene — so a thousand firs sharing one glTF are a handful of draw calls,
and a low-poly `_LOD1` is what keeps a whole hillside cheap.

**Lit windows at night.** A node whose name ends in **`_NIGHT`** is shown after dusk and
hidden by day — lit windows in a house, a glowing sign, the light pool under a platform
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@ figures on the face stay put while the line's limit changes.
Nothing that does not apply is drawn: the AFB row exists on a vehicle fitted with one, the
LZB lamps where an LZB is, the look-ahead when something is actually coming. `F5` opens the
keyboard as a sheet — with a legend of what the ten annunciators mean — and `F6` the
diagnostics: terrain, air detail, axles, temperatures, signals and the network, which is
where everything a driver has no use for lives.
diagnostics: frame time and entity count, terrain, air detail, axles, temperatures, signals
and the network, which is where everything a driver has no use for lives.

**`F7` walks three steps** — full, reduced, off — and rounds back. The reduced step keeps
what the train is *driven* by (the desk and the protection lamps) and everything that
Expand Down Expand Up @@ -445,10 +445,14 @@ skirts at the tile edges against cracks between levels, and a cutting/embankment
pulls the terrain near the track up to rail level.

The ground is textured by **splatting**: per-vertex weights from slope and track distance blend
grass, rock and gravel. Trees are line content — every tree its own entry, spawned as a child
of its terrain tile, so vegetation streams and batches with the ground it stands on. Terrain,
splatting, vegetation and track objects live in `world-render` and therefore look the same in
the simulator and in the route editor.
grass, rock and gravel. Trees and scenery objects are line content — every one its own entry,
placed by the terrain tile it stands on and spawned as a child of it, so they stream with the
ground. A tree is not a scene instance but one entity per mesh part of its model, sharing the
part's mesh and material, so Bevy batches a wood into instanced draws; `_LOD` nodes become
distance bands and every tree is culled past 2.5 km. Tiles are built several at a time — the
builder is shared read-only, the DGM sheets keep their own short lock. Terrain, splatting,
vegetation and track objects live in `world-render` and therefore look the same in the
simulator and in the route editor.

The app shows the terrain automatically (flat without DGM):

Expand Down Expand Up @@ -484,6 +488,9 @@ whole day past in one drag — which is how you find out that the platform lies
shadow of its own canopy all morning. Latitude and longitude are not edited there: they
are the module's anchor, the same pair a run reads, so both programs put the sun over the
same hillside. Underneath, the panel reads out where the sun and the moon actually stand.
The date and that slider sit in the **status bar** as well — a calendar behind the date and
the sun itself as the handle of the day rail — because the light is judged on the map, not
in a panel.

## Language

Expand Down
26 changes: 19 additions & 7 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,17 @@ As of 2026-08-19 · `cargo test --workspace`: **524 tests green** · clippy and
like a hand-set one. Trees are 3D objects from mods (`objects/*.ron`; empty
name = generated placeholder tree). The app blends three generated ground
textures by the weights in a `StandardMaterial` extension shader and spawns
the trees as children of the tile — shared assets per species, so they render
as instanced draws and stream with the tile. Track objects can opt into
`snap_to_terrain`: the base moves from the rail plane onto the terrain
surface (`TerrainBuilder::surface_height`). **One elevation source per UTM zone**: `--dgm`/`--epsg` may be
the trees **and the scenery objects** as children of the tile
(`content::terrain::Scenery`, `world_render::scatter`): a tree is one entity
per mesh part of its model, sharing the part's mesh and material handles —
read once out of the loaded `Gltf`, never a scene instance — so Bevy batches
a wood into instanced draws, `_LOD` nodes become `VisibilityRange` bands,
and a tile's wood appears in one go once its models have loaded. Track
objects can opt into `snap_to_terrain`: the base moves from the rail plane
onto the tile's own height grid. The `TerrainBuilder` is shared read-only
across the workers (`Arc`, no lock — the DGM sheets carry their own short
one, reads happen outside it), and an edited line is a new builder sharing
the sheets (`with_line`); the F6 panel reads frame time and entity count. **One elevation source per UTM zone**: `--dgm`/`--epsg` may be
repeated, and a line across the 12° zone boundary takes each height from the first
source that has one — the tile grid stays in the first zone, which is only a
partitioning and continues past the boundary without a seam.
Expand Down Expand Up @@ -380,9 +387,14 @@ As of 2026-08-19 · `cargo test --workspace`: **524 tests green** · clippy and
their mount points. The shared `world-render` crate is that code, used by
both programs, so a stroke, a wood, a signal box or a signal mast is judged
where it is set instead of only in the run. Tiles are
built on the task pool around the view point (radius from the view height,
capped at 64 tiles); the standing builder takes an edit over without
re-indexing the DGM, and the old tile stays until its replacement arrives.
built on the task pool around the view point (3 km radius with a 25 %
unload hysteresis, capped at 64 tiles); an edit is **diffed** against the
last state (`main.rs::diff`) into what it reached — a stroke the ground of
the tiles under its disc, a moved tree or object only the trees and objects
of its tile, which are placed onto the standing ground again
(`TerrainBuilder::rescatter`) without a rebuild; only the track asks for
everything. The old tile stays until its replacement arrives, and the status
bar reads out frame rate, entities and tiles.
Terrain and aerial imagery are the same ground layer, so only one of them is
drawn: `T` (View ▸ Show terrain) switches, and a module that brings height
data starts on its terrain. The status bar reads out the **ground height
Expand Down
37 changes: 37 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,43 @@ Attribution: *The HYG database, © David Nash / Astronomy Nexus, CC BY-SA 4.0.*
The file is data, not code: it is read by `world_render::sky` and shipped inside
the binary, and any redistribution of it stays under CC BY-SA 4.0.

## Phosphor icons in the editor bars

The sun on the status bar's day rail, the calendar leaf on its date button and
the two carets in the calendar are glyphs of the
[Phosphor](https://github.com/phosphor-icons/homepage) icon font by Helena
Zhang and Tobias Fried, bundled through the `egui-phosphor` crate and used
under the MIT licence. Everything else in the editors is drawn from line
segments (`crates/editor-ui/src/icon.rs`); the font is for the symbols a
22-pixel drawing cannot carry.

```
MIT License

Copyright (c) 2023 Phosphor Icons

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

The `egui-phosphor` crate that bundles the font (© 2023 Romet Tagobert) is MIT
OR Apache-2.0; the font file it ships is compiled into the editor binaries.

## Rust dependencies

Crates pulled in by Cargo are not vendored here; their licences are those
Expand Down
41 changes: 40 additions & 1 deletion crates/app/src/hud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
//! reservoir and the pale one the brake pipe, because that is what the needles mean in a
//! cab. Colour that carries a meaning the driver already knows is not decoration.

use bevy::diagnostic::{
DiagnosticPath, DiagnosticsStore, EntityCountDiagnosticsPlugin, FrameTimeDiagnosticsPlugin,
};
use bevy::ecs::system::SystemParam;
use bevy::picking::Pickable;
use bevy::prelude::*;
Expand Down Expand Up @@ -2372,12 +2375,14 @@ pub fn update_hud(
drawings: Res<Drawings>,
// Only present in a multiplayer run (`net.rs`); single player never sees the line.
session: Option<Res<crate::net::Session>>,
diagnostics: Res<DiagnosticsStore>,
mut nodes: Nodes,
) {
let mode = mode(&gameplay, over.as_deref());
if !mode.drawn() {
return;
}
let perf = Perf::read(&diagnostics);
let frame = Frame::read(
&sim.0,
player.0,
Expand All @@ -2389,6 +2394,7 @@ pub fn update_hud(
&streamer,
&view,
session.as_deref(),
&perf,
);

for (readout, mut content, mut color) in nodes.readouts.iter_mut() {
Expand Down Expand Up @@ -2492,6 +2498,31 @@ struct Frame<'a> {
mode: HudMode,
}

/// What Bevy's own diagnostics say about the last frames — the numbers that
/// tell whether a streaming change helped, averaged so they can be read.
#[derive(Default)]
pub struct Perf {
pub fps: f64,
pub frame_ms: f64,
pub entities: usize,
}

impl Perf {
pub fn read(store: &DiagnosticsStore) -> Self {
let smoothed = |path: &DiagnosticPath| {
store
.get(path)
.and_then(|d| d.smoothed())
.unwrap_or_default()
};
Self {
fps: smoothed(&FrameTimeDiagnosticsPlugin::FPS),
frame_ms: smoothed(&FrameTimeDiagnosticsPlugin::FRAME_TIME),
entities: smoothed(&EntityCountDiagnosticsPlugin::ENTITY_COUNT) as usize,
}
}
}

impl<'a> Frame<'a> {
// A frame is the whole HUD's input; it reads what the HUD shows, not one thing.
#[allow(clippy::too_many_arguments)]
Expand All @@ -2506,6 +2537,7 @@ impl<'a> Frame<'a> {
streamer: &TerrainStreamer,
view: &ViewDistance,
session: Option<&crate::net::Session>,
perf: &Perf,
) -> Self {
let train = &sim.trains[player];
let loco = &train.vehicles[0];
Expand Down Expand Up @@ -2561,7 +2593,7 @@ impl<'a> Frame<'a> {
};
// Only worked out while the panel is open — it walks every signal of the line.
if overlays.diagnostics {
frame.diagnostics = Some(frame.diagnose(terrain, streamer, view, session));
frame.diagnostics = Some(frame.diagnose(terrain, streamer, view, session, perf));
}
frame
}
Expand Down Expand Up @@ -3185,6 +3217,7 @@ impl<'a> Frame<'a> {
streamer: &TerrainStreamer,
view: &ViewDistance,
session: Option<&crate::net::Session>,
perf: &Perf,
) -> String {
let drive = self.loco.traction.drives[0];
let aspects: Vec<String> = self
Expand All @@ -3204,6 +3237,12 @@ impl<'a> Frame<'a> {
})
.collect();
let mut lines = vec![
t!(
"hud-diag-frame",
fps = format!("{:.0}", perf.fps),
millis = decimal(perf.frame_ms, 1),
entities = perf.entities,
),
t!(
"hud-diag-terrain",
tiles = terrain.0.tiles,
Expand Down
Loading