forked from 1b-agent/fieldcraft-upstream
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:
1 parent
5758dc7911
commit
ad84a52ed2
6 files changed
+332
-35
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user