From ad84a52ed2be5be24980f9351c11f6f52ea95cfc Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:09:29 -0400 Subject: [PATCH] Viewport: one framing rule for open, results, fit, home and standard views Every fit frames what is drawn (the deformed shape and the setup's symbols included) with Camera::fit_in in the part of the viewport the overlays leave free: the results legend, the selection bar, the gizmo and the navigation bar (framing::free_part). Standard views fit too, so a long beam's front view keeps both ends (#147); fit and home leave the legend clear (#76). The view frames itself in the home view when another model, sample or design is opened (Session::opened; the same design opened again after an edit keeps the camera) (#233), and when results come up with part of the model out of sight (#223). ui.inspect's layout reports the legend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/files.rs | 12 +- crates/ui-egui/src/framing.rs | 100 +++++++++++++++ crates/ui-egui/src/lib.rs | 187 +++++++++++++++++++++++++--- crates/ui-egui/src/results_panel.rs | 30 ++++- crates/ui-egui/src/viewport.rs | 34 ++++- docs/control-protocol.md | 4 +- 6 files changed, 332 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/src/framing.rs diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 30d0295..d44b2b7 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -216,8 +216,6 @@ pub struct Loading { done: String, /// The file it opens, for the recent list. recent: Option, - /// Whether it brings a different model or new geometry, which the view then frames. - fit: bool, /// The command form that started it: closed when it succeeds, showing the error when not. form: Option<&'static str>, /// The finished session copy and the command's reply. @@ -235,7 +233,6 @@ pub struct Opened { reply: Value, done: String, recent: Option, - fit: bool, form: Option<&'static str>, } @@ -373,8 +370,7 @@ impl ModelApp { let base = if fresh { Session::default() } else { self.session.clone() }; let (rx, pending) = spawn(base, command, params); self.set_status(format!("{title}…"), false); - let fit = fresh || recent.is_some(); - self.loading = Some(Loading { title, done, recent, fit, form: None, rx, pending, shown: false }); + self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false }); true } @@ -456,7 +452,6 @@ impl ModelApp { let Some(sample) = fieldcraft_engine::sample::find(name) else { return }; if self.run("file.sample", json!({"name": sample.name})).is_ok() { self.model_changed(); - self.fit_view(); let next = if self.session.model.steps().is_empty() { "" } else { " — Solve shows its results" }; self.set_status(format!("Opened the {} sample{next}", sample.label), false); } @@ -511,7 +506,7 @@ impl ModelApp { let Some(l) = self.loading.take() else { return false }; match outcome { Some((session, Ok(reply))) => { - let opened = Opened { session, reply, done: l.done, recent: l.recent, fit: l.fit, form: l.form }; + let opened = Opened { session, reply, done: l.done, recent: l.recent, form: l.form }; // A design of another part over a meshed or defined model: ask what to do with // the model before it changes (the job ran with the default, keep). let unrelated = opened.reply.get("related").and_then(Value::as_bool) == Some(false) @@ -552,9 +547,6 @@ impl ModelApp { let old = std::mem::replace(&mut self.session, *o.session); off_thread("fieldcraft drop".into(), move || drop(old)); self.model_changed(); - if o.fit { - self.fit_view(); - } if let Some(p) = &o.recent { self.recent.add(p); } diff --git a/crates/ui-egui/src/framing.rs b/crates/ui-egui/src/framing.rs new file mode 100644 index 0000000..f6c81b3 --- /dev/null +++ b/crates/ui-egui/src/framing.rs @@ -0,0 +1,100 @@ +//! Framing: when the view frames the model, and the part of the viewport it frames it in. +//! +//! One rule for every fit: the model as drawn (the deformed shape when results show one, and the +//! setup's symbols) is framed with [`fieldcraft_render::Camera::fit_in`] in the part of the +//! viewport the overlays (results legend, selection bar, orientation gizmo, navigation bar) leave +//! free, with a margin, keeping the direction of view. The view frames itself +//! ([`Reframe`]) when another model, sample or design is opened ([`fieldcraft_engine::Session::opened`]; +//! the same design opened again after an edit keeps the camera) and when the first model content +//! appears (home view), and when results come up with part of the model out of the free part. +//! Fit, home and the standard views frame on request. + +use egui::Rect; + +/// A framing the viewport does when it next draws, once it knows its size and its overlays. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Reframe { + /// Frame the model only if part of it is out of the free part (results coming up). + IfOffscreen, + /// Frame the model, keeping the direction of view. + Fit, + /// The home view: isometric, framed (a model opened). + Home, +} + +impl Reframe { + /// Two requests make the stronger one. + pub fn merge(a: Option, b: Reframe) -> Option { + Some(a.map_or(b, |a| a.max(b))) + } +} + +/// The free part of `view`: the largest rectangle left after cutting each overlay off (largest +/// overlays first, each cut along the side that keeps the most area), as fractions +/// `[x0, y0, x1, y1]` of the view's width and height. Overlays outside the view are ignored. +pub fn free_part(view: Rect, overlays: &[Rect]) -> [f64; 4] { + if !(view.width() > 1.0 && view.height() > 1.0) { + return fieldcraft_render::FULL_VIEW; + } + let mut sorted: Vec = overlays.iter().map(|o| o.intersect(view)).filter(|o| o.is_positive()).collect(); + sorted.sort_by(|a, b| b.area().total_cmp(&a.area())); + let mut free = view; + for o in sorted { + let o = o.intersect(free); + if !o.is_positive() { + continue; + } + let cuts = [ + Rect::from_min_max(free.min, egui::pos2(o.left(), free.bottom())), + Rect::from_min_max(egui::pos2(o.right(), free.top()), free.max), + Rect::from_min_max(free.min, egui::pos2(free.right(), o.top())), + Rect::from_min_max(egui::pos2(free.left(), o.bottom()), free.max), + ]; + if let Some(best) = cuts.into_iter().filter(|c| c.is_positive()).max_by(|a, b| a.area().total_cmp(&b.area())) { + free = best; + } + } + let (w, h) = (f64::from(view.width()), f64::from(view.height())); + [ + f64::from(free.left() - view.left()) / w, + f64::from(free.top() - view.top()) / h, + f64::from(free.right() - view.left()) / w, + f64::from(free.bottom() - view.top()) / h, + ] +} + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + + use super::*; + + fn close(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) + } + + #[test] + fn overlays_are_cut_off_along_their_cheapest_side() { + let view = Rect::from_min_size(pos2(250.0, 126.0), vec2(1000.0, 600.0)); + assert!(close(free_part(view, &[]), fieldcraft_render::FULL_VIEW)); + // A legend column on the left: the free part is right of it. + let legend = Rect::from_min_size(pos2(262.0, 138.0), vec2(170.0, 295.0)); + let f = free_part(view, &[legend]); + assert!(close(f, [0.182, 0.0, 1.0, 1.0]), "{f:?}"); + // With the navigation bar (bottom centre) and the gizmo (top right) too. + let nav = Rect::from_center_size(pos2(750.0, 704.0), vec2(220.0, 30.0)); + let gizmo = Rect::from_center_size(pos2(1182.0, 194.0), vec2(116.0, 116.0)); + let f = free_part(view, &[legend, nav, gizmo]); + assert!(f[0] >= 0.182 - 1e-9 && f[2] <= 0.874 + 1e-6 && f[3] <= (689.0 - 126.0) / 600.0 + 1e-6, "{f:?}"); + // An overlay outside the view, or an empty view, changes nothing. + assert!(close(free_part(view, &[Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0))]), fieldcraft_render::FULL_VIEW)); + assert!(close(free_part(Rect::NOTHING, &[legend]), fieldcraft_render::FULL_VIEW)); + } + + #[test] + fn the_stronger_request_wins() { + assert_eq!(Reframe::merge(None, Reframe::IfOffscreen), Some(Reframe::IfOffscreen)); + assert_eq!(Reframe::merge(Some(Reframe::Home), Reframe::IfOffscreen), Some(Reframe::Home)); + assert_eq!(Reframe::merge(Some(Reframe::Fit), Reframe::Home), Some(Reframe::Home)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cc3fc55..1ee7a3a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod fields; pub mod files; pub mod form; pub mod frame; +pub mod framing; pub mod gpu; pub mod icons; pub mod palette; @@ -253,6 +254,9 @@ pub struct Viewport { 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, } /// A probed node: its values as lines of text, for the step, mode and frame they were read from. @@ -320,6 +324,10 @@ pub struct ModelApp { 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. @@ -391,7 +399,7 @@ pub struct ModelApp { impl ModelApp { pub fn new(session: Session) -> ModelApp { - let mut app = ModelApp { + ModelApp { session, ui: UiState::default(), cam: Camera::default(), @@ -410,6 +418,8 @@ impl ModelApp { 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), @@ -446,9 +456,7 @@ impl ModelApp { element_counts: None, search: None, properties: None, - }; - app.fit_view(); - app + } } /// Serve control-channel requests (see [`control`]). @@ -763,6 +771,7 @@ impl ModelApp { /// 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(())?; @@ -822,7 +831,7 @@ impl ModelApp { self.display = (Some(self.revision), d); self.glyphs = Glyphs::from_model(&self.session.model); if was_empty && !self.display.1.bounds.is_empty() { - self.cam.fit(&self.display.1.bounds); + self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Fit); } } &self.display.1 @@ -891,13 +900,19 @@ impl ModelApp { } 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); - self.scene.1.radius = scene::radius(&bounds, &self.cam); - (key, &self.scene.1) + bounds } /// A CPU render of the viewport's scene with the current camera and the selection, as PNG @@ -1116,20 +1131,77 @@ impl ModelApp { Ok(()) } - /// Frame the whole model. + /// 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) { - let b = self.display().bounds; - self.cam.fit(&b); + // 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(); } - pub fn set_view(&mut self, v: StandardView) { - self.cam.set_view(v); + /// 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. @@ -1153,7 +1225,9 @@ impl ModelApp { 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. @@ -1627,8 +1701,9 @@ mod tests { app.run_script(&fieldcraft_engine::sample::script()).unwrap(); let d = app.display().clone(); assert!(!d.tris.is_empty() && !d.edges.is_empty()); - // Fitted: the model's centre is the camera target. - assert!(app.cam.target.dist(d.bounds.center()) < 1e-9); + // 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'])); } @@ -1711,6 +1786,90 @@ mod tests { 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] diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index 0de3138..bc6d2ce 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -26,8 +26,23 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { probe(app, &plot, &painter, t); } -/// The legend, top left under the view buttons. -fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) { +/// What the legend shows and where: its frame, title and text lines. +struct Legend { + frame: Rect, + title: String, + lines: Vec<(String, Color32)>, +} + +const LEGEND_LINE: f32 = 16.0; + +/// Where the legend of `plot` goes in the viewport `rect` (a fit leaves it free). +pub fn legend_rect(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Rect { + legend_layout(plot, painter, rect, t).frame +} + +/// The legend's text and frame: top left, as wide as its longest line and as tall as its lines +/// and bands. +fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Legend { let mut lines: Vec<(String, Color32)> = Vec::new(); let title = match (&plot.contour, plot.mode()) { (Some(l), _) => l.field.label().to_string(), @@ -52,7 +67,7 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) { } let origin = rect.left_top() + vec2(12.0, 12.0); let font = FontId::proportional(12.0); - let line_h = 16.0; + let line_h = LEGEND_LINE; let bands = plot.contour.as_ref().map_or(0, |l| l.bands.count) as f32; // As wide as its longest line (and the range labels beside the swatches). let widest = lines.iter().map(|(text, _)| painter.layout_no_wrap(text.clone(), font.clone(), t.overlay_text).size().x).fold(0.0_f32, f32::max); @@ -60,7 +75,14 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) { let width = (widest.max(title_w) + 20.0).max(170.0); // Padding and title, the lines, then the swatches with the range lines under them. let height = 36.0 + line_h * lines.len() as f32 + if bands > 0.0 { 16.0 + bands * SWATCH.y + 2.0 * line_h } else { 0.0 }; - let frame = Rect::from_min_size(origin, vec2(width, height)); + Legend { frame: Rect::from_min_size(origin, vec2(width, height)), title, lines } +} + +/// The legend, top left under the view buttons. +fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) { + let Legend { frame, title, lines } = legend_layout(plot, painter, rect, t); + let origin = frame.min; + let (font, line_h) = (FontId::proportional(12.0), LEGEND_LINE); painter.rect_filled(frame, 4.0, t.overlay); let mut y = origin.y + 8.0; painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, title, FontId::proportional(14.0), t.overlay_text); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d2bcd1e..660477d 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -28,6 +28,9 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { let rect = ui.available_rect_before_wrap(); app.viewport.rect = Some(rect); app.place("viewport", rect); + let t = Tokens::get(app.ui.dark); + app.viewport.overlays = overlays(app, ui, rect, &t); + app.apply_reframe(); let response = ui.allocate_rect(rect, Sense::click_and_drag()); let boxing = app.pick.box_mode && app.pick.filter.boxes(); navigate(app, ui, &response, rect, boxing); @@ -44,7 +47,6 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { hover(app, &response); } context_menu(app, &response); - let t = Tokens::get(app.ui.dark); let painter = ui.painter_at(rect); gradient(&painter, rect, t.viewport_top, t.viewport_bottom); if app.viewport.gpu.is_some() { @@ -69,6 +71,19 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { nav_bar(app, ui, rect, &t); } +/// Where this frame's overlays go over the viewport `rect`: the results legend, the selection +/// bar (where it was drawn last), the orientation gizmo and the navigation bar. +fn overlays(app: &mut ModelApp, ui: &egui::Ui, rect: Rect, t: &Tokens) -> Vec { + let mut out: Vec = [axis_view_rect(rect), nav_bar_rect(rect)].into_iter().filter(|r| rect.contains_rect(*r)).collect(); + if let Some(plot) = app.plot() { + let legend = crate::results_panel::legend_rect(&plot, &ui.painter_at(rect), rect, t); + app.place("legend", legend); + out.push(legend); + } + out.extend(ui.ctx().memory(|m| m.area_rect(egui::Id::new(SELECTION_BAR)))); + out +} + /// The empty viewport: open a file or a sample (the samples are ready to solve). fn start_screen(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { let mut open = None; @@ -184,9 +199,11 @@ fn context_menu(app: &mut ModelApp, response: &egui::Response) { }); } +const SELECTION_BAR: &str = "fc_selection_bar"; + /// The selection bar, top centre: what a click picks, box selection, and what is selected. fn selection_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { - let area = egui::Area::new(egui::Id::new("fc_selection_bar")) + let area = egui::Area::new(egui::Id::new(SELECTION_BAR)) .fixed_pos(pos2(rect.center().x, rect.top() + 10.0)) .pivot(egui::Align2::CENTER_TOP) .constrain_to(rect) @@ -404,10 +421,14 @@ fn view_along(d: Vec3) -> StandardView { best.0 } +fn axis_view_rect(rect: Rect) -> Rect { + Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), Vec2::splat(116.0)) +} + /// The orientation gizmo (top right): the six signed axes in the current view, back to front; /// clicking a tip looks along it, the house returns to the home view. fn axis_view(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { - let area = Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), Vec2::splat(116.0)); + let area = axis_view_rect(rect); if !rect.contains_rect(area) { return; } @@ -473,11 +494,14 @@ const NAV: [(&str, &str, &str); 7] = [ ("style", "ui.style", "Visual style and what is shown"), ]; +fn nav_bar_rect(rect: Rect) -> Rect { + Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(NAV.len() as f32 * 30.0 + 10.0, 30.0)) +} + /// The navigation bar, bottom centre: navigation modes, fit, home, projection, and the display /// menu (style, geometry, mesh, nodes). fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { - let w = NAV.len() as f32 * 30.0 + 10.0; - let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)); + let bar = nav_bar_rect(rect); if !rect.contains_rect(bar) { return; } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 568782e..d955c9b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -41,9 +41,9 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | +| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | | `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | -| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera | +| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | every view frames the model as drawn (the deformed shape and the setup's symbols included) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. Returns the camera | | `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | | `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `enabled` (false while coming soon or while its command misses what it needs), `unavailable` (null, or what its command misses: `reason`, the tooltip's lines such as "Needs a material — Properties › Material", and `missing`, each with `requirement`, `what`, `provider` (the command that provides it) and `place` (its toolbar entry)), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) | | `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form |