Files
OneCAD-Simulation/crates/ui-egui/src/framing.rs
T
jyvikoandClaude Opus 5.5 ad84a52ed2 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 22:29:44 -04:00

101 lines
4.6 KiB
Rust

//! 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));
}
}