Skip to content

Commit 030b53f

Browse files
authored
feat(vehicle-editor): navigation, data safety and legible physics (#18)
Iterative UX work on the vehicle editor. The form itself was already sound; what was missing was finding your way around it, protecting the work in it, and seeing what the numbers describe. Navigation and layout - Jump bar over the data panel, one chip per section, marking the section being read. The form runs two to three panel-heights long, and the only way to learn a section existed was to scroll past it. - Heading, name and file stay out of the scroll areas, so the vehicle being edited never leaves the screen. - Hairline and 12 px above each section title; the panel read as one long list before. - Values sit at the field's left edge, on the same column as combo box text, instead of centred where the gap depended on the number's length. - Subheadings move to TEXT: at 11.5 px they were smaller than the labels they head and shared their colour. - The scroll handle is visible at rest (egui hides it entirely). - Substring filter over the node list, with an n-of-m count. Data safety - Nothing discards work silently: New, Open, Quit and the window's close button ask first. The close button needed close_when_requested: false. - Undo/redo over a snapshot of the spec, one step per interaction rather than per frame, covering both panels. - Window title names the vehicle and its unsaved state. - Save is disabled when it would do nothing, and warns before rewriting a file that carries comments — ron::ser drops them. - Failures are drawn in ERROR and raise a dialog on user-triggered paths; a RON syntax error used to fail invisibly. - Bindings whose glTF node the model no longer has are marked. Legible physics - Sparklines under every (x, y) table, and under the running resistance, friction and tractive effort curves the vehicle computes — sampled from sim-core's own functions, never a copy. Zero baseline, since these are magnitudes; hover reads the value out. - Braked weight percentage closes the brake section. - One-metre ground grid under the vehicle: length over buffers and axle base are what the form is about, and the viewport gave nothing to measure them against. Coverage and settings - adhesive_mass_fraction and the coupler group were unreachable in the editor; a locomotive built here could not transmit a newton. - The custom friction curve could be selected but never edited. - The editor remembers recent vehicles, language, window size, panel widths and view toggles. Every branch of the form has been rendered and checked, including the four drive types and their optional sub-groups.
1 parent b77fcf0 commit 030b53f

11 files changed

Lines changed: 1929 additions & 257 deletions

File tree

.claude/skills/editor-ui/SKILL.md

Lines changed: 226 additions & 10 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,8 @@ What stays a literal: log output (`info!`, `warn!`), panic messages, test
3838
assertions, and type designations that are names rather than prose (`KE-GPR`,
3939
`PZB 90 V2.0`, `LOD0`).
4040

41-
The language comes from `TRAINSIM_LANG`, otherwise from the operating system,
42-
otherwise English; both editors switch it at runtime under View → Language.
41+
The language comes from `TRAINSIM_LANG`, otherwise from the choice made under
42+
View → Language, otherwise from the operating system, otherwise English. Both
43+
editors switch it at runtime; only the vehicle editor remembers the choice
44+
(`settings.rs`) — `i18n::set_language` sets an in-memory value, so a menu that
45+
calls it and nothing else throws the choice away at the next start.

crates/editor-ui/src/lib.rs

Lines changed: 193 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ fn style() -> egui::Style {
132132
spacing.combo_width = space::FIELD;
133133
spacing.tooltip_width = 360.0;
134134

135+
// egui's floating scroll handle is fully transparent at rest
136+
// (`dormant_handle_opacity` 0.0). In a panel two to three screens tall
137+
// that leaves nothing to say there is more below, or how much — the jump
138+
// bar names the sections but not the distance. Stay floating, so the bar
139+
// costs no panel width, and just let the handle be seen.
140+
let mut scroll = egui::style::ScrollStyle::floating();
141+
scroll.dormant_handle_opacity = 0.55;
142+
scroll.floating_width = space::XS;
143+
spacing.scroll = scroll;
144+
135145
style
136146
}
137147

@@ -244,33 +254,46 @@ pub fn section_title(text: impl Into<String>) -> RichText {
244254
}
245255

246256
/// A collapsible form section. All sections of a panel share this look.
257+
///
258+
/// A hairline above the title turns the panel from one long list into visible
259+
/// chunks — the rule plus the wider gap group each section's rows together
260+
/// (Gestalt: common region beats proximity alone in a dense form).
261+
///
262+
/// Returns the header's response, so a caller can scroll it into view.
247263
pub fn section(
248264
ui: &mut egui::Ui,
249265
id: &str,
250266
title: impl Into<String>,
251267
body: impl FnOnce(&mut egui::Ui),
252-
) {
253-
ui.add_space(space::S);
268+
) -> egui::CollapsingResponse<()> {
269+
ui.add_space(space::M);
270+
ui.separator();
271+
ui.add_space(space::XS);
254272
egui::CollapsingHeader::new(section_title(title))
255273
.id_salt(id)
256274
.default_open(true)
257275
.show(ui, |ui| {
258276
ui.add_space(2.0);
259277
body(ui);
260278
ui.add_space(space::XS);
261-
});
279+
})
262280
}
263281

264282
/// Sub-group heading inside a section ("Additional brakes").
265283
///
266284
/// No upper-casing — titles can carry units ("1/min → N·m") and the
267285
/// translations know their own capitalisation.
286+
/// `TEXT`, not `TEXT_SECONDARY`: at 11.5 px it is already smaller than the
287+
/// 13 px labels it heads, and in the same colour it would carry no more weight
288+
/// than the rows underneath it. Each level of the hierarchy has to outrank the
289+
/// next — `TEXT_STRONG` section title > `TEXT` subheading > `TEXT_SECONDARY`
290+
/// label.
268291
pub fn subheading(ui: &mut egui::Ui, text: impl Into<String>) {
269292
ui.add_space(space::S);
270293
ui.label(
271294
RichText::new(text.into())
272295
.font(FontId::new(11.5, semibold()))
273-
.color(colors::TEXT_SECONDARY),
296+
.color(colors::TEXT),
274297
);
275298
ui.add_space(2.0);
276299
}
@@ -346,22 +369,187 @@ pub fn drag<'a, N: egui::emath::Numeric>(
346369

347370
/// Adds a [`drag`] field at the shared [`space::FIELD`] width, so numeric
348371
/// fields and combo boxes in a value column share one footprint.
372+
///
373+
/// The value sits at the field's left edge, not centred: a drag value is wider
374+
/// than its content, and centring makes the distance from the label to the
375+
/// number depend on how long the number is. Left-aligned, every row of the
376+
/// column starts its value at the same x — and at the same x as the text of a
377+
/// combo box, which egui already aligns that way.
349378
pub fn field<N: egui::emath::Numeric>(
350379
ui: &mut egui::Ui,
351380
value: &mut N,
352381
speed: f64,
353382
range: std::ops::RangeInclusive<f64>,
354383
unit: &'static str,
355384
) -> egui::Response {
356-
ui.scope(|ui| {
385+
// `Button` (which a drag value paints itself as) takes its content
386+
// alignment from the surrounding layout, whose main axis defaults to
387+
// centre.
388+
let layout = ui.layout().with_main_align(egui::Align::Min);
389+
ui.scope_builder(egui::UiBuilder::new().layout(layout), |ui| {
357390
ui.spacing_mut().interact_size.x = space::FIELD;
358391
ui.add(drag(value, speed, range, unit))
359392
})
360393
.inner
361394
}
362395

396+
/// A small plot of an `(x, y)` table.
397+
///
398+
/// Not an analysis tool — no axes, no ticks, no numbers. It answers "does this
399+
/// look like a tractive effort curve" at a glance, which three rows of drag
400+
/// fields cannot: a point typed one digit wrong reads as a kink here and as a
401+
/// plausible number there.
402+
pub fn sparkline(ui: &mut egui::Ui, points: &[(f64, f64)], x_unit: &str, y_unit: &str) {
403+
plot(ui, points, x_unit, y_unit, true);
404+
}
405+
406+
/// `marks` puts a dot on every point. True where the points are the data the
407+
/// user typed; false for a sampled curve, where a dot per sample says nothing
408+
/// about the vehicle and only turns the line into a dotted one.
409+
fn plot(ui: &mut egui::Ui, points: &[(f64, f64)], x_unit: &str, y_unit: &str, marks: bool) {
410+
if points.len() < 2 {
411+
return;
412+
}
413+
let mut sorted = points.to_vec();
414+
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
415+
416+
let width = ui.available_width().min(space::FIELD * 2.0 + space::M);
417+
let (rect, response) = ui.allocate_exact_size(vec2(width, 56.0), egui::Sense::hover());
418+
let painter = ui.painter();
419+
// Same well as a text field, down to the border.
420+
painter.rect_filled(rect, CornerRadius::same(4), colors::BG_INPUT);
421+
painter.rect_stroke(
422+
rect,
423+
CornerRadius::same(4),
424+
Stroke::new(1.0, colors::BORDER_SUBTLE),
425+
egui::StrokeKind::Inside,
426+
);
427+
428+
let (x0, x1) = (sorted[0].0, sorted[sorted.len() - 1].0);
429+
// The y axis starts at zero, not at the smallest value. These are physical
430+
// magnitudes: normalised to their own range, a friction factor falling
431+
// from 1.0 to 0.6 fills the plot exactly like one falling to nothing, and
432+
// "how far does it drop" is the only question the picture is asked.
433+
let y0 = sorted.iter().map(|p| p.1).fold(0.0_f64, f64::min);
434+
let y1 = sorted.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
435+
// A curve with no spread in x has no shape to show.
436+
if x1 <= x0 {
437+
return;
438+
}
439+
let plot = rect.shrink(space::S);
440+
let at = |(x, y): (f64, f64)| {
441+
let tx = (x - x0) / (x1 - x0);
442+
// All-zero: along the bottom, where zero is, rather than divided by it.
443+
let ty = if y1 > y0 { (y - y0) / (y1 - y0) } else { 0.0 };
444+
egui::pos2(
445+
plot.left() + tx as f32 * plot.width(),
446+
plot.bottom() - ty as f32 * plot.height(),
447+
)
448+
};
449+
let line: Vec<egui::Pos2> = sorted.iter().copied().map(at).collect();
450+
painter.add(egui::Shape::line(line.clone(), Stroke::new(1.5, colors::ACCENT)));
451+
if marks {
452+
for point in line {
453+
painter.circle_filled(point, 2.0, colors::ACCENT);
454+
}
455+
}
456+
457+
// Reading a value off 56 px of line is guesswork. Hovering says it exactly,
458+
// and costs the plot no clutter when nobody asks.
459+
if let Some(pointer) = response.hover_pos() {
460+
let t = ((pointer.x - plot.left()) / plot.width()).clamp(0.0, 1.0) as f64;
461+
let x = x0 + t * (x1 - x0);
462+
let y = interpolate(&sorted, x);
463+
response.on_hover_text(format!(
464+
"{} → {}",
465+
with_unit(x, x_unit),
466+
with_unit(y, y_unit)
467+
));
468+
}
469+
}
470+
471+
/// Linear between the two points that bracket `x`.
472+
fn interpolate(sorted: &[(f64, f64)], x: f64) -> f64 {
473+
match sorted.iter().position(|p| p.0 >= x) {
474+
None => sorted[sorted.len() - 1].1,
475+
Some(0) => sorted[0].1,
476+
Some(i) => {
477+
let (x0, y0) = sorted[i - 1];
478+
let (x1, y1) = sorted[i];
479+
if x1 > x0 {
480+
y0 + (y1 - y0) * (x - x0) / (x1 - x0)
481+
} else {
482+
y1
483+
}
484+
}
485+
}
486+
}
487+
488+
/// Digit grouping above 100, two decimals below — one formatter for forces in
489+
/// the hundreds of thousands and friction factors below one.
490+
fn with_unit(value: f64, unit: &str) -> String {
491+
let number = if value.abs() >= 100.0 {
492+
group_digits(value)
493+
} else {
494+
format!("{value:.2}")
495+
};
496+
if unit.is_empty() {
497+
number
498+
} else {
499+
format!("{number}{NBSP}{unit}")
500+
}
501+
}
502+
503+
/// Samples `f` over `0..=x_max` and plots it with [`sparkline`].
504+
///
505+
/// For curves the vehicle does not store as points but computes — running
506+
/// resistance from three Davis coefficients, tractive effort from a handful of
507+
/// limits. Sample the simulator's own function, never a copy of it, or the
508+
/// picture and the physics drift apart.
509+
pub fn sparkline_fn(
510+
ui: &mut egui::Ui,
511+
x_max: f64,
512+
x_unit: &str,
513+
y_unit: &str,
514+
f: impl Fn(f64) -> f64,
515+
) {
516+
const STEPS: usize = 40;
517+
if !(x_max > 0.0) {
518+
return;
519+
}
520+
let points: Vec<(f64, f64)> = (0..=STEPS)
521+
.map(|i| {
522+
let x = x_max * i as f64 / STEPS as f64;
523+
(x, f(x))
524+
})
525+
.collect();
526+
plot(ui, &points, x_unit, y_unit, false);
527+
}
528+
363529
#[cfg(test)]
364530
mod tests {
531+
/// The hover readout is only as good as this: a wrong bracket reports a
532+
/// plausible number for the wrong speed, and nothing looks amiss.
533+
#[test]
534+
fn hover_reads_between_the_points() {
535+
let curve = [(0.0, 100.0), (50.0, 200.0), (150.0, 0.0)];
536+
assert_eq!(super::interpolate(&curve, 0.0), 100.0);
537+
assert_eq!(super::interpolate(&curve, 25.0), 150.0, "half way up");
538+
assert_eq!(super::interpolate(&curve, 50.0), 200.0, "on a point");
539+
assert_eq!(super::interpolate(&curve, 100.0), 100.0, "half way down");
540+
assert_eq!(super::interpolate(&curve, 150.0), 0.0);
541+
// Outside the curve it holds the end values rather than extrapolating.
542+
assert_eq!(super::interpolate(&curve, -10.0), 100.0);
543+
assert_eq!(super::interpolate(&curve, 999.0), 0.0);
544+
}
545+
546+
#[test]
547+
fn readouts_suit_both_forces_and_factors() {
548+
assert_eq!(super::with_unit(185_000.0, "N"), "185\u{A0}000\u{A0}N");
549+
assert_eq!(super::with_unit(0.6, ""), "0.60");
550+
assert_eq!(super::with_unit(120.0, "km/h"), "120\u{A0}km/h");
551+
}
552+
365553
#[test]
366554
fn digit_grouping_round_trips() {
367555
assert_eq!(super::group_digits(3_620_000.0), "3\u{A0}620\u{A0}000");

0 commit comments

Comments
 (0)