diff --git a/Cargo.lock b/Cargo.lock index 82e4899..eba5a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,6 +1436,7 @@ dependencies = [ "fieldcraft-model", "fieldcraft-render", "log", + "png", "resvg", "serde", "serde_json", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 11038d6..9660412 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -22,4 +22,5 @@ serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } resvg = { workspace = true } +png = { workspace = true } diff --git a/crates/ui-egui/src/appbar.rs b/crates/ui-egui/src/appbar.rs index 415df36..e027553 100644 --- a/crates/ui-egui/src/appbar.rs +++ b/crates/ui-egui/src/appbar.rs @@ -330,7 +330,7 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) { ui.set_max_width(340.0); crate::frame::menu_heading(ui, &t, "SAMPLES"); let mut open = None; - crate::files::sample_rows(ui, &t, &mut open); + crate::samples::rows(ui, &t, &mut open); ui.separator(); crate::frame::menu_heading(ui, &t, "NAVIGATION"); ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits."); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index b6229f9..6bfb8e0 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -976,8 +976,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { } Some(Prompt::Samples) => { let (mut open, mut close) = (None, false); - crate::frame::window(ctx, "Samples", crate::frame::Width::Normal).id(egui::Id::new("fc_samples")).show(ctx, |ui| { - sample_rows(ui, &t, &mut open); + crate::frame::window(ctx, "Samples", crate::frame::Width::Wide).id(egui::Id::new("fc_samples")).show(ctx, |ui| { + ui.set_width(crate::frame::WINDOW_WIDE_MAX_W.min(ctx.content_rect().width() - 48.0)); + crate::samples::grid(ui, &t, (ctx.content_rect().height() * 0.6).max(160.0), &mut open); ui.add_space(6.0); close = ui.button("Close").clicked(); }); @@ -992,28 +993,6 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { } } -/// One row per sample: its name, what it shows and an Open button; `open` gets the one clicked. -pub fn sample_rows(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>) { - let width = ui.available_width().clamp(280.0, 420.0); - egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| sample_list(ui, t, open, width)); -} - -fn sample_list(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>, width: f32) { - for sample in fieldcraft_engine::sample::SAMPLES { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(width - 70.0); - ui.label(egui::RichText::new(sample.label).strong()); - ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap()); - }); - if ui.button("Open").clicked() { - *open = Some(sample.name); - } - }); - ui.add_space(4.0); - } -} - impl ModelApp { /// The recover window's entries, read from disk once while it is open. fn recover_list(&mut self, ctx: &egui::Context) -> Vec { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fcadc1..0c61c1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,6 +42,7 @@ pub mod palette; pub mod reply; pub mod results; pub mod results_panel; +pub mod samples; pub mod scene; pub mod selection; pub mod theme; @@ -1962,6 +1963,72 @@ mod tests { (cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9 } + /// Every registered sample is on the Start card and opens with a click, at 1280 × 800 and in a + /// small window where the grid scrolls (#245). Unsaved work (a material, no geometry) keeps the + /// Start card up and turns the click into the question naming that sample, so nothing loads. + #[test] + fn every_sample_is_reachable_from_the_start_card() { + for (w, h) in [(1280.0, 800.0), (900.0, 560.0)] { + let mut scrolled = false; + for sample in fieldcraft_engine::sample::SAMPLES { + let mut app = ModelApp::new(Session::default()); + app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})) + .unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut frame = |app: &mut ModelApp, events: Vec| { + time += 0.1; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let card = |ctx: &egui::Context| ctx.read_response(samples::card_id(sample.name)).map(|r| (r.rect, r.interact_rect)); + // Wheel down over the grid until the card is in view: wholly, or (when the grid is + // shorter than a card) most of it. + let in_view = |c: Option<(egui::Rect, egui::Rect)>| { + c.is_some_and(|(r, i)| i.is_positive() && i.height() >= (r.height() * 0.6).min(r.height() - 0.5)) + }; + // Over the first card, which is in view before any scrolling. + let first = ctx + .read_response(samples::card_id(samples::ordered()[0].name)) + .map_or(egui::pos2(w / 2.0, h / 2.0), |r| r.interact_rect.center()); + for _ in 0..40 { + if in_view(card(&ctx)) { + break; + } + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + frame(&mut app, vec![egui::Event::PointerMoved(first), wheel]); + frame(&mut app, vec![]); + scrolled = true; + } + assert!(in_view(card(&ctx)), "{w}×{h}: {} is never in view: {:?}", sample.name, card(&ctx)); + let at = card(&ctx).map_or(egui::Pos2::ZERO, |(_, i)| i.center()); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, vec![button(true)]); + frame(&mut app, vec![button(false)]); + assert_eq!(app.prompt, Some(files::Prompt::Unsaved(files::Replace::Sample(sample.name))), "{w}×{h}: clicking {}", sample.name); + } + // The small window cannot show them all at once. + if w < 1000.0 { + assert!(scrolled, "{w}×{h}"); + } + } + } + /// Opening a sample, then another, frames each in the home view; an edit keeps the camera /// (#233, #223). #[test] diff --git a/crates/ui-egui/src/samples.rs b/crates/ui-egui/src/samples.rs new file mode 100644 index 0000000..4d86e1b --- /dev/null +++ b/crates/ui-egui/src/samples.rs @@ -0,0 +1,179 @@ +//! The samples as a grid of cards (the Start card, File › Samples) and as rows (Help › Samples): +//! every sample of `fieldcraft_engine::sample::SAMPLES`, the flagship ones first, each with its +//! thumbnail where one exists. Thumbnails are the gallery captures (docs/media/examples) cropped +//! and scaled to 320 px, embedded at build time so they work on the web too. + +use egui::{Rect, Sense, TextureHandle, vec2}; +use fieldcraft_engine::sample::{SAMPLES, Sample}; + +use crate::theme::Tokens; + +/// The order the samples are offered in: these first, then any other in the registry's order. +const FLAGSHIP: &[&str] = &["crank-arm", "drop-test", "rubber-seal", "heated-bracket", "bracket-hex", "flow-past", "cantilever", "impact", "parts"]; + +/// The embedded thumbnails (PNG, 320 px wide), by sample name. +const THUMBNAILS: &[(&str, &[u8])] = &[ + ("crank-arm", include_bytes!("../thumbnails/crank-arm.png")), + ("crank-mirror", include_bytes!("../thumbnails/crank-mirror.png")), + ("drop-test", include_bytes!("../thumbnails/drop-test.png")), + ("rubber-seal", include_bytes!("../thumbnails/rubber-seal.png")), + ("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")), + ("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")), +]; + +/// The thumbnails' aspect ratio (height over width). +const THUMB_ASPECT: f32 = 213.0 / 320.0; +/// The narrowest card; the grid fits as many columns as this allows. +const MIN_CARD_W: f32 = 180.0; +const MAX_COLUMNS: usize = 4; +const GAP: f32 = 10.0; + +/// Every sample, flagship first. +pub fn ordered() -> Vec<&'static Sample> { + let rank = |s: &Sample| FLAGSHIP.iter().position(|n| *n == s.name).unwrap_or(FLAGSHIP.len()); + let mut out: Vec<&'static Sample> = SAMPLES.iter().collect(); + // Stable: samples outside the flagship list keep the registry's order. + out.sort_by_key(|s| rank(s)); + out +} + +/// The id of a sample's card in the grid (tests and the control channel read its response). +pub fn card_id(name: &str) -> egui::Id { + egui::Id::new(("fc_sample_card", name)) +} + +/// A PNG decoded to an egui image; `None` when it does not decode. +fn decode(bytes: &[u8]) -> Option { + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().ok()?; + let mut buf = vec![0; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buf).ok()?; + let size = [info.width as usize, info.height as usize]; + let pixels = buf.get(..info.buffer_size())?; + match info.color_type { + png::ColorType::Rgba => Some(egui::ColorImage::from_rgba_unmultiplied(size, pixels)), + png::ColorType::Rgb => Some(egui::ColorImage::from_rgb(size, pixels)), + _ => None, + } +} + +/// The thumbnail of sample `name`, uploaded once; `None` when it has none. +fn thumbnail(ctx: &egui::Context, name: &'static str) -> Option { + let key = egui::Id::new(("fc_sample_thumbnail", name)); + if let Some(cached) = ctx.data(|d| d.get_temp::>(key)) { + return cached; + } + let texture = THUMBNAILS.iter().find(|(n, _)| *n == name).and_then(|(_, bytes)| { + let image = decode(bytes); + if image.is_none() { + log::warn!("sample thumbnail {name}: does not decode"); + } + image.map(|i| ctx.load_texture(format!("sample:{name}"), i, egui::TextureOptions::LINEAR)) + }); + ctx.data_mut(|d| d.insert_temp(key, texture.clone())); + texture +} + +/// The samples as a grid of cards in a vertical scroll area with a solid (always drawn) +/// scrollbar; `open` gets the one clicked. +pub fn grid(ui: &mut egui::Ui, t: &Tokens, max_height: f32, open: &mut Option<&'static str>) { + ui.spacing_mut().scroll = egui::style::ScrollStyle::solid(); + egui::ScrollArea::vertical().id_salt("fc_sample_grid").max_height(max_height).auto_shrink([false, true]).show(ui, |ui| { + let width = ui.available_width(); + let columns = (((width + GAP) / (MIN_CARD_W + GAP)).floor() as usize).clamp(1, MAX_COLUMNS); + let card_w = ((width - GAP * (columns as f32 - 1.0)) / columns as f32).max(1.0); + for row in ordered().chunks(columns) { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = GAP; + for sample in row { + if card(ui, t, sample, card_w) { + *open = Some(sample.name); + } + } + }); + ui.add_space(GAP); + } + }); +} + +/// One sample's card: thumbnail, name and a one-line description (in full on hover). True when +/// clicked. +fn card(ui: &mut egui::Ui, t: &Tokens, sample: &'static Sample, w: f32) -> bool { + let thumb_h = (w * THUMB_ASPECT).round(); + let text_h = 40.0; + let (rect, _) = ui.allocate_exact_size(vec2(w, thumb_h + text_h), Sense::hover()); + let response = ui.interact(rect, card_id(sample.name), Sense::click()).on_hover_text(sample.about); + let painter = ui.painter_at(rect); + let fill = if response.hovered() { t.hover } else { t.panel }; + painter.rect_filled(rect, 6.0, fill); + let image_rect = Rect::from_min_size(rect.min, vec2(w, thumb_h)).shrink(4.0); + match thumbnail(ui.ctx(), sample.name) { + Some(texture) => { + egui::Image::new(&texture).corner_radius(4.0).paint_at(ui, image_rect); + } + None => { + // No capture yet: the name's initial on a plain tile. + painter.rect_filled(image_rect, 4.0, t.accent_soft); + let initial = sample.label.chars().next().map(String::from).unwrap_or_default(); + painter.text(image_rect.center(), egui::Align2::CENTER_CENTER, initial, egui::FontId::proportional(thumb_h * 0.4), t.accent); + } + } + let stroke = if response.hovered() { t.accent } else { t.border }; + painter.rect_stroke(rect, 6.0, egui::Stroke::new(1.0, stroke), egui::StrokeKind::Inside); + let text_rect = Rect::from_min_max(egui::pos2(rect.left() + 6.0, rect.top() + thumb_h + 2.0), rect.max - vec2(6.0, 2.0)); + let mut text_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect).layout(egui::Layout::top_down(egui::Align::Min))); + text_ui.add(egui::Label::new(egui::RichText::new(sample.label).strong().color(t.overlay_text)).truncate().selectable(false)); + text_ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).truncate().selectable(false)); + response.clicked() +} + +/// The samples as rows (name, what it shows, Open) for a narrow menu; `open` gets the one clicked. +pub fn rows(ui: &mut egui::Ui, t: &Tokens, open: &mut Option<&'static str>) { + let width = ui.available_width().clamp(280.0, 420.0); + ui.spacing_mut().scroll = egui::style::ScrollStyle::solid(); + egui::ScrollArea::vertical().id_salt("fc_sample_rows").auto_shrink([false, true]).show(ui, |ui| { + for sample in ordered() { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(width - 70.0); + ui.label(egui::RichText::new(sample.label).strong()); + ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap()); + }); + if ui.button("Open").clicked() { + *open = Some(sample.name); + } + }); + ui.add_space(4.0); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every registered sample is offered exactly once, flagship first. + #[test] + fn every_sample_is_offered_once_flagship_first() { + let names: Vec<&str> = ordered().iter().map(|s| s.name).collect(); + assert_eq!(names.len(), SAMPLES.len()); + for s in SAMPLES { + assert_eq!(names.iter().filter(|n| **n == s.name).count(), 1, "{}", s.name); + } + assert_eq!(names.first(), Some(&"crank-arm")); + } + + /// Every flagship name and every thumbnail names a registered sample, and every thumbnail + /// decodes at 320 px wide. + #[test] + fn thumbnails_and_flagship_names_are_registered_samples() { + for name in FLAGSHIP.iter().chain(THUMBNAILS.iter().map(|(n, _)| n)) { + assert!(fieldcraft_engine::sample::find(name).is_some(), "{name}"); + } + for (name, bytes) in THUMBNAILS { + let image = decode(bytes).unwrap_or_else(|| panic!("{name} does not decode")); + assert_eq!(image.size[0], 320, "{name}"); + } + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index ff885d1..713a755 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -92,11 +92,20 @@ fn overlays(app: &mut ModelApp, ui: &egui::Ui, rect: Rect, t: &Tokens) -> Vec