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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 22:29:44 -04:00
1 parent 5758dc7911
commit ad84a52ed2
6 files changed
+332 -35

No files matched your search

+2 -10
View File
@@ -216,8 +216,6 @@ pub struct Loading {
done: String,
/// The file it opens, for the recent list.
recent: Option<String>,
/// 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<String>,
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);
}
+100
View File
@@ -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<Reframe>, b: Reframe) -> Option<Reframe> {
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<Rect> = 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));
}
}
+173 -14
View File
@@ -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<u64>,
/// 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<egui::Rect>,
}
/// 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<reply::Reply>,
/// 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<framing::Reframe>,
/// The open document ([`Session::opened`]) the view was framed for.
framed_document: Option<u64>,
/// Tessellated design bodies with their names, kept while the same design is open.
bodies: (Option<Arc<fieldcraft_geometry::Design>>, 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]
+26 -4
View File
@@ -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);
+29 -5
View File
@@ -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<Rect> {
let mut out: Vec<Rect> = [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;
}