Files
OneCAD-Simulation/crates/ui-egui/src/viewport.rs
T
jyvikoandClaude Opus 5.5 786121c21a UI: a setup checklist at the top of the browser
Once a model has geometry or a mesh, the browser shows the analysis stages from the engine's
checklist, each ticked, next, dashed (optional) or open; a click starts the stage's command and a
blocked stage's hover says what it waits for. The × hides it and View › Setup checklist shows it
again; the choice is kept in the per-user prefs.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 21:46:33 -04:00

568 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The 3D viewport: the model drawn on the GPU (or on the CPU without one), camera navigation,
//! the navigation bar (bottom centre) and the orientation gizmo (top right), as in SolidCraft.
//!
//! Navigation: left drag orbits (or pans or zooms: the navigation bar's modes), right or middle
//! drag (or Shift + left drag) pans, the wheel or a pinch zooms at the cursor, a double click or F
//! frames the model. The gizmo's axis tips turn the view to look along them. The bar and the gizmo
//! follow SolidCraft's `viewport.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
//!
//! Selection ([`crate::selection`]): what a click would pick is highlighted under the pointer; a
//! click selects it (Shift adds, Cmd/Ctrl adds or removes, a click on nothing clears), Escape
//! clears. The selection bar (top centre) chooses what is picked and turns on box selection,
//! where a left drag selects the visible nodes, element faces or elements inside the box. A
//! right click offers a set made from the selection.
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
use fieldcraft_render::{StandardView, Vec3};
use crate::gpu::{GpuScene, ViewportCallback};
use crate::icons::{self, Look};
use crate::selection::{Filter, Mode};
use crate::theme::Tokens;
use crate::{ModelApp, NavMode, Style};
/// Wheel zoom per point scrolled.
const ZOOM_PER_POINT: f64 = 0.0015;
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 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);
if boxing {
box_drag(app, ui, &response);
}
if response.clicked()
&& let Some(at) = response.interact_pointer_pos()
{
let _ = app.select_at(at, mode(ui.input(|i| i.modifiers)));
probe_at(app, at, rect);
}
if !app.capturing {
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() {
paint_gpu(app, ui, &painter, rect);
labels(app, &painter, &t);
} else {
paint_cpu(app, ui, &painter, rect);
}
sampled_note(app, &painter, rect, &t);
hover_label(app, &painter, &t);
if let Some((a, b)) = app.pick.dragging {
let r = Rect::from_two_pos(a, b);
painter.rect(r, 0.0, t.pick_selected.gamma_multiply(0.12), Stroke::new(1.0, t.pick_selected), egui::StrokeKind::Inside);
}
if app.display().bounds.is_empty() && app.busy().is_none() {
start_screen(app, ui, rect, &t);
} else {
selection_bar(app, ui, rect, &t);
}
crate::results_panel::show(app, ui, rect, &t);
axis_view(app, ui, rect, &t);
nav_bar(app, ui, rect, &t);
}
/// 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;
let mut pick = false;
let area = Rect::from_center_size(rect.center(), vec2(440.0_f32.min(rect.width() - 24.0), 300.0_f32.min(rect.height() - 24.0)));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::top_down(egui::Align::Min)));
egui::Frame::NONE.fill(t.overlay).corner_radius(6.0).inner_margin(egui::Margin::same(14)).show(&mut child, |ui| {
ui.label(egui::RichText::new("Start").size(16.0).color(t.overlay_text));
ui.label(egui::RichText::new("Open a model, a SolidCraft design or a tagged mesh — or drop one here.").color(t.text_dim));
ui.add_space(4.0);
pick = ui.button("Open…").clicked();
ui.add_space(8.0);
ui.label(egui::RichText::new("Samples").color(t.overlay_text));
crate::files::sample_rows(ui, t, &mut open);
});
if pick {
app.open_dialog(None);
} else if let Some(n) = open {
app.open_sample(n);
}
}
/// A click on a results plot probes the node nearest to the face under the pointer.
fn probe_at(app: &mut ModelApp, at: Pos2, rect: Rect) {
let Some(plot) = app.plot() else { return };
let (origin, dir) = app.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height()));
if let Some((node, _)) = plot.display.pick(origin, dir) {
// A failure (a stale result) shows in the status bar.
let _ = app.probe_node(node);
}
}
/// How a click with these modifier keys changes the selection: Cmd/Ctrl adds or removes, Shift
/// adds, a plain click replaces.
fn mode(m: egui::Modifiers) -> Mode {
if m.command || m.ctrl {
Mode::Toggle
} else if m.shift {
Mode::Add
} else {
Mode::Replace
}
}
/// Box selection: a left drag draws the box; on release, what is visible inside it is selected
/// (with the release's modifier keys).
fn box_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response) {
let pointer = response.interact_pointer_pos();
if response.drag_started_by(egui::PointerButton::Primary) {
app.pick.dragging = pointer.map(|p| (p, p));
} else if let (Some((a, _)), Some(p)) = (app.pick.dragging, pointer)
&& response.dragged_by(egui::PointerButton::Primary)
{
app.pick.dragging = Some((a, p));
}
if response.drag_stopped_by(egui::PointerButton::Primary)
&& let Some((a, b)) = app.pick.dragging.take()
&& a.distance(b) > 3.0
{
app.select_box(a, b, mode(ui.input(|i| i.modifiers)));
}
}
/// What a click would pick under the pointer, worked out again when the pointer, the camera, the
/// scene or the filter change.
fn hover(app: &mut ModelApp, response: &egui::Response) {
let at = response.hover_pos().filter(|_| !response.dragged() && app.pick.dragging.is_none());
let key = (at, app.cam, app.scene_key(), app.pick.filter, app.viewport.rect);
if app.pick.hover_key == Some(key) {
return;
}
app.pick.hover_key = Some(key);
app.pick.hover_at = at;
let item = at.and_then(|p| app.pick_at(p));
app.set_hover(item);
}
/// The hovered item's name beside the pointer.
fn hover_label(app: &ModelApp, painter: &egui::Painter, t: &Tokens) {
let (Some(item), Some(at)) = (&app.pick.hover, app.pick.hover_at) else { return };
if !painter.clip_rect().contains(at) {
return;
}
let galley = painter.layout_no_wrap(item.describe(), egui::FontId::proportional(12.0), t.overlay_text);
let r = Rect::from_min_size(at + vec2(14.0, 14.0), galley.size()).expand2(vec2(5.0, 2.0));
painter.rect_filled(r, 3.0, t.overlay);
painter.galley(r.min + vec2(5.0, 2.0), galley, t.overlay_text);
}
/// The right-click menu: a set from the selection, clearing it, and the filter.
fn context_menu(app: &mut ModelApp, response: &egui::Response) {
response.context_menu(|ui| {
let has = !app.pick.selection.is_empty();
if ui
.add_enabled(has, egui::Button::new("Create set from selection…"))
.on_hover_text("model.set.create with the selection as members")
.clicked()
{
let _ = app.create_set_from_selection();
ui.close();
}
if ui.add_enabled(has, egui::Button::new("Clear selection")).on_hover_text("Esc").clicked() {
app.clear_selection();
ui.close();
}
ui.separator();
for f in Filter::ALL {
if ui.radio(app.pick.filter == f, format!("Select {}", f.label().to_lowercase())).clicked() {
app.pick.filter = f;
ui.close();
}
}
});
}
/// 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"))
.fixed_pos(pos2(rect.center().x, rect.top() + 10.0))
.pivot(egui::Align2::CENTER_TOP)
.constrain_to(rect)
.order(egui::Order::Middle);
let shown = area.show(ui.ctx(), |ui| {
egui::Frame::NONE.fill(t.overlay).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(8, 4)).show(
ui,
|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Select").color(t.text_dim));
egui::ComboBox::from_id_salt("fc_selection_filter").selected_text(app.pick.filter.label()).width(110.0).show_ui(ui, |ui| {
for (i, f) in Filter::ALL.into_iter().enumerate() {
// Geometry, then the mesh.
if i > 0 && Filter::ALL.get(i - 1).is_some_and(|p| p.geometry() != f.geometry()) {
ui.separator();
}
if ui.selectable_label(app.pick.filter == f, f.label()).clicked() {
app.pick.filter = f;
}
}
});
let boxes = app.pick.filter.boxes();
let tip = if boxes {
"Box selection: a left drag selects the visible items inside the box (right or middle drag pans)"
} else {
"Box selection picks nodes, element faces and elements"
};
if ui.add_enabled(boxes, egui::Button::selectable(app.pick.box_mode && boxes, "Box")).on_hover_text(tip).clicked() {
app.pick.box_mode = !app.pick.box_mode;
}
if !app.pick.selection.is_empty() {
ui.separator();
let mut text = app.pick.selection.summary();
if app.pick.selection.capped() {
text.push_str(" (capped)");
}
ui.label(egui::RichText::new(text).color(t.pick_selected));
if ui.small_button("×").on_hover_text("Clear the selection (Esc)").clicked() {
app.clear_selection();
}
}
});
},
);
});
app.place("selection_bar", shown.response.rect);
}
fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: Rect, boxing: bool) {
let (w, h) = (f64::from(rect.width()), f64::from(rect.height()));
let d = response.drag_delta();
let (dx, dy) = (f64::from(d.x), f64::from(d.y));
let shift = ui.input(|i| i.modifiers.shift);
// In box selection a left drag draws the box.
let left = response.dragged_by(egui::PointerButton::Primary) && !boxing;
if left && !shift && app.nav == NavMode::Orbit {
app.cam.orbit(dx, dy);
} else if left && !shift && app.nav == NavMode::Zoom {
app.cam.zoom_at((dy * ZOOM_PER_POINT * 4.0).exp(), None);
} else if response.dragged_by(egui::PointerButton::Secondary) || response.dragged_by(egui::PointerButton::Middle) || left {
app.cam.pan(dx, dy, h);
}
if response.double_clicked() || (response.hovered() && ui.input(|i| i.key_pressed(egui::Key::F) && i.modifiers.is_none())) {
app.fit_view();
}
if response.hovered() {
let (scroll, pinch, at) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta(), i.pointer.hover_pos()));
let factor = (-f64::from(scroll) * ZOOM_PER_POINT).exp() / f64::from(pinch).max(1e-3);
if (factor - 1.0).abs() > 1e-9 {
let anchor = at.and_then(|p| app.cam.target_plane_point(f64::from(p.x - rect.left()), f64::from(p.y - rect.top()), w, h));
app.cam.zoom_at(factor, anchor);
}
}
}
/// A vertical gradient behind the model.
fn gradient(painter: &egui::Painter, rect: Rect, top: Color32, bottom: Color32) {
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(rect.left_top(), top);
mesh.colored_vertex(rect.right_top(), top);
mesh.colored_vertex(rect.right_bottom(), bottom);
mesh.colored_vertex(rect.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: Rect) {
let ppp = ui.ctx().pixels_per_point();
let key = app.scene().0;
let radius = app.scene.1.radius;
if app.viewport.uploaded != Some(key) {
let g = GpuScene::from_scene(&app.scene.1, ppp);
if let Ok(mut slot) = app.viewport.slot.lock() {
*slot = Some(g);
app.viewport.uploaded = Some(key);
}
}
let cam = app.cam;
let aspect = f64::from(rect.width()) / f64::from(rect.height()).max(1.0);
let eye = cam.eye();
let (overlay_key, overlay) = app.overlay();
if app.viewport.overlay_uploaded != Some(overlay_key)
&& let Ok(mut slot) = app.viewport.overlay_slot.lock()
{
*slot = Some(GpuScene::from_scene(&overlay, ppp));
app.viewport.overlay_uploaded = Some(overlay_key);
}
let cb = ViewportCallback {
key,
slot: app.viewport.slot.clone(),
overlay_key,
overlay_slot: app.viewport.overlay_slot.clone(),
view_proj: cam.view_proj(aspect, radius).to_f32(),
back: cam.back().to_f32(),
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if cam.fov > 0.0 { 1.0 } else { 0.0 }],
size_px: [rect.width() * ppp, rect.height() * ppp],
};
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
}
/// Without a GPU: the CPU rasterizer into a texture, redrawn when the scene, camera or size
/// changes.
fn paint_cpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: Rect) {
let ppp = ui.ctx().pixels_per_point();
// A window larger than the renderer's limit, or than the largest texture the context takes,
// is drawn smaller and scaled up.
let (w, h) = cpu_size((rect.width() * ppp) as usize, (rect.height() * ppp) as usize, ui.ctx().input(|i| i.max_texture_side));
let size = [w, h];
let cam = app.cam;
let (overlay_key, overlay) = app.overlay();
let key = (app.scene().0, overlay_key);
let fresh = app.viewport.cpu.as_ref().is_some_and(|(_, k, c, s)| *k == key && *c == cam && *s == size);
// A scene the renderer refused is reported once and not retried until it changes.
if !fresh && app.viewport.cpu_failed != Some(key.0) {
let c = match fieldcraft_render::render_layers(&app.scene.1, Some(&overlay), &cam, w, h) {
Ok(c) => c,
Err(e) => {
app.viewport.cpu = None;
app.viewport.cpu_failed = Some(key.0);
app.set_status(format!("Viewport: {e}"), true);
return;
}
};
app.viewport.cpu_failed = None;
let image = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba);
let tex = match app.viewport.cpu.take() {
Some((mut tex, ..)) => {
tex.set(image, egui::TextureOptions::LINEAR);
tex
}
None => ui.ctx().load_texture("fc_viewport_cpu", image, egui::TextureOptions::LINEAR),
};
app.viewport.cpu = Some((tex, key, cam, size));
}
if let Some((tex, ..)) = &app.viewport.cpu {
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
/// The CPU viewport image's size for a viewport of `w` × `h` pixels: within the renderer's limits
/// and no side above `max_side`, the aspect kept.
fn cpu_size(w: usize, h: usize, max_side: usize) -> (usize, usize) {
let (w, h) = fieldcraft_render::fit_size(w, h);
let longest = w.max(h);
if max_side == 0 || longest <= max_side {
return (w, h);
}
let k = max_side as f64 / longest as f64;
let fit = |v: usize| ((v as f64 * k).floor() as usize).clamp(1, max_side);
(fit(w), fit(h))
}
/// The scene's labels (reference point names) beside their points, with the interface's font.
/// The CPU renderer draws them into its image.
fn labels(app: &mut ModelApp, painter: &egui::Painter, t: &Tokens) {
let labels: Vec<(Vec3, String)> = app.scene().1.labels.iter().map(|l| (l.p, l.text.clone())).collect();
let offset = fieldcraft_render::LABEL_OFFSET as f32;
for (p, text) in labels {
if let Some(at) = to_screen(app, p) {
let galley = painter.layout_no_wrap(text, egui::FontId::proportional(12.0), t.overlay_text);
let r = Rect::from_min_size(at + vec2(offset, -offset - galley.size().y), galley.size()).expand2(vec2(4.0, 1.0));
painter.rect_filled(r, 3.0, t.overlay);
painter.galley(r.min + vec2(4.0, 1.0), galley, t.overlay_text);
}
}
}
/// Bottom left: the definitions whose symbols are a sample of their nodes or faces.
fn sampled_note(app: &ModelApp, painter: &egui::Painter, rect: Rect, t: &Tokens) {
let show = app.ui.show();
let notes = &app.glyph_notes();
if notes.is_empty() || !(show.loads || show.interactions) {
return;
}
let text = format!("Symbols sampled: {}", notes.join("; "));
painter.text(rect.left_bottom() + vec2(10.0, -10.0), egui::Align2::LEFT_BOTTOM, text, egui::FontId::proportional(11.0), t.text_dim);
}
/// The orientation gizmo's arm length and tip radius (points).
const AXIS_LENGTH: f32 = 38.0;
const AXIS_RADIUS: f32 = 11.0;
/// The standard view looking along `d` from outside (the one whose eye side is nearest `d`).
fn view_along(d: Vec3) -> StandardView {
let mut best = (StandardView::Front, f64::NEG_INFINITY);
for v in StandardView::ALL.into_iter().filter(|v| *v != StandardView::Iso) {
let mut c = fieldcraft_render::Camera::default();
c.set_view(v);
let score = c.back().dot(d);
if score > best.1 {
best = (v, score);
}
}
best.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));
if !rect.contains_rect(area) {
return;
}
app.place("axis_view", area);
let c = area.center();
let (r, u, b) = app.cam.basis();
let mut tips: Vec<(&str, Vec3, Pos2, f64, Color32)> = [
("X", Vec3::X, t.axis_x),
("-X", Vec3::new(-1.0, 0.0, 0.0), t.axis_x),
("Y", Vec3::Y, t.axis_y),
("-Y", Vec3::new(0.0, -1.0, 0.0), t.axis_y),
("Z", Vec3::Z, t.axis_z),
("-Z", Vec3::new(0.0, 0.0, -1.0), t.axis_z),
]
.into_iter()
.map(|(name, d, col)| (name, d, c + vec2(d.dot(r) as f32 * AXIS_LENGTH, -(d.dot(u) as f32) * AXIS_LENGTH), d.dot(b), col))
.collect();
tips.sort_by(|a, b2| a.3.total_cmp(&b2.3));
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| area.contains(*p));
let hovered = hover.and_then(|p| tips.iter().rposition(|(_, _, at, _, _)| at.distance(p) <= AXIS_RADIUS));
let painter = ui.painter_at(rect);
if hover.is_some() {
painter.circle_filled(c, 55.0, t.overlay);
}
for (i, (name, _, at, depth, col)) in tips.iter().enumerate() {
let positive = !name.starts_with('-');
let ink = if *depth < -0.01 { col.gamma_multiply(0.55) } else { *col };
painter.line_segment([c, *at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
let hot = hovered == Some(i);
painter.circle_filled(*at, AXIS_RADIUS, if hot { t.accent_soft } else { t.overlay });
painter.circle_stroke(*at, AXIS_RADIUS, Stroke::new(if hot { 2.0 } else { 1.0 }, if hot { t.accent } else { ink }));
painter.text(*at, egui::Align2::CENTER_CENTER, *name, egui::FontId::proportional(11.0), ink);
}
let resp = ui.interact(area, ui.id().with("fc_axis_view"), Sense::click());
if let Some(i) = hovered
&& let Some((name, d, ..)) = tips.get(i)
{
let view = view_along(*d);
let resp = resp.clone().on_hover_text(format!("Look from {name} ({})", view.label()));
if resp.clicked() {
app.set_view(view);
}
}
let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), Vec2::splat(18.0));
let hr = ui.interact(home, ui.id().with("fc_axis_home"), Sense::click());
if hr.hovered() {
painter.rect_filled(home.expand(2.0), 3.0, t.hover);
}
icons::paint(app, ui, "ui.home", home, Look::Normal);
if hr.on_hover_text("Home view").clicked() {
app.home_view();
}
}
/// The navigation bar's buttons: (key, icon, tooltip).
const NAV: [(&str, &str, &str); 7] = [
("orbit", "ui.orbit", "Orbit: left drag turns the view"),
("pan", "ui.pan", "Pan: left drag moves the view (or right or middle drag)"),
("zoom", "ui.zoom", "Zoom: left drag up and down (or the wheel)"),
("fit", "ui.fit", "Fit the model (F, or a double click)"),
("home", "ui.home", "Home view"),
("perspective", "ui.perspective", "Perspective or orthographic"),
("style", "ui.style", "Visual style and what is shown"),
];
/// 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));
if !rect.contains_rect(bar) {
return;
}
app.place("nav_bar", bar);
ui.painter().rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let mut style_button = None;
for (i, (key, icon, tip)) in NAV.iter().enumerate() {
let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), Vec2::splat(26.0));
let resp = ui.interact(br, ui.id().with(("fc_nav", *key)), Sense::click());
let active = match *key {
"orbit" => app.nav == NavMode::Orbit,
"pan" => app.nav == NavMode::Pan,
"zoom" => app.nav == NavMode::Zoom,
"perspective" => app.ui.perspective,
"style" => app.ui.menu.as_deref() == Some("view"),
_ => false,
};
if active || resp.hovered() {
ui.painter().rect_filled(br, 4.0, if active { t.accent_soft } else { t.hover });
}
icons::paint(app, ui, icon, br.shrink(4.0), Look::Normal);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, *tip));
if resp.on_hover_text(*tip).clicked() {
match *key {
"orbit" => app.nav = NavMode::Orbit,
"pan" => app.nav = NavMode::Pan,
"zoom" => app.nav = NavMode::Zoom,
"fit" => app.fit_view(),
"home" => {
app.home_view();
}
"perspective" => app.ui.perspective = !app.ui.perspective,
_ => app.toggle_menu("view"),
}
}
if *key == "style" {
style_button = Some(br);
}
}
if let Some(br) = style_button {
crate::frame::settings_menu(app, ui, "view", br, |app, ui| {
let t = Tokens::get(app.ui.dark);
crate::frame::menu_heading(ui, &t, "STYLE");
for s in Style::ALL {
if ui.radio(app.ui.style == s, s.label()).clicked() {
app.ui.style = s;
}
}
ui.separator();
crate::frame::menu_heading(ui, &t, "SHOW");
ui.checkbox(&mut app.ui.show_geometry, "Geometry");
ui.checkbox(&mut app.ui.show_mesh, "Mesh");
ui.checkbox(&mut app.ui.show_nodes, "Nodes");
ui.checkbox(&mut app.ui.show_loads, "Loads & BCs");
ui.checkbox(&mut app.ui.show_interactions, "Interactions");
ui.separator();
ui.checkbox(&mut app.ui.show_tree, "Browser");
ui.checkbox(&mut app.ui.show_setup, "Setup checklist");
ui.checkbox(&mut app.ui.show_timeline, "Timeline");
});
}
}
/// The screen point (points) of a world point in the viewport drawn last frame.
pub fn to_screen(app: &ModelApp, p: Vec3) -> Option<Pos2> {
let rect = app.viewport.rect?;
// The projection picking uses ([`ModelApp::projector`]).
let (x, y, _) = app.cam.to_screen(p, f64::from(rect.width()), f64::from(rect.height()), app.scene.1.radius)?;
Some(pos2(rect.left() + x as f32, rect.top() + y as f32))
}
#[cfg(test)]
mod tests {
use super::cpu_size;
/// The CPU viewport never asks for a texture larger than the context takes (#237: a Retina
/// window 2300 pixels wide on a 2048 limit).
#[test]
fn the_cpu_image_fits_the_texture_limit() {
assert_eq!(cpu_size(2300, 1412, 2048), (2048, 1257));
assert_eq!(cpu_size(1412, 2300, 2048), (1257, 2048));
assert_eq!(cpu_size(800, 600, 2048), (800, 600));
assert_eq!(cpu_size(800, 600, 0), (800, 600));
let (w, h) = cpu_size(100_000, 3, 2048);
assert!(w <= 2048 && h >= 1);
}
}