//! The FieldCraft egui front end. //! //! A thin shell over [`fieldcraft_engine::Session`]: panels read engine state and act through //! commands ([`ModelApp::run`]); nothing here owns model data. The window follows the Craft //! family's layout (SolidCraft, VectorCraft): //! //! - the title bar ([`appbar`]): the app, quick actions (File menu, save, undo, redo, Solve, //! search, theme, help) and the model's document tab; //! - the ribbon ([`toolbar`]): the workspace switcher, its tabs, and panels of icon buttons whose //! labelled drop-downs list the whole catalogue (coming-soon entries greyed with their //! milestone); //! - the browser on the left ([`browser`]): the model's geometry, mesh and definitions; //! - the viewport ([`viewport`]) with its navigation bar, orientation gizmo and selection bar //! (picking faces, edges, vertices, bodies, nodes and elements: [`selection`]); //! - the context panel on the right ([`context`]), only while there is context: the open command //! form, the results controls, the selected definition's properties; //! - the command line and status ([`palette`]), and the journal timeline at the bottom //! ([`timeline`]). Every command is also one search away (S or Cmd/Ctrl+K). //! //! The control channel's requests are handled here ([`control`]); the desktop app owns the //! socket. Structure and conventions follow SolidCraft's `crates/ui-egui` (MIT OR Apache-2.0), //! see ATTRIBUTION.md. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod appbar; pub mod browser; pub mod capture; pub mod context; pub mod control; pub mod elide; pub mod fields; pub mod files; pub mod form; pub mod frame; pub mod framing; pub mod gpu; pub mod icons; pub mod navigation; pub mod palette; pub mod reply; pub mod results; pub mod results_panel; pub mod scene; pub mod selection; pub mod theme; pub mod timeline; pub mod titlebar; pub mod toolbar; pub mod viewport; use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; use fieldcraft_engine::catalogue::{self, Tab, Workspace}; use fieldcraft_engine::{EngineError, Session}; use fieldcraft_render::glyphs::Glyphs; use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView, Vec3}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; pub use control::ControlRequest; pub use files::{Filter, Services}; pub use scene::Style; /// The version shown in Help and over the control channel. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// View settings: what is shown and how, and which menu or search is open. `ui.set` changes any /// of them by name. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiState { pub style: Style, pub show_nodes: bool, /// The open design and imported surfaces. pub show_geometry: bool, /// Elements. pub show_mesh: bool, /// Boundary condition and load symbols. pub show_loads: bool, /// Reference point, coupling and rigid wall symbols. pub show_interactions: bool, pub perspective: bool, pub dark: bool, /// The browser (left). pub show_tree: bool, /// The journal timeline (bottom). pub show_timeline: bool, /// The mouse navigation preset ([`navigation::NAV_PRESETS`] id; remembered per user). pub nav_preset: String, /// The wheel zooms the other way (remembered per user). pub reverse_zoom: bool, /// The workspace shown, and its toolbar tab. pub workspace: Workspace, pub tab: Tab, /// The command search (S, Cmd/Ctrl+K). pub palette: bool, /// The open menu: `file`, `help`, `workspace`, `view`, or a toolbar panel's drop-down as /// `/` (`mesh/Tet`). pub menu: Option, } impl Default for UiState { fn default() -> Self { UiState { style: Style::ShadedEdges, show_nodes: false, show_geometry: true, show_mesh: true, show_loads: true, show_interactions: true, perspective: false, dark: true, show_tree: true, show_timeline: true, nav_preset: navigation::DEFAULT_PRESET.into(), reverse_zoom: false, workspace: Workspace::default(), tab: Tab::default(), palette: false, menu: None, } } } impl UiState { /// Show `tab`, on the current workspace when it has it, else on the first that does. pub fn select_tab(&mut self, tab: Tab) { self.tab = tab; self.workspace = Workspace::showing(tab, self.workspace); } /// Show workspace `w` on its first tab. pub fn select_workspace(&mut self, w: Workspace) { self.workspace = w; self.tab = w.tabs().first().copied().unwrap_or(Tab::Tools); } /// What the viewport shows. pub fn show(&self) -> scene::Show { scene::Show { geometry: self.show_geometry, mesh: self.show_mesh, nodes: self.show_nodes, loads: self.show_loads, interactions: self.show_interactions, } } } /// What a left drag in the viewport does (the navigation bar's modes). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum NavMode { #[default] Orbit, Pan, Zoom, } /// The parameter form a command opens when started from the Commands panel. #[derive(Clone, Debug, PartialEq)] pub struct ParamsDialog { pub command: &'static str, /// The parameters as JSON text. pub text: String, pub error: Option, /// The command refused one parameter: its path and the problem phrased for the form, shown /// beside its field. pub refused: Option<(String, String)>, /// The paths of the fields edited: their problems show; the others wait for OK. pub touched: std::collections::BTreeSet, /// OK was tried with something missing: every problem shows. pub attempted: bool, /// The JSON text is shown instead of the typed fields. pub advanced: bool, /// Which opening of a form this is (counts up): a form opened over another one, even of the /// same command, gets fresh widget ids, so nothing typed into the one it replaces carries over. pub opening: u64, /// Opened from another form's "Create …" button: the form to go back to. pub back: Option>, } /// A command that failed: its message, and the parameter it refused (path, form message). struct Failure { message: String, refused: Option<(String, String)>, } /// The form a "Create …" button left, and where the item made goes in it. #[derive(Clone, Debug, PartialEq)] pub struct Back { pub dialog: ParamsDialog, pub collection: fieldcraft_engine::schema::Collection, /// The reference field's path in its parameters (`a/b`). pub path: String, /// The collection's names before: the one added is the new item. pub before: Vec, } /// A status bar message. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct Status { pub text: String, pub error: bool, } /// Selection by picking in the viewport ([`selection`]): the filter, what is selected and what /// is under the pointer, and what they are drawn with. #[derive(Default)] pub struct PickState { pub filter: selection::Filter, pub selection: selection::Selection, /// What a click would pick under the pointer. pub hover: Option, /// A left drag draws a selection box (nodes, element faces, elements) instead of orbiting. pub box_mode: bool, /// The box being dragged (window points). pub dragging: Option<(egui::Pos2, egui::Pos2)>, /// Where the pointer was when the hover was worked out (window points). pub hover_at: Option, /// Bumped whenever the selection changes, and whenever the hover does. generation: u64, hover_generation: u64, /// The geometry tags behind the drawn mesh and the model revision they were read at. index: (Option, Arc), /// The names of the drawn geometry surfaces, in the order they were added to the display. names: Vec, /// The selection's highlight and what it was built for (kept while only the hover changes). selected_overlay: (Option, Arc), /// The highlight overlay (the selection's and the hover's) and what it was built for. overlay: (Option, Arc), /// What the hover was last worked out for: the pointer, the camera, the scene, the filter /// and the viewport. pub(crate) hover_key: Option<(Option, Camera, u64, selection::Filter, Option)>, /// The model revision the selection's mesh labels were last checked at. checked: Option, } /// The viewport's renderer state. #[derive(Default)] pub struct Viewport { /// Where the viewport was drawn last frame (points). pub rect: Option, /// The GPU target when drawing with wgpu; `None` draws on the CPU. pub gpu: Option, pub slot: gpu::SceneSlot, /// The scene key last handed to the GPU. pub uploaded: Option, /// The selection highlight for the GPU, and the overlay key last handed to it. pub overlay_slot: gpu::SceneSlot, pub overlay_uploaded: Option, /// CPU fallback: the texture and what it shows (scene and overlay keys, camera, size). cpu: Option<(egui::TextureHandle, (u64, u64), Camera, [usize; 2])>, /// CPU fallback: the scene key the renderer last refused. cpu_failed: Option, /// GPU errors reported by the device (see [`gpu::GpuErrors`]), shown in the status bar. pub gpu_errors: gpu::GpuErrors, /// The overlays drawn over the viewport last frame (legend, selection bar, gizmo, navigation /// bar): a fit leaves them free ([`framing::free_part`]). pub overlays: Vec, /// What the current drag does to the camera, chosen when it starts. pub drag: Option, /// The point the current orbit turns about (drawn as a marker while orbiting). pub orbit_pivot: Option, } /// A probed node: its values as lines of text, for the step, mode and frame they were read from. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct Probe { pub node: u32, pub step: String, pub mode: usize, /// The explicit field frame (`None`: the last). pub frame: Option, pub lines: Vec, } /// What a results plot was built for. #[derive(Clone, Debug, PartialEq)] struct PlotKey { revision: u64, /// The result's identity: every solve stores a new one. result: usize, current: bool, view: fieldcraft_engine::results::ResultsView, /// The mode and field frame drawn (an animated explicit step changes frames). pick: fieldcraft_engine::results::Pick, frame: Option, } /// A screenshot asked for over the control channel. struct Shot { token: u64, path: Option, reply: Sender, /// Frames waited so far: the request's own changes get drawn before the picture is taken. frames: u32, requested: bool, /// App time after which the request is answered with an error. deadline: f64, } pub struct ModelApp { pub session: Session, pub ui: UiState, pub cam: Camera, pub status: Option, pub dialog: Option, /// Forms opened so far (numbers each [`ParamsDialog::opening`]). forms_opened: u64, pub viewport: Viewport, pub icons: icons::IconCache, /// The toolbar's buttons drawn last frame, by catalogue id (for the control channel). pub toolbar_rects: Vec<(&'static str, egui::Rect, toolbar::Fit)>, /// The GPU adapter, for the status bar and diagnostics. pub gpu_info: Option, /// Bumped whenever the model may have changed. revision: u64, display: (Option, DisplayMesh), /// The analysis setup's symbols, built with the display data. glyphs: Glyphs, /// The results plot and what it was built for. plot: (Option, Option>), /// The node probed last (a click on a results plot). pub probe: Option, /// The reaction sums asked for last in the results panel. pub reactions: Option, /// The last command reply with more to it than its status line, shown in the context panel. pub reply: Option, /// Whether results were shown last frame (the Results tab comes up when they appear). results_shown: bool, /// The framing the viewport does when it next draws ([`framing::Reframe`]). reframe: Option, /// The open document ([`Session::opened`]) the view was framed for. framed_document: Option, /// Tessellated design bodies with their names, kept while the same design is open. bodies: (Option>, Vec<(selection::BodyNames, fieldcraft_geometry::SurfaceMesh)>), /// The frame-independent part of the results plot (scale, contour) and what it was built for. prepared: (Option, results::Prepared), /// The built scene, its key and the box around its symbols (they reach beyond the model). scene: (Option, Scene, Aabb3), /// The theme last applied to the context. styled: Option, /// Input injected by the control channel, fed to egui one pointer event per frame. pub synthetic: Vec, synthetic_mods: egui::Modifiers, /// Files dropped on the window by the control channel (`ui.drop`): the next frame takes them /// with the window system's drops. pub synthetic_drops: Vec, control_rx: Option>, shots: Vec, shot_token: u64, /// The offscreen context `ui.capture` draws the window with (made on first use). offscreen: Option>, /// The app time (seconds) of the current frame. pub now: f64, pub quit_requested: bool, /// Host services: native file pickers, the recent-files file. pub services: files::Services, /// Recently opened and saved files. pub recent: files::Recent, /// The user's preferences as last saved. prefs: files::Prefs, /// The user's preferences as last saved. /// The setup progress the browser marks, and what it was worked out for. setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc>), /// The file opening in the background, and files waiting to open after it. pub loading: Option, pub queued: std::collections::VecDeque, /// The question the window is asking (unsaved changes, recover). pub prompt: Option, /// A design opened over a model of another part, waiting for [`files::Prompt::Design`]'s answer. pub held: Option, /// The window draws its own title bar and caption buttons (Windows, Linux). pub custom_titlebar: bool, /// The title bar runs under the macOS traffic lights. pub integrated_titlebar: bool, /// The command search's query and the command line's text. pub palette_text: String, pub command_line: String, /// Browser sections that are unfolded. pub tree_open: std::collections::BTreeSet, /// The definition selected in the browser: (kind, name), its properties on the right. pub selected: Option<(browser::Def, String)>, /// What is picked in the viewport. pub pick: PickState, /// Set while [`ModelApp::capture`] draws the window offscreen: no pointer there is the /// user's, so the hover stays as it is. capturing: bool, /// What a left drag in the viewport does. pub nav: NavMode, /// Where the window's regions were drawn last frame (`ui.inspect`'s `layout`). pub layout: std::collections::BTreeMap<&'static str, egui::Rect>, /// The toolbar's panel labels drawn last frame, by `/`. pub panel_rects: Vec<(String, egui::Rect)>, /// The timeline's items and the journal state they were built for. timeline: Option<((u64, usize, (usize, usize)), std::sync::Arc>)>, /// The browser's element counts and the model revision they were counted at. element_counts: Option<(u64, Vec<(fieldcraft_model::ElementKind, usize)>)>, /// The selected definition's property rows and what they were read for. properties: Option<((u64, browser::Def, String), std::sync::Arc>)>, /// The search's query and its results. search: Option<(String, std::sync::Arc>)>, } impl ModelApp { pub fn new(session: Session) -> ModelApp { ModelApp { session, ui: UiState::default(), cam: Camera::default(), status: None, dialog: None, forms_opened: 0, viewport: Viewport::default(), icons: icons::IconCache::default(), toolbar_rects: Vec::new(), gpu_info: None, revision: 0, display: (None, DisplayMesh::default()), glyphs: Glyphs::default(), plot: (None, None), probe: None, reactions: None, reply: None, results_shown: false, reframe: None, framed_document: None, bodies: (None, Vec::new()), prepared: (None, results::Prepared::default()), scene: (None, Scene::default(), Aabb3::EMPTY), styled: None, synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), synthetic_drops: Vec::new(), control_rx: None, shots: Vec::new(), shot_token: 0, offscreen: None, now: 0.0, quit_requested: false, services: files::Services::default(), recent: files::Recent::default(), prefs: files::Prefs::default(), setup: (None, std::sync::Arc::default()), loading: None, queued: std::collections::VecDeque::new(), prompt: None, held: None, custom_titlebar: false, integrated_titlebar: false, palette_text: String::new(), command_line: String::new(), tree_open: ["mesh", "results"].into_iter().map(str::to_string).collect(), selected: None, pick: PickState::default(), capturing: false, nav: NavMode::default(), layout: std::collections::BTreeMap::new(), panel_rects: Vec::new(), timeline: None, element_counts: None, search: None, properties: None, } } /// Serve control-channel requests (see [`control`]). pub fn with_control(mut self, rx: Receiver) -> Self { self.control_rx = Some(rx); self } /// Use the host's services (file pickers, the recent-files file) and load the recent list. pub fn with_services(mut self, services: files::Services) -> Self { self.recent = files::Recent::load(services.recent_path.clone()); self.prefs = files::Prefs::load(services.prefs_path.as_deref()); self.ui.nav_preset = self.prefs.nav_preset.clone(); self.ui.reverse_zoom = self.prefs.reverse_zoom; self.services = services; self } /// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer /// bits and MSAA sample count). Without it the viewport renders on the CPU. Device errors /// show in the status bar. pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32, ctx: &egui::Context) { self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples, self.viewport.gpu_errors.clone(), ctx.clone())); let info = rs.adapter.get_info(); self.gpu_info = Some(format!("{} ({:?})", info.name.trim(), info.backend)); } /// Run a command with JSON parameters: what every panel, the control channel and scripts /// do. The result or the error goes to the status bar. pub fn run(&mut self, id: &str, params: Value) -> Result { self.run_form(id, params, false).map_err(|f| f.message) } /// [`Self::run`], from a form when `form`: a refused parameter is then named by its field's /// label (the engine's message names its key, which agents need). fn run_form(&mut self, id: &str, params: Value, form: bool) -> Result { self.ensure_idle().map_err(|message| Failure { message, refused: None })?; let revision = self.session.revision(); let label = command_label(id); let r = self.session.execute(id, ¶ms).map_err(|e| match e { EngineError::Param(p) => { let message = if form { p.for_form() } else { p.for_agents() }; let shown = p.for_form(); Failure { refused: Some((p.path, shown)), message } } e => Failure { message: e.to_string(), refused: None }, }); let changed = self.session.revision() != revision; if changed { self.model_changed(); } // The results display commands reply with what is shown, which the viewport shows. let display = (id.starts_with("results.") && id != "results.reactions") || id == "solve.run"; if r.is_ok() && (changed || display) { self.follow_stage(id); } match &r { Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false), // A reply reads as one line; the whole of a rich one shows in the context panel (the // reaction sums have their own place in the results panel). Ok(v) => { let mut line = format!("{label}: {}", reply::summary(id, v)); if reply::is_rich(v) && !(id == "results.reactions" && self.plot().is_some()) { line.push_str(" (details in the panel)"); self.reply = Some(reply::Reply { command: id.to_string(), label: label.to_string(), params: params.clone(), value: v.clone() }); } self.set_status(line, false) } Err(e) => self.set_status(format!("{label}: {}", e.message), true), } r } /// Run a script (a JSON array of `{command, params}`), stopping at the first error. The /// ribbon follows its last command that has a stage. pub fn run_script(&mut self, script: &Value) -> Result<(), String> { let r = self.session.run_script(script).map(|_| ()).map_err(|e| e.to_string()); self.model_changed(); if r.is_ok() { let commands = script.as_array().into_iter().flatten().filter_map(|step| step.get("command").and_then(Value::as_str)); if let Some(id) = commands.rev().find(|id| catalogue::stage_tab(id).is_some()) { self.follow_stage(id); } } match &r { Ok(()) => self.set_status("Script done", false), Err(e) => self.set_status(format!("Script: {e}"), true), } r } /// The ribbon turns to the tab where `id` lives ([`catalogue::stage_tab`]), as a person's click /// there would, so commands run by scripts, the control channel and MCP show their stage. /// Called after a command changed the model or the results display; queries leave the ribbon /// alone, and a ribbon click never moves it. fn follow_stage(&mut self, id: &str) { if let Some(tab) = catalogue::stage_tab(id) { self.ui.select_tab(tab); } } /// What a click on a toolbar entry, a menu row or a palette result does: a command without /// parameters runs, one with parameters opens its form, and the file entries (open, save, /// write a deck, recover) go through the host's file pickers. `id` is a command or a /// catalogue entry; an entry that is coming soon does nothing and says when it comes. /// Programmatic calls use [`ModelApp::run`] or [`ModelApp::start_programmatic`]. pub fn start(&mut self, id: &str) -> Result<(), String> { self.begin(id, true) } /// `ui.start` from the control channel: as a click, but never a native file picker (entries /// that need one open their command's form). pub fn start_programmatic(&mut self, id: &str) -> Result<(), String> { self.begin(id, false) } fn begin(&mut self, id: &str, interactive: bool) -> Result<(), String> { let (command, preset) = match catalogue::find(id) { Some(c) => match c.command() { Some(command) => (command, c.preset()), None => { let e = format!("{} is coming soon ({})", c.label, c.milestone().unwrap_or("later")); self.set_status(e.clone(), false); return Err(e); } }, None => (id, json!({})), }; let spec = fieldcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?; // A command missing what it needs opens no form: the status names what is missing. if let Err(u) = self.session.availability(spec) { let e = u.to_string(); self.set_status(e.clone(), true); return Err(e); } if self.file_action(spec.id, &preset, interactive) { self.dialog = None; return Ok(()); } if spec.params.trim() == "{}" { self.dialog = None; self.run(spec.id, json!({})).map(|_| ()) } else { // The form starts with the entry's preset; the rest (a path…) is filled in there. self.open_form(spec.id, preset); Ok(()) } } /// Open a command's parameter form with `params` filled in. pub fn open_form(&mut self, command: &str, params: Value) { if let Some(spec) = fieldcraft_engine::find_command(command) { // The schema's defaults are filled in, so the form starts as the command would run. let mut params = spec.form.map_or(params.clone(), |form| fieldcraft_engine::schema::prefill(form, ¶ms)); // A required reference with one obvious candidate starts picked. if let (Some(form), Some(obj)) = (spec.form, params.as_object_mut()) { let selected = self.selected.clone(); let session = &self.session; let pick = |c| match &selected { Some((kind, name)) if kind.collection() == c => Some(name.clone()), _ => Some(session.names(c)).filter(|n| n.len() == 1).and_then(|n| n.into_iter().next()), }; form::preselect(form, obj, &pick); } self.forms_opened = self.forms_opened.wrapping_add(1); self.dialog = Some(ParamsDialog { command: spec.id, text: params.to_string(), error: None, refused: None, touched: Default::default(), attempted: false, advanced: spec.form.is_none(), opening: self.forms_opened, back: None, }); } } /// A reference field's "Create …": open the command that makes an item of `collection` over /// the open form, which comes back (with the new item at `path`) when that command runs or /// is cancelled. A provider that opens no form (a file picker, a missing requirement) leaves /// the form as it was. pub fn create_from_form(&mut self, collection: fieldcraft_engine::schema::Collection, path: String) { let Some(parent) = self.dialog.take() else { return }; let before = self.session.names(collection); let provider = collection.requirement().provider(); let _ = self.start(provider); match self.dialog.as_mut() { Some(d) if d.command == provider && d.back.is_none() => d.back = Some(Box::new(Back { dialog: parent, collection, path, before })), _ => self.dialog = Some(parent), } } /// Close the open form: back to the form it was opened from, if any. pub fn close_dialog(&mut self) { self.dialog = self.dialog.take().and_then(|d| d.back).map(|b| b.dialog); } /// The open form's command ran: back to the form it was opened from, with the item it made /// in the reference field. fn dialog_done(&mut self) { let Some(back) = self.dialog.take().and_then(|d| d.back) else { return }; let Back { mut dialog, collection, path, before } = *back; let made = self.session.names(collection).into_iter().find(|n| !before.contains(n)); if let (Some(name), Ok(mut params)) = (made, serde_json::from_str::(&dialog.text)) { let (parent, key) = path.rsplit_once('/').unwrap_or(("", &path)); let pointer = if parent.is_empty() { String::new() } else { format!("/{parent}") }; if let Some(Value::Object(obj)) = params.pointer_mut(&pointer) { obj.insert(key.into(), json!(name)); } dialog.text = params.to_string(); } self.dialog = Some(dialog); } /// Run the open parameter form; it closes when the command succeeds. pub fn run_dialog(&mut self) -> Result { let (command, params) = self.dialog_params()?; match self.run_form(command, params, true) { Ok(v) => { self.dialog_done(); Ok(v) } Err(Failure { message, refused }) => { if let Some(open) = self.dialog.as_mut() { open.error = Some(message.clone()); open.refused = refused; } Err(message) } } } /// The open form's command and parameters; parameters that are not JSON are the form's error. pub(crate) fn dialog_params(&mut self) -> Result<(&'static str, Value), String> { let Some(d) = self.dialog.as_mut() else { return Err("no command form is open".into()) }; match serde_json::from_str::(&d.text) { Ok(params) => { d.error = None; Ok((d.command, params)) } Err(e) => { let e = format!("the parameters are not JSON: {e}"); d.error = Some(e.clone()); Err(e) } } } /// Probe a node of the results shown (`solve.probe`) and keep its values for the viewport. pub fn probe_node(&mut self, node: u32) -> Result<(), String> { let Some(view) = self.session.results_view.clone() else { return Err("no results are shown".into()) }; let pick = self.plot().map_or(view.pick(), |p| p.pick); let mut params = json!({"step": view.step, "nodes": [node], "mode": pick.mode}); if let Some(frame) = pick.frame { params["frame"] = json!(frame); } let reply = match self.run("solve.probe", params) { Ok(v) => v, Err(e) => { self.probe = None; return Err(e); } }; let at = reply.pointer("/nodes/0").cloned().unwrap_or(Value::Null); let vector = |key: &str| at.get(key).and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()); let triple = |v: [f64; 3]| format!("{}, {}, {}", results::number(v[0]), results::number(v[1]), results::number(v[2])); let mut lines = vec![format!("Node {node}")]; if let Some(u) = vector("displacement") { lines.push(format!("U {} |U| {}", triple(u), results::number(fieldcraft_engine::results::magnitude(u)))); } if let Some(rf) = vector("reaction").filter(|r| r.iter().any(|c| *c != 0.0)) { lines.push(format!("RF {}", triple(rf))); } if let Some(m) = at.get("von_mises").and_then(Value::as_f64) { lines.push(format!("Von Mises {}", results::number(m))); } if let Some(v) = vector("velocity") { lines.push(format!("V {} |V| {}", triple(v), results::number(fieldcraft_engine::results::magnitude(v)))); } if let Some(shape) = vector("shape") { lines.push(format!("Mode {} shape {}", pick.mode, triple(shape))); } let contour = self.plot().and_then(|p| p.contour.as_ref().map(|c| (c.field, c.at(p.result.nodes(), node)))); if let Some((field, v)) = contour.filter(|(f, _)| *f != fieldcraft_engine::results::Field::VonMises) { lines.push(format!("{} {}", field.label(), results::number(v))); } self.set_status(lines.join(" "), false); self.probe = Some(Probe { node, step: view.step, mode: pick.mode, frame: pick.frame, lines }); Ok(()) } pub fn set_status(&mut self, text: impl Into, error: bool) { let text = text.into(); if error { log::warn!("{text}"); } self.status = Some(Status { text, error }); } /// Move an error the graphics device reported into the status bar. fn show_gpu_errors(&mut self) { let error = self.viewport.gpu_errors.lock().ok().and_then(|mut e| e.take()); if let Some(e) = error { self.status = Some(Status { text: format!("Graphics: {e}"), error: true }); } } /// The model may have changed: display data and the scene are rebuilt when next drawn. pub fn model_changed(&mut self) { self.revision = self.revision.wrapping_add(1); self.frame_new_document(); // A reply about the model is read again; any other one is out of date. self.reply = self.reply.take().and_then(|mut r| { reply::LIVE.contains(&r.command.as_str()).then_some(())?; r.value = self.session.execute(&r.command, &r.params).ok()?; Some(r) }); } /// The model's display data with its geometry (the open design's bodies, tessellated once /// per design, and the imported surfaces), rebuilt when the model changed. The first content /// to appear is framed. pub fn display(&mut self) -> &DisplayMesh { if self.display.0 != Some(self.revision) { let mut d = DisplayMesh::from_model(&self.session.model); let design = self.session.design.clone(); if !match (&design, &self.bodies.0) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (None, None) => true, _ => false, } { let mut failed = Vec::new(); let meshes: Vec<_> = design .iter() .flat_map(|d| &d.bodies) .filter_map(|body| { let tolerance = body.measure().map(|m| display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })); let mesh = tolerance.and_then(|t| body.tessellate(t)).map_err(|e| failed.push(format!("{}: {e}", body.name))).ok()?; let names = selection::BodyNames { name: body.name.clone(), faces: body.faces().iter().map(|f| f.name.clone()).collect(), edges: body.edge_names(&mesh), }; Some((names, mesh)) }) .collect(); self.bodies = (design, meshes); if !failed.is_empty() { self.set_status(format!("Some bodies cannot be drawn: {}", failed.join("; ")), true); } } self.pick.names.clear(); for (names, mesh) in &self.bodies.1 { d.add_curves(&mesh.edges); d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face); self.pick.names.push(names.clone()); } // An imported surface's faces are named by their tags. for s in &self.session.surfaces { d.add_surface(&s.mesh.positions, &s.mesh.triangles, &s.mesh.tri_face); self.pick.names.push(selection::BodyNames { name: s.name.clone(), faces: (0..s.faces).map(|f| f.to_string()).collect(), edges: Vec::new(), }); } let was_empty = self.display.1.bounds.is_empty(); self.display = (Some(self.revision), d); self.glyphs = Glyphs::from_model(&self.session.model); if was_empty && !self.display.1.bounds.is_empty() { self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Fit); } } &self.display.1 } /// Definitions whose symbols are a sample (`Clamp: 400 of 12000 nodes`). pub fn glyph_notes(&self) -> &[String] { &self.glyphs.notes } /// The key of the current scene: the model revision, every setting the scene depends on and /// the results plot shown. pub fn scene_key(&self) -> u64 { let mut h = std::hash::DefaultHasher::new(); let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize); (self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot).hash(&mut h); h.finish() } /// What the results plot is built for: the model, the result, its view and the animation /// frame; `None` when no results are shown. fn plot_key(&self) -> Option { let (view, result, current) = results::shown(&self.session)?; let frame = results::frame(view, self.now); let pick = results::pick(view, result, frame); Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame }) } /// The results plot the viewport draws, rebuilt when its key changes; `None` shows the /// model. Only the deformation follows the animation frame; the scale and contour values are /// kept while the rest of the key (the mode and field frame drawn among it) stays. pub fn plot(&mut self) -> Option> { let key = self.plot_key(); if self.plot.0 != key { self.display(); let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() }); if self.prepared.0 != still { let pick = key.as_ref().map(|k| k.pick).unwrap_or_default(); self.prepared = (still, results::prepare(&self.session, &self.display.1, pick).unwrap_or_default()); } let frame = key.as_ref().and_then(|k| k.frame); let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1)); self.plot = (key, p.map(Arc::new)); } self.plot.1.clone() } /// The scene the viewport draws, rebuilt when its key changes; its radius fits the camera. pub fn scene(&mut self) -> (u64, &Scene) { self.display(); self.plot(); let key = self.scene_key(); if self.scene.0 != Some(key) { let t = theme::Tokens::get(self.ui.dark); let (style, show) = (self.ui.style, self.ui.show()); let mut s = match &self.plot.1 { Some(p) => results::scene(p, style, show, &t), None => scene::build(&self.display.1, style, show, &t), }; // Symbols follow the drawn nodes: the deformed shape of a results plot. let drawn = self.plot.1.as_ref().map_or(&self.display.1, |p| &p.display); let mut symbols = scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), show, self.plot.1.is_some(), &t); if show.geometry && self.plot.1.is_none() { symbols.union(scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t)); scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t); } self.scene = (Some(key), s, symbols); } self.scene.1.radius = scene::radius(&self.drawn_bounds(), &self.cam); (key, &self.scene.1) } /// What the scene built last draws: the model, a results plot's (deformed) shape and the /// setup's symbols. fn drawn_bounds(&self) -> Aabb3 { let mut bounds = self.display.1.bounds; if let Some(p) = &self.plot.1 { bounds.union(p.display.bounds); } bounds.union(self.scene.2); bounds } /// A CPU render of the viewport's scene with the current camera and the selection, as PNG /// bytes. pub fn render_png(&mut self, w: usize, h: usize) -> Result, fieldcraft_render::RenderError> { let cam = self.cam; let (_, overlay) = self.overlay(); let (_, scene) = self.scene(); fieldcraft_render::render_layers_png(scene, &[&overlay], &cam, w, h) } /// The geometry tags behind the drawn mesh, read again when the model changed. fn pick_index(&mut self) -> Arc { self.display(); if self.pick.index.0 != Some(self.revision) { self.pick.index = (Some(self.revision), Arc::new(selection::Index::new(&self.session.model, &self.display.1))); } self.pick.index.1.clone() } /// Run `f` with the picker of what the viewport draws: a results plot's elements, or the /// model's elements and its geometry where no element faces cover it (as [`scene::build`]). pub fn with_picker(&mut self, f: impl FnOnce(&selection::Picker) -> R) -> R { let index = self.pick_index(); let plot = self.plot(); let (show_mesh, show_geometry) = (self.ui.show_mesh, self.ui.show_geometry); let display = plot.as_ref().map_or(&self.display.1, |p| &p.display); let mesh_faces = show_mesh && !display.tris.is_empty(); let picker = selection::Picker { display, names: &self.pick.names, model: &self.session.model, index: &index, elements: show_mesh, geometry: show_geometry && !mesh_faces, curves: show_geometry, }; f(&picker) } /// The viewport's projection as drawn last frame (`None` before it was drawn). pub fn projector(&mut self) -> Option { let rect = self.viewport.rect?; let radius = self.scene().1.radius; Some(fieldcraft_render::pick::Projector::new(&self.cam, f64::from(rect.width()), f64::from(rect.height()), radius)) } /// The nearest drawn point under window point `at`: the pick ray's first hit on the results /// plot when one shows, else on the shown elements' faces or the design's surfaces. pub fn surface_point(&mut self, at: egui::Pos2) -> Option { let rect = self.viewport.rect.filter(|r| r.contains(at))?; let (o, d) = self.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height())); let t = if let Some(plot) = self.plot() { plot.display.hit_element_face(o, d).map(|h| h.t) } else { let (mesh, geometry) = (self.ui.show_mesh, self.ui.show_geometry); let display = self.display(); let faces = mesh.then(|| display.hit_element_face(o, d)).flatten().map(|h| h.t); let surface = geometry.then(|| display.hit_surface(o, d)).flatten().map(|h| h.t); faces.into_iter().chain(surface).min_by(f64::total_cmp) }?; let p = o + d * t; p.is_finite().then_some(p) } /// What an orbit that starts on empty space turns about: the centre of what is drawn (the /// target when nothing is). pub fn drawn_centre(&mut self) -> Vec3 { self.scene(); let b = self.drawn_bounds(); if b.is_empty() || !b.center().is_finite() { self.cam.target } else { b.center() } } /// What a click at window point `at` would pick with the current filter. pub fn pick_at(&mut self, at: egui::Pos2) -> Option { let rect = self.viewport.rect.filter(|r| r.contains(at))?; let proj = self.projector()?; let filter = self.pick.filter; let local = [f64::from(at.x - rect.left()), f64::from(at.y - rect.top())]; self.with_picker(|p| p.pick(&proj, local, filter)) } /// Change the selection by `items` (see [`selection::Mode`]); the status bar says what is /// selected. pub fn select(&mut self, items: impl IntoIterator, mode: selection::Mode) { if self.pick.selection.apply(items, mode) { self.pick.generation = self.pick.generation.wrapping_add(1); } let mut text = format!("Selected: {}", self.pick.selection.summary()); if self.pick.selection.capped() { text.push_str(&format!(" (at most {} items)", selection::MAX_SELECTION)); } self.set_status(text, false); } pub fn clear_selection(&mut self) { if !self.pick.selection.is_empty() { self.pick.selection.clear(); self.pick.generation = self.pick.generation.wrapping_add(1); self.set_status("Selection cleared", false); } } /// Pick at window point `at` and change the selection by it; a plain click on nothing clears /// the selection. Returns what was picked. pub fn select_at(&mut self, at: egui::Pos2, mode: selection::Mode) -> Option { // A click where the hover was just worked out picks what is highlighted. let current = self.pick.hover_key.as_ref().is_some_and(|k| k.0 == Some(at) && k.1 == self.cam && k.2 == self.scene_key() && k.3 == self.pick.filter); let item = if current { self.pick.hover.clone() } else { self.pick_at(at) }; match &item { Some(i) => self.select([i.clone()], mode), None if mode == selection::Mode::Replace => self.clear_selection(), None => {} } item } /// Select what is visible inside the box between window points `a` and `b` (nodes, element /// faces or elements, with the filter); returns how many items it held. pub fn select_box(&mut self, a: egui::Pos2, b: egui::Pos2, mode: selection::Mode) -> usize { let (Some(rect), Some(proj)) = (self.viewport.rect, self.projector()) else { return 0 }; let filter = self.pick.filter; let r = [a.x - rect.left(), a.y - rect.top(), b.x - rect.left(), b.y - rect.top()].map(f64::from); let items = self.with_picker(|p| p.pick_box(&proj, r, filter)); let n = items.len(); self.select(items, mode); n } /// Set what is under the pointer (`None` when nothing, or the pointer is elsewhere). pub fn set_hover(&mut self, item: Option) { if self.pick.hover != item { self.pick.hover = item; self.pick.hover_generation = self.pick.hover_generation.wrapping_add(1); } } /// The selection and the hover drawn over the scene in the theme's highlight colours, /// rebuilt when either or the scene changes; and its key. pub fn overlay(&mut self) -> (u64, Arc) { let hash = |v: &dyn Fn(&mut std::hash::DefaultHasher)| { let mut h = std::hash::DefaultHasher::new(); v(&mut h); h.finish() }; let scene = self.scene_key(); let selected = hash(&|h| (scene, self.pick.generation).hash(h)); let key = hash(&|h| (selected, self.pick.hover_generation).hash(h)); if self.pick.overlay.0 == Some(key) { return (key, self.pick.overlay.1.clone()); } let t = theme::Tokens::get(self.ui.dark); // The selection's part is kept while only the hover changes. if self.pick.selected_overlay.0 != Some(selected) { let sel = std::mem::take(&mut self.pick.selection); let s = self.with_picker(|p| p.overlay(&sel, None, theme::rgb(t.pick_selected), theme::rgb(t.pick_hover))); self.pick.selection = sel; self.pick.selected_overlay = (Some(selected), Arc::new(s)); } let mut s = (*self.pick.selected_overlay.1).clone(); if let Some(hover) = self.pick.hover.clone() { // Over result contours the hover is an outline, so it never hides the result (#207). let fill = self.plot.1.is_none(); let h = self.with_picker(|p| p.hover(&hover, theme::rgb(t.pick_hover), fill)); s.tris.extend(h.tris); s.lines.extend(h.lines); s.points.extend(h.points); } self.pick.overlay = (Some(key), Arc::new(s)); (key, self.pick.overlay.1.clone()) } /// Drop selected nodes, elements and element faces the model no longer has (after an edit, /// an undo or a remesh); geometry stays selected by name. fn check_selection(&mut self) { if self.pick.checked == Some(self.revision) { return; } self.pick.checked = Some(self.revision); let model = &self.session.model; let before = self.pick.selection.len(); self.pick.selection.retain(|item| match item { selection::Item::Geometry(_) => true, selection::Item::Node(n) => model.node(*n).is_some(), selection::Item::Element(e) => model.element(*e).is_some(), selection::Item::Face(f) => model.face_nodes(*f).is_some(), }); if self.pick.selection.len() != before { self.pick.generation = self.pick.generation.wrapping_add(1); } } /// Open `model.set.create`'s form with the selection as the new set's members. pub fn create_set_from_selection(&mut self) -> Result<(), String> { let params = self.selection_set(None)?; self.open_form("model.set.create", params); Ok(()) } /// `model.set.create`'s parameters for a set of the selection, named after `base` (`Set-1` /// by default; the first name not taken). fn selection_set(&self, base: Option<&str>) -> Result { if self.pick.selection.is_empty() { return Err("nothing is selected".into()); } let name = selection::fresh_set_name(&self.session.model, base.unwrap_or("Set-1")); Ok(selection::with_members(&json!({"name": name}), &self.pick.selection)) } /// Put the selection into the open command form: a set's members, or a new set made from it /// (`model.set.create`, named after the definition) as the target of a boundary condition, /// load, section or coupling. pub fn use_selection_in_dialog(&mut self) -> Result<(), String> { let Some(d) = self.dialog.as_ref() else { return Err("no command form is open".into()) }; let command = d.command; if self.pick.selection.is_empty() { return Err("nothing is selected".into()); } let params: Value = serde_json::from_str(&d.text).map_err(|e| format!("the parameters are not JSON: {e}"))?; if !params.is_object() { return Err("the parameters must be a JSON object".into()); } let Some(target) = selection::target(command, ¶ms) else { return Err(format!("{command} takes no selection")) }; let filled = match target { selection::Target::Members => selection::with_members(¶ms, &self.pick.selection), selection::Target::Edges => { selection::with_edges(¶ms, &self.pick.selection).ok_or("no edge is selected: pick edges with Select: Edges")? } selection::Target::Set | selection::Target::TargetSet => { let base = params.get("name").and_then(Value::as_str).map(|n| format!("{n}-set")); let set = self.selection_set(base.as_deref())?; let name = set.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); self.run("model.set.create", set)?; selection::with_set(¶ms, target, &name) } }; if let Some(open) = self.dialog.as_mut() { open.text = serde_json::to_string_pretty(&filled).unwrap_or_else(|_| filled.to_string()); open.error = None; } Ok(()) } /// Frame what is drawn in the part of the viewport the overlays leave free (see /// [`framing`]), keeping the direction of view. pub fn fit_view(&mut self) { // A view asked for replaces one still waiting to be done. self.reframe = None; self.scene(); let b = self.drawn_bounds(); let (aspect, free) = self.free_part(); self.cam.fit_in(&b, aspect, free); } /// The viewport's aspect and the part of it the overlays leave free, as fractions (the whole /// of a square view before the viewport is first drawn). fn free_part(&self) -> (f64, [f64; 4]) { match self.viewport.rect.filter(|r| r.width() > 1.0 && r.height() > 1.0) { Some(r) => (f64::from(r.width()) / f64::from(r.height()), framing::free_part(r, &self.viewport.overlays)), None => (1.0, fieldcraft_render::FULL_VIEW), } } /// Whether every corner of what is drawn shows in the free part of the viewport. fn framed(&mut self) -> bool { let Some(r) = self.viewport.rect.filter(|r| r.width() > 1.0 && r.height() > 1.0) else { return true }; self.scene(); let b = self.drawn_bounds(); if b.is_empty() { return true; } let [x0, y0, x1, y1] = framing::free_part(r, &self.viewport.overlays); let (w, h) = (f64::from(r.width()), f64::from(r.height())); let p = fieldcraft_render::pick::Projector::new(&self.cam, w, h, self.scene.1.radius); b.corners().into_iter().all(|c| p.screen(c).is_some_and(|[x, y, _]| x >= x0 * w && x <= x1 * w && y >= y0 * h && y <= y1 * h)) } /// Another model, sample or design opened (or the window's first one): a fresh start's home /// view when the viewport next draws. fn frame_new_document(&mut self) { if self.framed_document != Some(self.session.opened()) { self.framed_document = Some(self.session.opened()); self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Home); } } /// The home view: isometric, the whole model framed. pub fn home_view(&mut self) { self.set_view(StandardView::Iso); } /// Look along a standard view, the whole model framed. pub fn set_view(&mut self, v: StandardView) { self.cam.set_view(v); self.fit_view(); } /// Do the framing asked for ([`framing::Reframe`]) once there is something to frame; the /// viewport calls it when it knows its size and its overlays, before drawing. pub(crate) fn apply_reframe(&mut self) { let Some(r) = self.reframe else { return }; if self.display().bounds.is_empty() { return; } self.reframe = None; match r { framing::Reframe::Home => self.home_view(), framing::Reframe::Fit => self.fit_view(), framing::Reframe::IfOffscreen => { if !self.framed() { self.fit_view(); } } } } /// Per-frame logic before layout: theme, camera projection, control requests, screenshots. pub fn logic(&mut self, ctx: &egui::Context) { self.now = ctx.input(|i| i.time); if self.styled != Some(self.ui.dark) { theme::apply(ctx, self.ui.dark); self.styled = Some(self.ui.dark); } self.drain_control(ctx); if self.poll_loading() { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } self.dropped_files(ctx); self.shortcuts(ctx); self.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 }; if !self.synthetic.is_empty() { ctx.request_repaint(); } // Results coming up (a solve, a results command) bring the Results tab. let shown = self.session.results_view.is_some(); if shown && !self.results_shown { self.ui.select_tab(Tab::Results); self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::IfOffscreen); } self.frame_new_document(); self.results_shown = shown; self.check_selection(); // A selected definition that is gone (undo, delete) is no longer selected. if self.selected.as_ref().is_some_and(|(kind, name)| !kind.exists(&self.session.model, name)) { self.selected = None; } if self.session.results_view.as_ref().is_some_and(|v| v.animate) { ctx.request_repaint_after(std::time::Duration::from_secs_f64(results::PERIOD / results::FRAMES as f64)); } self.screenshots(ctx); } /// Undo (Ctrl/Cmd+Z), redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), open (Ctrl/Cmd+O), save /// (Ctrl/Cmd+S) and save as (Ctrl/Cmd+Shift+S), unless a text field has the keyboard (it /// undoes its own typing). fn shortcuts(&mut self, ctx: &egui::Context) { if ctx.memory(|m| m.focused().is_some()) { return; } // Escape clears the viewport's selection first (a second one cancels an open form). if !self.pick.selection.is_empty() && self.ui.menu.is_none() && !self.ui.palette && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { self.clear_selection(); } let shortcut = |modifiers, key| egui::KeyboardShortcut::new(modifiers, key); let shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; // Shift first: the plain shortcut matches it too. let (redo, undo, save_as, save, open) = ctx.input_mut(|i| { let redo = i.consume_shortcut(&shortcut(shift, egui::Key::Z)) || i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Y)); let undo = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Z)); let save_as = i.consume_shortcut(&shortcut(shift, egui::Key::S)); let save = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::S)); (redo, undo, save_as, save, i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::O))) }); if redo { let _ = self.run("edit.redo", json!({})); } else if undo { let _ = self.run("edit.undo", json!({})); } if save_as { self.save_as(); } else if save { self.save(); } if open { self.open_dialog(None); } // The command search: S (as in SolidCraft) or Cmd/Ctrl+K. let search = ctx.input_mut(|i| { i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::K)) || i.consume_key(egui::Modifiers::NONE, egui::Key::S) }); if search { self.ui.palette = true; self.ui.menu = None; } } /// Inject synthetic events: one pointer event per frame (egui reads a press and release in /// the same frame as a click, but a drag needs frames between its moves), keys up to their /// release. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { let Some(first) = self.synthetic.first() else { return }; let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { raw.events.push(egui::Event::PointerMoved(*p)); } // egui keeps the modifier keys as input state, changed by their own event. match first { egui::Event::PointerButton { modifiers, pressed, .. } => { // A drag keeps its press's modifiers until the release. self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::default() }; raw.events.push(egui::Event::ModifiersChanged(*modifiers)); } egui::Event::Key { modifiers, .. } => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), egui::Event::PointerMoved(_) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)), _ => {} } let n = n.min(self.synthetic.len()); raw.events.extend(self.synthetic.drain(..n)); } /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { self.show_gpu_errors(); self.layout.clear(); appbar::app_bar(self, ui); toolbar::toolbar(self, ui); if self.ui.show_timeline { timeline::timeline(self, ui); } palette::command_line(self, ui); if self.ui.show_tree { browser::browser(self, ui); } context::panel(self, ui); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui)); palette::popup(self, ui.ctx()); files::show(self, ui.ctx()); if self.custom_titlebar { titlebar::resize_zones(ui); } self.save_prefs(); } /// The setup progress ([`fieldcraft_engine::progress`]) the browser marks, worked out again /// only when the model or its results change. pub fn setup_stages(&mut self) -> std::sync::Arc> { use fieldcraft_engine::requirement::Requirement; let key = (self.revision, self.session.revision(), Requirement::Result.met(&self.session), Requirement::ResultsShown.met(&self.session)); if self.setup.0 != Some(key) { self.setup = (Some(key), std::sync::Arc::new(self.session.progress())); } self.setup.1.clone() } /// Save the preferences the window changed (a View menu toggle, `ui.set`). pub(crate) fn save_prefs(&mut self) { if self.ui.nav_preset != self.prefs.nav_preset || self.ui.reverse_zoom != self.prefs.reverse_zoom { self.prefs = files::Prefs { nav_preset: self.ui.nav_preset.clone(), reverse_zoom: self.ui.reverse_zoom }; self.prefs.save(self.services.prefs_path.as_deref()); } } /// Remember where a region of the window was drawn (`ui.inspect`'s `layout`). pub fn place(&mut self, region: &'static str, rect: egui::Rect) { self.layout.insert(region, rect); } /// Open, or close when it is open, a menu (see [`UiState::menu`]). pub fn toggle_menu(&mut self, key: &str) { self.ui.menu = if self.ui.menu.as_deref() == Some(key) { None } else { Some(key.to_string()) }; } fn drain_control(&mut self, ctx: &egui::Context) { /// Requests run on this thread, so a flood would starve drawing; the rest wait for the /// next frame. const PER_FRAME: usize = 64; let Some(rx) = self.control_rx.take() else { return }; for _ in 0..PER_FRAME { let Ok(req) = rx.try_recv() else { break }; match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { let _ = req.reply.send(v); } control::Outcome::Screenshot { path } => { self.shot_token = self.shot_token.wrapping_add(1); let deadline = self.now + SHOT_TIMEOUT; self.shots.push(Shot { token: self.shot_token, path, reply: req.reply.clone(), frames: 0, requested: false, deadline }); } } ctx.request_repaint(); } self.control_rx = Some(rx); } /// Ask for pending screenshots once their request's changes are drawn, and save the ones /// that arrived. fn screenshots(&mut self, ctx: &egui::Context) { if self.shots.is_empty() { return; } let arrived: Vec<(u64, std::sync::Arc)> = ctx.input(|i| { i.raw .events .iter() .filter_map(|e| match e { egui::Event::Screenshot { user_data, image, .. } => { user_data.data.as_ref().and_then(|d| d.downcast_ref::()).map(|t| (*t, image.clone())) } _ => None, }) .collect() }); for (token, image) in arrived { if let Some(i) = self.shots.iter().position(|s| s.token == token) { let shot = self.shots.remove(i); let _ = shot.reply.send(control::save_screenshot(&image, shot.path.as_deref())); } } let now = self.now; // No frame was presented in time (a hidden window, a locked screen): draw the window // offscreen instead. let (late, waiting): (Vec, Vec) = std::mem::take(&mut self.shots).into_iter().partition(|s| now > s.deadline); self.shots = waiting; for s in late { let _ = s.reply.send(control::capture_to(self, control::window_size(ctx), s.path.as_deref())); } self.shots.retain_mut(|s| { s.frames += 1; if !s.requested && s.frames >= 3 { ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token))); s.requested = true; } true }); if !self.shots.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } } /// The chordal tolerance bodies are drawn with: a thousandth of their size, within 1 µm–1 mm. fn display_tolerance(b: &Aabb3) -> f64 { let d = b.diagonal(); if d.is_finite() { (d * 1e-3).clamp(1e-3, 1.0) } else { 0.1 } } /// Seconds a screenshot waits for a presented frame. const SHOT_TIMEOUT: f64 = 8.0; /// Vertical field of view of the perspective camera (radians). const PERSPECTIVE_FOV: f64 = 0.6; impl ModelApp { /// Why a toolbar entry or a command cannot start now: what its command needs that the /// session lacks (`fieldcraft_engine::requirement`). `None` when it can start, and for /// coming-soon entries and unknown ids (which say so themselves). pub fn unavailable(&self, id: &str) -> Option { let command = catalogue::find(id).map_or(Some(id), |c| c.command())?; let spec = fieldcraft_engine::find_command(command)?; self.session.availability(spec).err() } } /// A command's name as people see it (its label). pub fn command_label(id: &str) -> &str { fieldcraft_engine::find_command(id).map_or(id, |c| c.label) } #[cfg(test)] mod tests { use fieldcraft_render::Vec3; use super::*; #[test] fn commands_run_through_the_engine_and_report_in_the_status_bar() { let mut app = ModelApp::new(Session::default()); let key = app.scene_key(); app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Add Node done")); assert_ne!(app.scene_key(), key, "a model change rebuilds the scene"); let err = app.run("mesh.node.add", json!({"label": 0, "xyz": [0, 0, 0]})).unwrap_err(); assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.starts_with("Add Node:")), "{err}"); app.run("model.info", json!({})).unwrap(); assert!(app.status.as_ref().is_some_and(|s| s.text.starts_with("Model Info: 1 node, 0 elements")), "{:?}", app.status); } /// Every command run from the UI reports one readable line, never its JSON, in every state /// tried (empty, a solved sample); a reply with more to it opens in the context panel. #[test] fn every_reply_reads_as_one_line() { let empty = Session::default(); let mut solved = ModelApp::new(Session::default()); solved.open_sample("cantilever"); solved.run("solve.run", json!({})).unwrap(); let mut ran = 0; for session in [empty, solved.session.clone()] { // Every command bare, and the queries that need parameters with ones the sample has. let bare = fieldcraft_engine::commands().iter().filter(|c| !c.id.starts_with("file.")).map(|c| (c.id, json!({}))); let queries = [ ("model.set.resolve", json!({"name": "root"})), ("solve.probe", json!({"step": "Load", "nodes": [1, 2]})), ("solve.probe", json!({"step": "Modes", "set": "tip", "mode": 1})), ("results.reactions", json!({"step": "Load"})), ]; for (id, params) in bare.chain(queries) { let mut app = ModelApp::new(session.clone()); let r = app.run(id, params); let Some(status) = app.status.clone() else { continue }; let text = status.text; assert!(!text.contains('\n'), "{id}: {text}"); if r.is_ok() { ran += 1; assert!(!text.contains("{\"") && !text.contains("[{") && text.chars().count() <= 200, "{id}: {text}"); } } } assert!(ran >= 20, "only {ran} commands ran"); } /// Check Model and the Step Table open their readable view in the context panel, which /// follows the model as it changes. #[test] fn check_model_opens_its_diagnostics_in_the_panel() { let mut app = ModelApp::new(Session::default()); app.run("model.check", json!({})).unwrap(); let status = app.status.clone().unwrap(); assert_eq!(status.text, "Check Model: 2 errors, 1 warning (details in the panel)"); assert!(app.reply.as_ref().is_some_and(|r| r.command == "model.check")); assert!(context::has_context(&mut app)); app.run("model.units", json!({"system": "mm-t-s-N-MPa"})).unwrap(); assert_eq!(app.reply.as_ref().map(|r| r.value["warnings"].clone()), Some(json!(0)), "the check follows the model"); let mut app = ModelApp::new(Session::default()); app.open_sample("cantilever"); app.run("model.activity", json!({})).unwrap(); assert_eq!(app.status.unwrap().text, "Step Table: 2 steps, 1 boundary condition, 1 load (details in the panel)"); } /// Commands run from outside the ribbon bring their tab; queries and Tools commands leave it. #[test] fn the_ribbon_follows_commands_to_their_stage() { let mut app = ModelApp::new(Session::default()); app.ui.select_tab(Tab::Results); app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Prepare, Tab::Mesh)); app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap(); assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Model, Tab::Properties)); // A query and a failed command leave it; so does undo (Tools). app.run("mesh.quality", json!({})).ok(); let _ = app.run("model.load.create", json!({"name": "bad"})); app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.ui.tab, Tab::Properties); // A script follows its last command with a stage. app.run_script(&json!([{"command": "mesh.node.add", "params": {"label": 2, "xyz": [1, 0, 0]}}, {"command": "model.info", "params": {}}])) .unwrap(); assert_eq!(app.ui.tab, Tab::Mesh); // The ribbon's own entries never move it. app.ui.select_tab(Tab::Properties); app.start("model.units").unwrap(); assert_eq!(app.ui.tab, Tab::Properties); } /// Mesh Quality reads as a sentence in the status bar, not as its JSON report. #[test] fn mesh_quality_reports_in_a_line() { let mut app = ModelApp::new(Session::default()); // Nothing to measure: the command needs a mesh and says so. assert!(app.run("mesh.quality", json!({})).is_err()); app.run_script(&fieldcraft_engine::sample::script()).unwrap(); app.run("mesh.quality", json!({})).unwrap(); let status = app.status.clone().unwrap(); assert!(status.text.starts_with("Mesh Quality: 226 elements, all valid; scaled Jacobian min "), "{}", status.text); assert!(!status.error && !status.text.contains('{')); } #[test] fn starting_a_command_opens_its_form_and_running_it_closes_the_form() { let mut app = ModelApp::new(Session::default()); app.start("model.info").unwrap(); assert!(app.dialog.is_none()); app.start("mesh.node.add").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("mesh.node.add")); app.dialog.as_mut().unwrap().text = "not json".into(); assert!(app.run_dialog().is_err()); assert!(app.dialog.as_ref().is_some_and(|d| d.error.is_some())); app.dialog.as_mut().unwrap().text = r#"{"label": 7, "xyz": [1, 2, 3]}"#.into(); app.run_dialog().unwrap(); assert!(app.dialog.is_none()); assert!(app.session.model.node(7).is_some()); assert!(app.start("no.such").is_err()); } /// A parameter the command refuses is named by its field's label in the form and the status /// bar; the command itself (agents) still names the key. #[test] fn a_refused_parameter_is_named_by_its_label() { let mut app = ModelApp::new(Session::default()); app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap(); app.start("model.material.plasticity").unwrap(); app.dialog.as_mut().unwrap().text = r#"{"name": 7}"#.into(); let e = app.run_dialog().unwrap_err(); assert_eq!(e, "Material: must be a name"); let d = app.dialog.as_ref().unwrap(); assert_eq!(d.refused, Some(("name".to_string(), "Material: must be a name".to_string()))); assert!(app.status.as_ref().is_some_and(|s| s.text == "Plasticity: Material: must be a name")); let raw = app.session.execute("model.material.plasticity", &json!({"name": 7})).unwrap_err(); assert_eq!(raw.to_string(), "`name` must be a name"); } /// A required reference with one candidate starts picked (the browser's selection wins); with /// none, "Create …" opens the providing command over the form, which comes back with the new /// item picked (or as it was, when that command is cancelled). #[test] fn reference_fields_pick_the_only_candidate_and_create_a_missing_one() { use fieldcraft_engine::schema::Collection; let params = |app: &ModelApp| serde_json::from_str::(&app.dialog.as_ref().unwrap().text).unwrap(); let steel = json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}); let mut app = ModelApp::new(Session::default()); app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); app.run("model.set.create", json!({"name": "all", "nodes": [1]})).unwrap(); // (Opened directly: from the toolbar, Section waits for a material.) app.open_form("model.section.create", json!({})); let opened = params(&app); assert_eq!(opened["set"], "all"); assert!(opened.get("material").is_none()); // Cancelled: back to the section form as it was. app.create_from_form(Collection::Material, "material".into()); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create")); app.close_dialog(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.section.create")); assert_eq!(params(&app), opened); // Made: back with it picked. app.create_from_form(Collection::Material, "material".into()); app.dialog.as_mut().unwrap().text = steel.to_string(); app.run_dialog().unwrap(); let d = app.dialog.as_ref().unwrap(); assert_eq!(d.command, "model.section.create"); assert!(d.back.is_none()); assert_eq!(params(&app)["material"], "Steel"); app.close_dialog(); assert!(app.dialog.is_none()); // One material: Plasticity starts with it. app.start("model.material.plasticity").unwrap(); assert_eq!(params(&app)["name"], "Steel"); // Two: the one selected in the browser, else nothing. app.run("model.material.create", json!({"name": "Al", "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}})).unwrap(); app.start("model.material.plasticity").unwrap(); assert!(params(&app).get("name").is_none()); app.selected = Some((browser::Def::Material, "Steel".into())); app.start("model.material.plasticity").unwrap(); assert_eq!(params(&app)["name"], "Steel"); } #[test] fn toolbar_entries_start_their_command_and_planned_ones_do_nothing() { let mut app = ModelApp::new(Session::default()); app.start("model.material.create").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create")); app.dialog = None; let journal = app.session.journal.len(); let err = app.start("mesh.order.quadratic").unwrap_err(); assert_eq!(err, "Quadratic is coming soon (M2)"); assert!(app.dialog.is_none()); assert_eq!(app.session.journal.len(), journal); assert_eq!(app.status.as_ref().map(|s| s.error), Some(false)); // An entry whose command misses what it needs opens nothing and says what is missing. let err = app.start("deck.write.abaqus").unwrap_err(); assert_eq!(err, "deck.write cannot run yet. Needs a mesh — Mesh › Tet Mesh"); assert!(app.dialog.is_none()); assert_eq!(app.status.as_ref().map(|s| (s.error, s.text.as_str())), Some((true, err.as_str()))); // With a model, a deck entry opens deck.write's form with its format filled in. app.run("file.sample", json!({"name": "cantilever"})).unwrap(); app.start("deck.write.abaqus").unwrap(); let d = app.dialog.as_ref().unwrap(); assert_eq!((d.command, d.text.as_str()), ("deck.write", r#"{"format":"abaqus"}"#)); } /// The ribbon, the command search and Solve grey what the model cannot run yet, and say why. #[test] fn entries_missing_what_they_need_are_greyed_with_the_reason() { let mut app = ModelApp::new(Session::default()); let entry = |app: &ModelApp, id: &str| crate::toolbar::describe(app)["entries"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap().clone(); let plasticity = entry(&app, "model.material.plasticity"); assert_eq!(plasticity["enabled"], false); assert_eq!(plasticity["unavailable"]["reason"], "Needs a material — Properties › Material"); assert_eq!(plasticity["unavailable"]["missing"][0]["provider"], "model.material.create"); assert_eq!(entry(&app, "model.material.create")["enabled"], true); assert_eq!(entry(&app, "mesh.order.quadratic")["enabled"], false, "coming soon"); let solve = app.unavailable("solve.run").unwrap(); assert_eq!(solve.lines(), "Needs a mesh — Mesh › Tet Mesh\nNeeds a step — Steps › Step"); app.run("file.sample", json!({"name": "cantilever"})).unwrap(); assert_eq!(entry(&app, "model.material.plasticity")["enabled"], true); assert!(app.unavailable("solve.run").is_none()); assert_eq!(entry(&app, "results.contour")["unavailable"]["reason"], "Needs a solved step — Solve › Solve"); } #[test] fn the_sample_fills_the_view_and_renders() { let mut app = ModelApp::new(Session::default()); app.run_script(&fieldcraft_engine::sample::script()).unwrap(); let d = app.display().clone(); assert!(!d.tris.is_empty() && !d.edges.is_empty()); // Framed when the viewport first draws. draw(&mut app, &egui::Context::default(), 1280.0, 800.0); assert!(app.framed(), "{:?}", app.cam); let png = app.render_png(64, 48).unwrap(); assert!(png.starts_with(&[0x89, b'P', b'N', b'G'])); } #[test] fn the_open_design_and_imported_surfaces_are_drawn() { let dir = std::env::temp_dir().join(format!("fieldcraft-ui-design-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let design = dir.join("plate.solidcraft"); let bytes = fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap(); std::fs::write(&design, bytes).unwrap(); let mut app = ModelApp::new(Session::default()); app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap(); let d = app.display().clone(); assert!(!d.surface_tris.is_empty() && !d.surface_edges.is_empty(), "the design's bodies are drawn"); assert!(d.tris.is_empty()); // Without elements, geometry draws as faces with their boundaries; the camera frames it. let t = theme::Tokens::dark(); let s = scene::build(&d, Style::ShadedEdges, scene::Show::DEFAULT, &t); assert_eq!(s.tris.len(), d.surface_tris.len()); assert!(s.tris.iter().all(|tri| tri.color == theme::rgb(t.geometry))); assert!(!d.bounds.is_empty()); let _ = std::fs::remove_dir_all(&dir); } /// The sample cantilever in an app, solved (static step `Load`, frequency step `Modes`). fn solved_app() -> ModelApp { ModelApp::new(results::tests::solved_cantilever()) } fn node_at(app: &ModelApp, xyz: [f64; 3]) -> u32 { results::tests::node_at(&app.session, xyz) } #[test] fn setup_symbols_show_with_their_toggles_and_fade_over_results() { let mut app = ModelApp::new(Session::default()); app.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); let t = theme::Tokens::get(app.ui.dark); let count = |app: &mut ModelApp, c: egui::Color32| app.scene().1.tris.iter().filter(|x| x.color == theme::rgb(c)).count(); assert!(count(&mut app, t.glyph_constraint) > 0, "the clamp's cones"); assert!(app.scene().1.lines.iter().any(|l| l.color == theme::rgb(t.glyph_load)), "the push's arrows"); // The symbols reach beyond the model: the clipping radius holds them. let radius = app.scene().1.radius; app.ui.show_loads = false; assert_eq!(count(&mut app, t.glyph_constraint), 0); assert!(app.scene().1.radius <= radius); app.ui.show_loads = true; // Over a results plot they are muted. app.run("solve.static", json!({})).unwrap(); app.run("results.deformed", json!({"step": "Load"})).unwrap(); assert_eq!(count(&mut app, t.glyph_constraint), 0); let muted = t.glyph_colors().over_results(theme::rgb(t.viewport_bottom)); assert!(app.scene().1.tris.iter().any(|x| x.color == muted.constraint)); assert!(app.glyph_notes().is_empty()); } #[test] fn solving_shows_the_result_on_the_results_tab() { let mut app = ModelApp::new(Session::default()); app.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); app.start("solve.static").unwrap(); app.run_dialog().unwrap(); let view = app.session.results_view.clone().unwrap(); // A static step first shows its contours on the undeformed shape (#77). assert_eq!((view.step.as_str(), view.contour, view.deformed), ("Load", Some(fieldcraft_engine::results::Field::VonMises), false)); app.logic(&egui::Context::default()); assert_eq!(app.ui.tab, Tab::Results); app.run("solve.frequency", json!({})).unwrap(); app.run("results.deformed", json!({"step": "Modes"})).unwrap(); let view = app.session.results_view.clone().unwrap(); assert_eq!((view.step.as_str(), view.mode, view.contour), ("Modes", 1, Some(fieldcraft_engine::results::Field::Displacement))); // The plot draws contour bands: more triangles than the model's faces. let faces = app.display().tris.len(); assert!(app.scene().1.tris.len() > faces); app.run("results.clear", json!({})).unwrap(); assert!(app.plot().is_none()); // The model's faces again (without the setup's symbols). app.ui.show_loads = false; assert_eq!(app.scene().1.tris.len(), faces); } /// One frame of the window at `w` × `h` points. fn draw(app: &mut ModelApp, ctx: &egui::Context, w: f32, h: f32) { let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); app.ui(ui); }); out.textures_delta.clear(); } fn is_iso(cam: &Camera) -> bool { let (yaw, pitch) = StandardView::Iso.angles(); (cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9 } /// Opening a sample, then another, frames each in the home view; an edit keeps the camera /// (#233, #223). #[test] fn opening_another_sample_frames_it_and_an_edit_keeps_the_camera() { let mut app = ModelApp::new(Session::default()); let ctx = egui::Context::default(); app.run("file.sample", json!({"name": "crank-arm"})).unwrap(); draw(&mut app, &ctx, 1280.0, 800.0); assert!(is_iso(&app.cam) && app.framed(), "{:?}", app.cam); // Looked at from elsewhere, an edit keeps that camera. app.cam.orbit(120.0, -40.0); app.cam.zoom_at(0.2, None); let turned = app.cam; app.run("model.material.create", json!({"name": "Al", "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}})).unwrap(); draw(&mut app, &ctx, 1280.0, 800.0); assert_eq!(app.cam, turned); // Another sample: a fresh start's home view, all of it in sight. app.run("file.sample", json!({"name": "bracket-hex"})).unwrap(); draw(&mut app, &ctx, 1280.0, 800.0); assert!(is_iso(&app.cam) && app.framed(), "{:?}", app.cam); } /// After a solve the model is in sight, clear of the legend, at the window sizes people use; /// a fit leaves the legend free with the deformed shape too (#223, #76). #[test] fn results_come_up_framed_clear_of_the_legend() { for (w, h) in [(1280.0, 800.0), (700.0, 1000.0)] { let mut app = ModelApp::new(Session::default()); let ctx = egui::Context::default(); app.run("file.sample", json!({"name": "crank-arm"})).unwrap(); draw(&mut app, &ctx, w, h); // Zoomed in on a corner before solving: the results bring it all back. let corner = app.display().bounds.max; app.cam.zoom_at(0.3, Some(corner)); app.run("solve.run", json!({})).unwrap(); draw(&mut app, &ctx, w, h); draw(&mut app, &ctx, w, h); let legend = *app.layout.get("legend").expect("the legend is placed"); assert!(app.framed(), "{w}×{h}: {:?}", app.cam); app.run("results.deformed", json!({"show": true, "scale": 200})).unwrap(); draw(&mut app, &ctx, w, h); app.fit_view(); assert!(app.framed(), "{w}×{h} deformed"); let view = app.viewport.rect.unwrap(); let b = app.drawn_bounds(); for (k, c) in b.corners().into_iter().enumerate() { let at = crate::viewport::to_screen(&app, c).unwrap(); assert!(!legend.contains(at) && view.contains(at), "{w}×{h}: corner {k} at {at:?}, legend {legend:?}"); } } } /// Every standard view frames the whole model: the cantilever's front view keeps both ends /// (#147), in orthographic and perspective. #[test] fn standard_views_frame_the_whole_model() { for perspective in [false, true] { let mut app = ModelApp::new(Session::default()); app.ui.perspective = perspective; let ctx = egui::Context::default(); app.run("file.sample", json!({"name": "cantilever"})).unwrap(); draw(&mut app, &ctx, 1280.0, 800.0); for v in StandardView::ALL { app.set_view(v); assert!(app.framed(), "{} view, perspective {perspective}: {:?}", v.label(), app.cam); } } } /// Rendered on the CPU from the front, undeformed, with Z displacement contours: the free end /// (lowest band) is blue, the clamped end (highest band) red. #[test] fn contours_render_with_known_colours_at_known_nodes() { let mut app = solved_app(); app.run("results.contour", json!({"step": "Load", "field": "displacement_z", "bands": 6})).unwrap(); app.run("results.deformed", json!({"show": false})).unwrap(); app.ui.style = Style::Shaded; app.set_view(StandardView::Front); app.fit_view(); let (w, h) = (400, 200); let cam = app.cam; let radius = app.scene().1.radius; let canvas = fieldcraft_render::render(app.scene().1, &cam, w, h).unwrap(); let pixel = |p: Vec3| { let (x, y, _) = cam.to_screen(p, w as f64, h as f64, radius).unwrap(); let i = (y as usize * w + x as usize) * 4; [canvas.rgba[i], canvas.rgba[i + 1], canvas.rgba[i + 2]] }; // Cell centres on the front face (y = 0), one at each end. let tip = pixel(Vec3::new(97.5, 0.0, 12.5)); let root = pixel(Vec3::new(2.5, 0.0, 12.5)); assert!(tip[2] > tip[0] + 60, "the free end is blue: {tip:?}"); assert!(root[0] > root[2] + 60, "the clamped end is red: {root:?}"); } #[test] fn probing_a_node_reads_its_values_and_stale_results_say_so() { let mut app = solved_app(); app.run("results.deformed", json!({"step": "Load"})).unwrap(); let tip = node_at(&app, [100.0, 0.0, 0.0]); app.probe_node(tip).unwrap(); let probe = app.probe.clone().unwrap(); assert_eq!((probe.node, probe.step.as_str()), (tip, "Load")); assert!(probe.lines.iter().any(|l| l.starts_with("U ")) && probe.lines.iter().any(|l| l.starts_with("Von Mises")), "{probe:?}"); // A root node carries a reaction. app.probe_node(node_at(&app, [0.0, 0.0, 0.0])).unwrap(); assert!(app.probe.as_ref().unwrap().lines.iter().any(|l| l.starts_with("RF "))); // The plot under a click: a ray onto the tip's end face picks a node there. let plot = app.plot().unwrap(); let (node, _) = plot.display.pick(Vec3::new(1000.0, 0.5, 0.5), Vec3::new(-1.0, 0.0, 0.0)).unwrap(); assert_eq!(app.session.model.node(node).unwrap().xyz[0], 100.0); // An edit leaves the plot shown but stale; probing it is refused. app.run( "model.material.edit", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}, "density": 2.7e-9}), ) .unwrap(); assert!(app.plot().is_some_and(|p| p.stale)); let e = app.probe_node(tip).unwrap_err(); assert!(e.contains("stale") && app.probe.is_none(), "{e}"); } #[test] fn explicit_frames_are_drawn_and_animated() { let mut app = ModelApp::new(Session::default()); app.run_script(&fieldcraft_engine::sample::impact()).unwrap(); app.run("solve.explicit", json!({})).unwrap(); // The last frame at true scale: the bar has moved back off the wall's side (x < 0 is // the wall, so nothing passes it) and carries stress contours. let last = app.plot().unwrap(); assert_eq!(last.frame().map(|(k, _, n)| (k, n)), Some((10, 11))); assert!(last.contour.as_ref().is_some_and(|c| c.field == fieldcraft_engine::results::Field::VonMises)); assert!(last.display.bounds.min.x > -1e-6, "{:?}", last.display.bounds); // Frame 0 is the undeformed bar at rest on its initial velocity. app.run("results.frame", json!({"frame": 0})).unwrap(); let first = app.plot().unwrap(); assert_eq!(first.display.tris, app.display().tris); // Animated, the frames run through once per cycle. app.run("results.animate", json!({})).unwrap(); app.now = results::PERIOD * 0.5; assert_eq!(app.plot().unwrap().pick.frame, Some(5)); let image = app.capture(capture::CaptureSize { width: 1200.0, height: 800.0, scale: 1.0 }).unwrap(); assert_eq!(image.size, [1200, 800]); // Probing reads the frame drawn. app.run("results.animate", json!({"on": false})).unwrap(); app.run("results.frame", json!({"frame": 4})).unwrap(); app.probe_node(node_at(&app, [100.0, 0.0, 0.0])).unwrap(); let probe = app.probe.clone().unwrap(); assert_eq!(probe.frame, Some(4)); assert!(probe.lines.iter().any(|l| l.starts_with("U ")), "{probe:?}"); } #[test] fn the_capture_shows_the_legend_and_panel_over_the_plot() { let mut app = solved_app(); app.run("results.deformed", json!({"step": "Load"})).unwrap(); let image = app.capture(capture::CaptureSize { width: 1200.0, height: 800.0, scale: 1.0 }).unwrap(); let t = theme::Tokens::dark(); // The legend's top swatch (the highest band, red) is drawn left of the middle. let red = t.contour[4]; let near = |c: egui::Color32, d: egui::Color32| { (i32::from(c.r()) - i32::from(d.r())).abs() < 30 && (i32::from(c.g()) - i32::from(d.g())).abs() < 40 && (i32::from(c.b()) - i32::from(d.b())).abs() < 30 }; let found = (0..600).any(|x| (150..400).any(|y| near(image.pixels[y * 1200 + x], red))); assert!(found, "a red legend swatch"); } fn region(app: &ModelApp, name: &str) -> egui::Rect { app.layout.get(name).copied().unwrap_or_else(|| panic!("no {name} in {:?}", app.layout.keys())) } /// The family layout: title bar, ribbon, browser left, viewport, command line and timeline at /// the bottom; the context panel on the right only while there is context. #[test] fn the_window_is_laid_out_like_the_other_crafts() { let mut app = ModelApp::new(Session::default()); app.open_sample("cantilever"); let size = capture::CaptureSize { width: 1400.0, height: 900.0, scale: 1.0 }; // `layout` is the live window's; a capture leaves it, so draw a frame of the UI directly. let frame = |app: &mut ModelApp| { let ctx = egui::Context::default(); for _ in 0..2 { let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size.width, size.height))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); out.textures_delta.clear(); } }; frame(&mut app); let (bar, ribbon, browser, view) = (region(&app, "app_bar"), region(&app, "ribbon"), region(&app, "browser"), region(&app, "viewport")); let (line, timeline) = (region(&app, "command_line"), region(&app, "timeline")); assert_eq!((bar.top(), bar.height()), (0.0, appbar::HEIGHT)); assert!((ribbon.top() - bar.bottom()).abs() < 1.0 && (ribbon.height() - toolbar::HEIGHT).abs() < 1.0, "{ribbon:?}"); // The browser's content, inside its margin. assert!(browser.left() < 8.0 && (browser.width() - browser::WIDTH).abs() <= 14.0, "{browser:?}"); assert!(view.left() >= browser.right() - 1.0 && view.top() >= ribbon.bottom() - 1.0 && view.bottom() <= line.top() + 1.0); assert!((timeline.bottom() - 900.0).abs() < 1.0 && line.bottom() <= timeline.top() + 1.0); assert!((view.right() - 1400.0).abs() < 1.0, "no context: the viewport reaches the right edge"); assert!(!app.layout.contains_key("context")); for chrome in ["nav_bar", "axis_view", "workspace_switcher", "document_tab", "app_bar:solve"] { let r = region(&app, chrome); assert!(r.is_positive(), "{chrome}"); } assert!(view.contains_rect(region(&app, "nav_bar")) && view.contains_rect(region(&app, "axis_view"))); // A form brings the context panel; the viewport makes room. app.start("model.material.create").unwrap(); frame(&mut app); let context = region(&app, "context"); assert!(context.right() > 1390.0 && context.width() > 280.0, "{context:?}"); assert!(region(&app, "viewport").right() <= context.left() + 1.0); // The search and a drop-down are drawn when their state says so. app.dialog = None; app.ui.palette = true; app.ui.menu = Some("mesh/Tet".into()); app.ui.select_tab(Tab::Mesh); frame(&mut app); assert!(app.layout.contains_key("palette") && app.layout.contains_key("toolbar_dropdown"), "{:?}", app.layout.keys()); assert_eq!(app.ui.workspace, Workspace::Prepare); } /// No entry is lost: on every tab, at a wide and a narrow window, every catalogue entry is a /// button, a collapsed panel or a » panel, and a row of its panel's drop-down once it is open. #[test] fn every_catalogue_entry_is_reachable_from_its_tab() { for width in [1400.0, 960.0] { for tab in Tab::ALL { let mut app = ModelApp::new(Session::default()); app.ui.select_tab(tab); let ctx = egui::Context::default(); let run = |app: &mut ModelApp| { let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 800.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); out.textures_delta.clear(); }; run(&mut app); for c in catalogue::tab_entries(tab) { assert!(app.toolbar_rects.iter().any(|(id, ..)| *id == c.id), "{width}: {} is not on the {tab:?} tab", c.id); } for p in catalogue::panels(tab) { let Some(panel) = p.first().map(|c| c.panel) else { continue }; let collapsed_or_more = app.toolbar_rects.iter().any(|(id, _, f)| *id == p[0].id && matches!(f, toolbar::Fit::More)); if collapsed_or_more { continue; } app.ui.menu = Some(toolbar::panel_key(tab, panel)); run(&mut app); run(&mut app); for c in p { let row = app.toolbar_rects.iter().any(|(id, r, f)| *id == c.id && *f == toolbar::Fit::Menu && r.is_positive()); assert!(row, "{width}: {} has no row in the {panel} drop-down", c.id); } app.ui.menu = None; } } } } /// A face picked by name stays selected through a remesh and highlights the new mesh's faces; /// the set made from it resolves on the new mesh. #[test] fn a_geometry_pick_survives_a_remesh() { let block = |n: u32| { let corners = [[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10]]; json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [n, n, n], "faces": {"4": "tip"}}) }; let mut app = ModelApp::new(Session::default()); app.run("mesh.block", block(2)).unwrap(); app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0))); app.set_view(StandardView::Right); app.fit_view(); let at = viewport::to_screen(&app, Vec3::new(10.0, 5.5, 4.5)).unwrap(); let tip = selection::Item::Geometry(fieldcraft_model::GeometryRef::face("Bar", "tip").unwrap()); assert_eq!(app.select_at(at, selection::Mode::Replace), Some(tip.clone())); assert_eq!(app.overlay().1.tris.len(), 2 * 2 * 2); // A click beside the model clears the selection; a Shift click adds back. assert_eq!(app.select_at(egui::pos2(105.0, 55.0), selection::Mode::Replace), None); assert!(app.pick.selection.is_empty()); app.select_at(at, selection::Mode::Add); // Remeshed finer: the tip is still selected and highlights the new faces. app.run("edit.undo", json!({})).unwrap(); app.run("mesh.block", block(4)).unwrap(); app.logic(&egui::Context::default()); assert_eq!(app.pick.selection.items().iter().collect::>(), vec![&tip]); assert_eq!(app.overlay().1.tris.len(), 4 * 4 * 2); app.create_set_from_selection().unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.set.create")); app.run_dialog().unwrap(); assert_eq!(app.session.model.resolve_set("Set-1").unwrap().faces.len(), 16); // Mesh labels that are gone leave the selection. app.select([selection::Item::Node(1), selection::Item::Element(9999)], selection::Mode::Replace); app.run("mesh.node.add", json!({"label": 100_000, "xyz": [50, 50, 50]})).unwrap(); app.logic(&egui::Context::default()); assert_eq!(app.pick.selection.items().iter().collect::>(), vec![&selection::Item::Node(1)]); } #[test] fn display_tolerance_follows_the_size() { let b = |max: [f64; 3]| Aabb3 { min: Vec3::ZERO, max: max.into() }; assert_eq!(display_tolerance(&b([100.0, 0.0, 0.0])), 0.1); assert_eq!(display_tolerance(&b([0.0; 3])), 1e-3); assert_eq!(display_tolerance(&b([1e9, 0.0, 0.0])), 1.0); assert_eq!(display_tolerance(&Aabb3::EMPTY), 1e-3); } #[test] fn graphics_device_errors_reach_the_status_bar() { let mut app = ModelApp::new(Session::default()); if let Ok(mut e) = app.viewport.gpu_errors.lock() { *e = Some("Out of Memory".into()); } app.show_gpu_errors(); assert_eq!(app.status, Some(Status { text: "Graphics: Out of Memory".into(), error: true })); assert!(app.viewport.gpu_errors.lock().unwrap().is_none(), "each error is shown once"); } /// An orbit pivots on the model point under the press (the pick ray's first hit, on the /// face toward the eye) and keeps it still on screen; from empty space it pivots on the /// centre of what is drawn (#262). #[test] fn orbit_pivots_on_the_point_under_the_cursor_or_the_drawn_centre() { let corners = [[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10]]; let mut app = ModelApp::new(Session::default()); app.run("mesh.block", json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [2, 2, 2]})).unwrap(); app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0))); app.set_view(StandardView::Right); app.fit_view(); // The right view looks along −X: the face under the cursor is x = 10. let on = Vec3::new(10.0, 7.0, 3.0); let at = viewport::to_screen(&app, on).unwrap(); let pivot = app.surface_point(at).unwrap(); assert!(pivot.dist(on) < 1e-6, "{pivot:?}"); app.cam.orbit_about(pivot, 60.0, -30.0); let after = viewport::to_screen(&app, pivot).unwrap(); assert!((after - at).length() < 1e-3, "{at:?} -> {after:?}"); // Empty space: the centre of the drawn bounds. let pivot = app.drawn_centre(); assert!(pivot.dist(Vec3::new(5.0, 5.0, 5.0)) < 1e-6, "{pivot:?}"); assert!(app.surface_point(egui::pos2(105.0, 55.0)).is_none()); // Nothing drawn: the target. let mut empty = ModelApp::new(Session::default()); empty.cam.target = Vec3::new(1.0, 2.0, 3.0); assert_eq!(empty.drawn_centre(), Vec3::new(1.0, 2.0, 3.0)); } }