feat: add smooth interactive axis view and diagonal snapping

This commit is contained in:
bherbruck committed 2026-10-11 08:51:24 -04:00
1 parent 6cbcf5c823
commit 43a02625a2
24 files changed
+673 -520

No files matched your search

+2 -6
View File
@@ -190,6 +190,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"ui.view" => {
let v = s("view").unwrap_or("home");
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
if p.get("animate").and_then(Value::as_bool).unwrap_or(false) {
if v != "fit" && v != "home" && StandardView::parse(v).is_none() {
return err(format!("unknown view `{v}`"));
@@ -343,12 +345,6 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
};
}
let Some(rect) = app.viewport.rect else { return err("no viewport") };
if let Some(c) = p.get("cube") {
// A point on the view cube, in cube units (faces at ±1).
let n = |i: usize| c.get(i).and_then(Value::as_f64).unwrap_or(0.0);
let q = crate::viewport::cube_point(app, rect, solvecraft_engine::geom::Vec3::new(n(0), n(1), n(2)));
return ok(json!([q.x, q.y]));
}
let proj = crate::viewport::projection(app, rect);
let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0);
let world = if let Some(w) = p.get("world") {
+2 -2
View File
@@ -1263,7 +1263,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
}
/// Show the active dialog (if any), docked flush to the right edge of the viewport just below
/// the view cube, sized to its content.
/// the axis view, sized to its content.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let _language = crate::i18n::scope(&app.preferences.language);
@@ -1272,7 +1272,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
update_fill_cells(app, &mut d);
let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE);
let mut keep = true;
let mut ok = false;
let mut applied = false;
+2
View File
@@ -79,6 +79,8 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
app.tool = None;
app.params = None;
app.cam_anim = None;
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
app.preview = crate::preview::PreviewState::default();
app.viewport.hover = None;
app.menu.props = None;
+8 -4
View File
@@ -3,7 +3,7 @@
//! A thin shell over [`solvecraft_engine::Session`]: panels read engine state and act through
//! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar
//! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser
//! on the left, the 3D viewport with a view cube and navigation bar, the command dialog on the
//! on the left, the 3D viewport with an axis view and navigation bar, the command dialog on the
//! right of the viewport, the command palette and the timeline at the bottom.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -129,7 +129,7 @@ pub struct UiState {
pub visual_style: u8,
/// A soft shadow on the ground under the model (seen from above).
pub ground_shadow: bool,
/// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it.
/// The world's up axis (Z or Y; #3): the camera, axis view, home view and ground follow it.
pub up_axis: solvecraft_engine::render::UpAxis,
/// Sketches whose closed profiles are not shaded.
pub hidden_profiles: Vec<u64>,
@@ -180,7 +180,7 @@ pub struct SolveApp {
pub session: Session,
pub ui: UiState,
pub cam: Camera,
/// A view change in progress (view cube, Home, Fit); any mouse navigation cancels it.
/// A view change in progress (axis view, Home, Fit); any mouse navigation cancels it.
pub cam_anim: Option<CameraAnim>,
/// Seconds since start, as of this frame.
pub now: f64,
@@ -736,6 +736,8 @@ impl SolveApp {
pub fn fit_view(&mut self) {
self.cam_anim = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
self.cam = self.fitted(self.cam);
}
@@ -753,6 +755,8 @@ impl SolveApp {
/// Move the camera to `to` over half a second (ease in-out, orientation slerped).
pub fn animate_to(&mut self, to: Camera) {
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
if !to.is_valid() {
return;
}
@@ -770,7 +774,7 @@ impl SolveApp {
self.cam_anim = Some(anim);
}
/// Animated view changes for the view cube and navigation bar.
/// Animated view changes for the axis view and navigation bar.
pub fn animate_view(&mut self, v: &str) {
let mut to = self.cam;
match v {
+4 -4
View File
@@ -52,7 +52,7 @@ pub struct Prefs {
pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
pub zoom_to_cursor: bool,
/// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast.
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
pub look_at_anim: bool,
pub look_at_speed: f32,
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
@@ -554,7 +554,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
ui.end_row();
note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav)));
note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_help(&app.preferences.nav)));
ui.label("Reverse zoom direction");
ui.checkbox(&mut app.preferences.zoom_reverse, "");
ui.end_row();
@@ -569,7 +569,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
});
ui.end_row();
ui.label("Animate view changes");
ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit");
ui.checkbox(&mut app.preferences.look_at_anim, "Axis view, Look At, Home and Fit");
ui.end_row();
ui.label("Animation speed");
ui.add_enabled(
@@ -585,7 +585,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
});
ui.end_row();
note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged.");
note(ui, "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.");
ui.label("Offer to stand up Y-up files");
ui.checkbox(&mut app.preferences.offer_stand_up, "")
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
+5 -5
View File
@@ -82,7 +82,7 @@ pub fn rect_of(name: &str) -> Option<egui::Rect> {
RECTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
}
/// A number about this frame's drawing, by name ("cube_highlights": the view cube's lit patches).
/// A number about this frame's drawing, by name ("axis_view_highlights": the axis view's highlighted tips).
pub fn publish_count(name: &str, v: f64) {
COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v)));
}
@@ -213,7 +213,7 @@ impl Harness {
pub fn settle(&mut self) {
for _ in 0..2000 {
// Queued input used up and the view no longer moving.
if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() {
if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
break;
}
self.frame();
@@ -314,14 +314,14 @@ impl Harness {
}
}
/// Animate the view to `v` (as the view cube does), sampling the camera every frame: no
/// Animate the view to `v` (as the axis view does), sampling the camera every frame: no
/// frame may turn the view (its right or back vector) by more than `max_deg`, the last
/// included, so the move ends without a jump.
fn view_anim(&mut self, v: &str, max_deg: f64) -> Result<(), String> {
let basis = |c: &solvecraft_engine::render::Camera| c.basis();
let mut prev = basis(&self.app.cam);
self.app.animate_view(v);
if self.app.cam_anim.is_none() {
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
return Err(format!("view_anim {v}: no animation started"));
}
let mut worst = (0.0f64, 0usize);
@@ -334,7 +334,7 @@ impl Harness {
worst = (d, i);
}
prev = now;
if self.app.cam_anim.is_none() {
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
if worst.0 > max_deg {
return Err(format!("view_anim {v}: frame {} of {} turns the view {:.1}° (limit {max_deg}°)", worst.1 + 1, i + 1, worst.0));
}
+1 -1
View File
@@ -178,7 +178,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
};
let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE);
let frame = egui::Frame::window(&ctx.global_style())
.fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border))
-7
View File
@@ -103,9 +103,6 @@ pub struct Tokens {
pub glyph_text: Color32,
/// Chips drawn over the viewport (status, navigation bar, dimension labels).
pub overlay: Color32,
/// View cube face (shaded by orientation) and edges.
pub cube_face: Color32,
pub cube_edge: Color32,
/// Rubber band of the shape being drawn.
pub rubber_band: Color32,
/// Light text on the application bar.
@@ -191,8 +188,6 @@ impl Tokens {
glyph_edge: Color32::from_rgb(90, 150, 90),
glyph_text: Color32::from_rgb(40, 110, 40),
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
cube_face: Color32::from_rgb(236, 236, 236),
cube_edge: Color32::from_rgb(140, 148, 160),
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
timeline_item: Color32::WHITE,
@@ -247,8 +242,6 @@ impl Tokens {
t.glyph_edge = Color32::from_rgb(96, 170, 110);
t.glyph_text = Color32::from_rgb(150, 220, 160);
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
t.cube_face = Color32::from_rgb(196, 200, 207);
t.cube_edge = Color32::from_rgb(100, 108, 122);
t.rubber_band = Color32::from_rgb(92, 160, 255);
t.timeline_item = Color32::from_rgb(52, 58, 70);
t.timeline_item_error = Color32::from_rgb(110, 50, 54);
+369 -401
View File
@@ -1,5 +1,5 @@
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view
//! cube and the navigation bar.
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the axis
//! view and the navigation bar.
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use serde_json::json;
@@ -55,8 +55,12 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool,
/// A primary press on the cube: the pointer position used by its last orbit step.
pub cube_drag: Option<Pos2>,
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
pub orbit_free: Option<Camera>,
/// Spring toward the chosen orientation, including after the drag ends.
pub(crate) orbit_snap: Option<OrbitSnap>,
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
@@ -983,8 +987,8 @@ fn section_clip(app: &SolveApp) -> [f32; 4] {
}
}
/// Space kept free at the top right of the viewport for the view cube (docked dialogs start below).
pub const VIEW_CUBE_CLEARANCE: f32 = 168.0;
/// Space kept free at the top right of the viewport for the axis view (docked dialogs start below).
pub const AXIS_VIEW_CLEARANCE: f32 = 168.0;
/// Axis length as a multiple of the origin plane size (solid part 1, dashed beyond).
const AXIS_LEN: f64 = 1.8;
@@ -1442,10 +1446,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
});
// The right button belongs to the marking menu while it is open.
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
// The view cube handles its own clicks; the model underneath must not see them.
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
// The axis view handles its own clicks; the model underneath must not see them.
let axes = axis_rect(rect);
// Floating windows and menus own input over the model, including wheel navigation.
let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p));
let inside = resp.contains_pointer() && hover.is_some_and(|p| !axes.contains(p));
app.viewport.mouse = hover.filter(|_| inside);
// Ctrl or Alt held: drawing and dragging don't snap.
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
@@ -1453,6 +1457,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// ---- navigation ----
let h = rect.height().max(1.0) as f64;
if inside && scroll != 0.0 {
app.viewport.orbit_snap = None;
app.viewport.orbit_free = None;
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
@@ -1470,26 +1476,37 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.cam.zoom_at(f, anchor);
}
let dragging = resp.dragged() || (inside && (middle || secondary_down));
if dragging && delta != egui::Vec2::ZERO {
let (dx, dy) = (delta.x as f64, delta.y as f64);
let mode = if middle || secondary_down {
let mode = if dragging && app.viewport.axis_drag.is_none() {
if middle || secondary_down {
crate::prefs::nav_mode(app, middle, secondary_down, mods)
} else if primary_down && app.viewport.cube_drag.is_none() {
} else if primary_down {
app.viewport.nav
} else {
None
};
if mode.is_some() {
app.cancel_view_animation();
}
match mode {
Some(NavMode::Orbit) => {
let press = ui.input(|i| i.pointer.press_origin());
crate::prefs::orbit(app, rect, dx, dy, press);
} else {
None
};
if mode == Some(NavMode::Orbit) {
let press = ui.input(|i| i.pointer.press_origin());
app.cancel_view_animation();
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, mods.shift, true);
} else {
if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) {
app.viewport.orbit_snap = None;
}
if app.viewport.axis_drag.is_none() {
app.viewport.orbit_free = None;
}
if delta != egui::Vec2::ZERO {
if mode.is_some() {
app.cancel_view_animation();
}
match mode {
Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h),
Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None),
_ => {}
}
Some(NavMode::Pan) => app.cam.pan(dx, dy, h),
Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None),
None => {}
}
}
if !dragging {
@@ -1513,10 +1530,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
if !app.cam.is_valid() {
app.viewport.orbit_free = None;
app.viewport.orbit_snap = None;
app.cam = Camera::default();
app.fit_view();
}
step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt)));
if app.viewport.orbit_snap.is_some() {
ui.ctx().request_repaint();
}
let proj = projection(app, rect);
// Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused.
@@ -1525,8 +1548,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|| middle
|| secondary_down
|| (app.viewport.nav.is_some() && primary_down)
|| app.viewport.cube_drag.is_some()
|| app.viewport.axis_drag.is_some()
|| app.viewport.spin
|| app.viewport.orbit_snap.is_some()
|| app.cam_anim.is_some();
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
@@ -1762,7 +1786,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if resp.dragged_by(egui::PointerButton::Primary)
&& let (Some(a), Some(b)) = (origin, hover)
&& rect.contains(a)
&& !cube.contains(a)
&& !axes.contains(a)
{
app.viewport.boxsel = Some(BoxSel::from_drag(a, b));
}
@@ -1807,7 +1831,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::canvas::show(app, ui, &painter, &proj);
crate::sketch_tools::show(app, ui, &painter, &proj);
crate::sketch_dims::show(app, ui, &proj);
view_cube(app, ui, rect);
axis_view(app, ui, rect);
nav_bar(app, ui, rect);
// Status chip.
if let Some((msg, at, err)) = app.status.clone() {
@@ -2153,117 +2177,220 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
}
/// Where a point in view-cube units (faces at ±1) is drawn.
pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
let (r, u, _) = cube_basis(&app.cam);
pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32)
/// Compact orientation gizmo, drawn and hit-tested in world coordinates.
const AXIS_LENGTH: f64 = 38.0;
const AXIS_RADIUS: f32 = 11.0;
fn axis_center(rect: Rect) -> Pos2 {
pos2(rect.right() - 68.0, rect.top() + 68.0)
}
/// Half a view cube face, in pixels.
const CUBE_SCALE: f64 = 32.0;
fn axis_rect(rect: Rect) -> Rect {
Rect::from_center_size(axis_center(rect), vec2(116.0, 116.0))
}
/// The view cube (top right): a cube with chamfered corners. Its 14 targets are the 6 faces
/// (orthographic views) and the 8 corner facets (iso views); the edges between them are not
/// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the
/// house goes home.
/// The camera's basis in its up-axis frame (Z up): the cube's TOP is the world's up, X, Y or Z.
fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) {
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
fn axis_tips(cam: &Camera, rect: Rect) -> Vec<(&'static str, Vec3, Pos2, f64)> {
let (r, u, b) = cam.basis();
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b))
let c = axis_center(rect);
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
.into_iter()
.map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b)))
.collect();
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
tips
}
/// Dragging a primary press on the cube orbits about the view centre. A stationary press
/// remains a click; a floating window over the cube owns its input.
fn view_cube_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, targets: &[CubeTarget], c: Pos2) -> bool {
let (pressed, down, decided, origin, pos) = ui.input(|i| {
fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
}
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
fn snap_directions() -> impl Iterator<Item = Vec3> {
[-1.0, 0.0, 1.0].into_iter().flat_map(|x| {
[-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized()))
})
}
/// Snap to the nearest standard or diagonal direction in the user's up-axis frame.
fn snapped_axis_camera(cam: Camera) -> Camera {
let b = cam.to_frame(cam.back());
let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z);
let mut to = cam.looking_from(cam.from_frame(dir));
if dir.x == 0.0 && dir.y == 0.0 {
to.yaw = nearest_quarter_turn(cam.yaw);
}
to
}
pub(crate) struct OrbitSnap {
target: Camera,
yaw_velocity: f64,
pitch_velocity: f64,
pivot: Option<(Vec3, Pos2)>,
}
/// Pointer motion accumulates independently of the spring preview, so many small deltas
/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker.
fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Pos2>, snap: bool, around_cursor: bool) {
let displayed = app.cam;
if let Some(free) = app.viewport.orbit_free.take() {
app.cam = free;
}
if around_cursor {
crate::prefs::orbit(app, rect, dx, dy, press);
} else {
app.cam.orbit(dx, dy);
}
let free = app.cam;
app.viewport.orbit_free = Some(free);
if snap || !around_cursor || app.viewport.orbit_snap.is_some() {
let mut target = if snap { snapped_axis_camera(free) } else { free };
if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() {
let b = free.back();
if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) {
target.yaw = previous.target.yaw;
target.pitch = previous.target.pitch;
}
}
let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| {
app.cam = displayed;
projection(app, rect).to_screen(p).map(|at| (p, at))
});
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
spring.target = target;
spring.pivot = pivot;
app.cam = displayed;
}
}
/// A lightly damped spring: mostly settled in about a third of a second, with a small
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
let steps = (dt * 240.0).ceil().max(1.0) as usize;
let h = dt / steps as f64;
let omega = 16.0;
let damping = 0.82;
let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
for _ in 0..steps.min(12) {
spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h;
spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h;
app.cam.yaw += spring.yaw_velocity * h;
app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2);
}
app.cam.target = spring.target.target;
let done = yaw_error(app.cam.yaw).abs() < 1e-5
&& (spring.target.pitch - app.cam.pitch).abs() < 1e-5
&& spring.yaw_velocity.abs() < 1e-4
&& spring.pitch_velocity.abs() < 1e-4;
if done {
app.cam = spring.target;
}
if let Some((pivot, at)) = spring.pivot
&& let Some(after) = projection(app, rect).to_screen(pivot)
{
app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0)));
}
if !done {
app.viewport.orbit_snap = Some(spring);
}
}
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
/// on a tip chooses its view. Floating windows own their input over the gizmo.
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
let p = &i.pointer;
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos())
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
});
if pressed && ui.rect_contains_pointer(rect) {
let shapes = cube_projected(targets, cube_basis(&app.cam), c);
app.viewport.cube_drag = origin.filter(|p| cube_hit(&shapes, *p).is_some());
app.viewport.axis_drag = origin.filter(|p| axis_rect(rect).contains(*p));
if app.viewport.axis_drag.is_some() {
app.viewport.orbit_free = None;
}
}
if !down {
app.viewport.cube_drag = None;
if !down && app.viewport.axis_drag.take().is_some() {
app.viewport.orbit_free = None;
}
let Some(last) = app.viewport.cube_drag.filter(|_| decided) else { return false };
let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false };
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
// Include the movement that crossed the drag threshold, so the orbit follows the pointer.
if let Some(p) = pos
&& p != last
{
if let Some(p) = pos {
app.cancel_view_animation();
app.cam.orbit(f64::from(p.x - last.x), f64::from(p.y - last.y));
app.viewport.cube_drag = Some(p);
orbit_drag(app, rect, f64::from(p.x - last.x), f64::from(p.y - last.y), None, shift, false);
app.viewport.axis_drag = Some(p);
ui.ctx().request_repaint();
}
true
}
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
let targets = cube_targets();
let orbiting = view_cube_orbit(app, ui, rect, &targets, c);
let (r, u, b) = cube_basis(&app.cam);
let (wr, wu, _) = app.cam.basis();
let painter = ui.painter_at(rect);
let c = axis_center(rect);
let orbiting = axis_view_orbit(app, ui, rect);
let tips = axis_tips(&app.cam, rect);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
let shapes = cube_projected(&targets, (r, u, b), c);
let hovered = if orbiting { None } else { hover.and_then(|h| cube_hit(&shapes, h)) };
// Axis triad from the cube's back corner, drawn first: the cube hides what is behind it.
// The corner is in the cube's frame, the axes are the world's.
let o = Vec3::new(-1.0, -1.0, -1.0);
let at = pos2(c.x + (o.dot(r) * CUBE_SCALE) as f32, c.y - (o.dot(u) * CUBE_SCALE) as f32);
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32);
painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
let painter = ui.painter_at(rect);
if orbiting || hover.is_some_and(|p| axis_rect(rect).contains(p)) {
painter.circle_filled(c, 55.0, t.overlay);
}
let labels: Vec<String> = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect();
let size = cube_label_size(ui.ctx(), &labels);
// Back to front: the faces and facets turned toward the camera.
for (i, pts) in shapes.iter().rev() {
let tg = &targets[*i];
let n = tg.normal();
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
if hovered == Some(*i) {
painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent)));
}
let centre = cube_centroid(pts);
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre);
// Fades out as the face turns edge-on.
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
if !tg.corner && ink > 0.05 {
let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink);
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color));
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
let col = if d.x != 0.0 {
colors::AXIS_X
} else if d.y != 0.0 {
colors::AXIS_Y
} else {
colors::AXIS_Z
};
let col = Color32::from_rgb(col.0, col.1, col.2);
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));
painter.circle_filled(*at, AXIS_RADIUS, if hovered == Some(i) { t.accent_soft } else { t.overlay });
painter.circle_stroke(
*at,
AXIS_RADIUS,
Stroke::new(if hovered == Some(i) { 2.0 } else { 1.0 }, if hovered == Some(i) { t.accent } else { ink }),
);
painter.text(*at, Align2::CENTER_CENTER, name.to_uppercase(), FontId::proportional(11.0), ink);
crate::scenario::publish_handle(&format!("axis_view:{name}"), *at);
}
crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
if let Some(i) = hovered
&& let Some((name, d, _, _)) = tips.get(i)
{
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover())
.on_hover_text(format!("View from {}. Click again to reverse. Drag to orbit; hold Shift to snap.", name.to_uppercase()));
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
if ui.input(|i| i.pointer.primary_clicked()) {
let frame = app.cam.to_frame(*d);
let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d };
let mut to = app.cam.looking_from(dir);
if frame.z.abs() > 0.5 {
to.yaw = nearest_quarter_turn(app.cam.yaw);
}
if app.preferences.look_at_anim {
app.cancel_view_animation();
app.viewport.orbit_free = None;
app.viewport.orbit_snap = Some(OrbitSnap { target: to, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot: None });
ui.ctx().request_repaint();
} else {
app.animate_to(to);
}
}
}
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
if let Some(i) = hovered {
crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c));
}
// Clicked: the view from the face's or facet's direction (target → eye).
let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir);
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
let home = Rect::from_center_size(pos2(c.x - 46.0, c.y - 54.0), vec2(18.0, 18.0));
let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click());
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
if hr.on_hover_text("Home view").clicked() {
app.animate_view("home");
}
if let Some(dir) = clicked {
let mut to = app.cam.looking_from(app.cam.from_frame(dir));
// Keep the nearest square heading when looking straight down or up.
if dir.x == 0.0 && dir.y == 0.0 {
to.yaw = nearest_quarter_turn(app.cam.yaw);
}
app.animate_to(to);
}
}
/// The nearest square heading. At a halfway tie, prefer the heading nearer the standard
/// plan view, so clicking TOP or BOTTOM from home still puts the front edge at the bottom.
/// At a halfway tie, prefer the square heading nearer the standard plan view.
fn nearest_quarter_turn(yaw: f64) -> f64 {
if !yaw.is_finite() {
return 0.0;
@@ -2282,168 +2409,6 @@ fn nearest_quarter_turn(yaw: f64) -> f64 {
}
}
/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1).
const CUBE_CHAMFER: f64 = 0.38;
/// A view cube target: a face or a corner facet, its view direction (target → eye) and outline
/// (cube units).
#[derive(Clone, Debug)]
pub(crate) struct CubeTarget {
pub name: String,
pub dir: Vec3,
pub corner: bool,
pub outline: Vec<Vec3>,
}
impl CubeTarget {
fn normal(&self) -> Vec3 {
self.dir.normalized().unwrap_or(Vec3::Z)
}
}
/// The 14 targets: 6 octagonal faces and 8 triangular corner facets.
pub(crate) fn cube_targets() -> Vec<CubeTarget> {
let k = CUBE_CHAMFER;
let mut out = Vec::new();
for (n, name) in [
(Vec3::new(0.0, -1.0, 0.0), "front"),
(Vec3::new(0.0, 1.0, 0.0), "back"),
(Vec3::new(1.0, 0.0, 0.0), "right"),
(Vec3::new(-1.0, 0.0, 0.0), "left"),
(Vec3::Z, "top"),
(Vec3::new(0.0, 0.0, -1.0), "bottom"),
] {
let (e1, e2) = cube_face_axes(n);
let uv =
[(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)];
out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() });
}
for sz in [1.0, -1.0] {
for sy in [-1.0, 1.0] {
for sx in [1.0, -1.0] {
let corner = Vec3::new(sx, sy, sz);
let name = format!(
"{}-{}-{}",
if sz > 0.0 { "top" } else { "bottom" },
if sy < 0.0 { "front" } else { "back" },
if sx > 0.0 { "right" } else { "left" }
);
let outline = vec![corner - Vec3::X * (sx * k), corner - Vec3::Y * (sy * k), corner - Vec3::Z * (sz * k)];
out.push(CubeTarget { name, dir: corner, corner: true, outline });
}
}
}
out
}
/// Where a point in cube units is drawn, for the camera's right and up `ru` and the cube's
/// centre `c`.
fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 {
pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32)
}
/// A face's text directions (right, up): read from outside the cube, and upright in the view
/// of the standard face view (TOP and BOTTOM: yaw 0, front edge at the bottom).
fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
}
/// How much of a face the widest face label spans.
const CUBE_LABEL_WIDTH: f32 = 0.9;
/// The face labels' font size: one size for all, the widest of `labels` spanning
/// `CUBE_LABEL_WIDTH` of a face.
fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 {
let probe = 10.0;
let widest = labels
.iter()
.map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x))
.fold(0.0, f32::max);
if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe }
}
/// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's
/// projection, so it turns and foreshortens with the face.
fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY));
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
let mut mesh = egui::Mesh::default();
let at = (-galley.size() / 2.0).to_pos2();
tess.tessellate_text(&egui::epaint::TextShape::new(at, galley, color), &mut mesh);
let (right, up) = cube_label_axes(n);
for v in &mut mesh.vertices {
let p = n + right * (f64::from(v.pos.x) / CUBE_SCALE) - up * (f64::from(v.pos.y) / CUBE_SCALE);
v.pos = cube_to_screen(ru, c, p);
}
Shape::mesh(mesh)
}
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around
/// the cube's centre `c`.
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
let (r, u, b) = rub;
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets
.iter()
.enumerate()
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3)
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect()))
.collect();
v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
v.into_iter().map(|(i, _, p)| (i, p)).collect()
}
/// The target under `p`. The faces and facets tile the cube, so a point on an edge between two
/// of them (where neither polygon claims it) goes to the nearer one, within a few pixels.
pub(crate) fn cube_hit(shapes: &[(usize, Vec<Pos2>)], p: Pos2) -> Option<usize> {
if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) {
return Some(*i);
}
shapes.iter().map(|(i, pts)| (*i, poly_distance(p, pts))).filter(|(_, d)| *d <= 3.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
fn poly_distance(p: Pos2, pts: &[Pos2]) -> f32 {
(0..pts.len())
.map(|i| {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
let ab = b - a;
let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0);
p.distance(a + ab * t)
})
.fold(f32::INFINITY, f32::min)
}
fn cube_centroid(pts: &[Pos2]) -> Pos2 {
let n = pts.len().max(1) as f32;
pos2(pts.iter().map(|p| p.x).sum::<f32>() / n, pts.iter().map(|p| p.y).sum::<f32>() / n)
}
/// The in-face axes of a view cube face.
fn cube_face_axes(n: Vec3) -> (Vec3, Vec3) {
if n.z.abs() > 0.5 {
(Vec3::X, Vec3::Y)
} else if n.x.abs() > 0.5 {
(Vec3::Y, Vec3::Z)
} else {
(Vec3::X, Vec3::Z)
}
}
fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool {
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (a, b) = (poly[i], poly[(i + 1) % n]);
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
if p.x < x {
inside = !inside;
}
}
}
inside
}
/// Navigation bar (bottom centre).
/// Turn the view to face the selected planar face or plane, else the active sketch.
pub fn look_at_selection(app: &mut SolveApp) {
@@ -2465,7 +2430,7 @@ pub fn look_at_selection(app: &mut SolveApp) {
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let items: [(&str, &str); 10] = [
("orbit", "Orbit (drag; or Shift+middle / right drag)"),
("orbit", "Orbit (right drag; hold Shift to snap)"),
("spin", "Continuous orbit (stops at the next click or key)"),
("pan", "Pan (drag; or middle drag)"),
("zoom", "Zoom (drag; or wheel)"),
@@ -2945,44 +2910,44 @@ mod tests {
}
#[test]
fn floating_dialog_blocks_view_cube_highlights_and_clicks() {
fn floating_dialog_blocks_axis_view_highlights_and_clicks() {
let mut h = crate::scenario::Harness::new();
h.call("ui.resize", json!({"width": 800, "height": 700}));
let p = crate::scenario::handle_at("cube:top").unwrap();
let p = crate::scenario::handle_at("axis_view:z").unwrap();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0));
h.call("ui.prefs", json!({"open": "Navigation"}));
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
assert!(window.contains(p), "the floating dialog must cover the cube target");
assert!(window.contains(p), "the floating dialog must cover the axis target");
let camera = serde_json::to_value(h.app.cam).unwrap();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view");
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8}));
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the cube");
assert!(h.app.viewport.cube_drag.is_none());
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo");
assert!(h.app.viewport.axis_drag.is_none());
h.call("ui.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
}
#[test]
fn view_cube_drag_owns_navigation_and_keeps_the_view_centre() {
fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() {
for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view": "front"}));
h.app.viewport.nav = nav;
let before = h.app.cam;
let p = crate::scenario::handle_at("cube:front").unwrap();
let p = crate::scenario::handle_at("axis_view:-y").unwrap();
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8}));
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: cube drag must orbit exactly once");
assert_eq!(h.app.cam.target, before.target, "{nav:?}: cube drag must not pan");
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once");
assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan");
assert_eq!(h.app.cam.distance, before.distance);
assert_eq!(h.app.cam.pitch, before.pitch);
assert!(h.app.viewport.cube_drag.is_none(), "release clears the cube gesture");
assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture");
assert!(h.app.viewport.boxsel.is_none());
}
}
@@ -3018,69 +2983,8 @@ mod tests {
assert!(!build_highlight(&app).hidden.is_empty());
}
/// A camera basis looking from `dir` (target → eye), as the view cube draws with.
fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) {
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
cam.basis()
}
#[test]
fn view_cube_has_14_targets_each_hit_where_drawn() {
let targets = cube_targets();
assert_eq!(targets.len(), 14);
assert_eq!(targets.iter().filter(|t| t.corner).count(), 8);
let c = pos2(100.0, 100.0);
for (i, tg) in targets.iter().enumerate() {
// Seen from a little off its own direction, its middle hits it.
let shapes = cube_projected(&targets, basis_from(tg.dir + Vec3::new(0.11, 0.07, 0.05)), c);
let pts = &shapes.iter().find(|(j, _)| *j == i).expect("visible").1;
assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name);
}
// From an iso view: 3 faces and the 4 facets turned toward it.
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.0, 1.0)), c);
assert_eq!(shapes.len(), 3 + 4);
assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None);
}
/// From any direction, including faces turned nearly edge-on, everything drawn stays within
/// the cube.
#[test]
fn view_cube_faces_never_spike_past_the_cube() {
let targets = cube_targets();
let c = pos2(100.0, 100.0);
// The cube's circumradius (corner at √3) plus the stroke and feathering.
let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0;
let mut worst = 0.0f32;
for dir in orbit_directions() {
for (_, pts) in cube_projected(&targets, basis_from(dir), c) {
for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
worst = worst.max(p.distance(c));
}
}
}
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
}
#[test]
fn view_cube_labels_read_upright_in_their_own_view() {
for tg in cube_targets().iter().filter(|t| !t.corner) {
// The standard face view (TOP and BOTTOM with yaw 0).
let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
if tg.dir.z.abs() > 0.5 {
cam.yaw = 0.0;
}
let (r, u, _) = cam.basis();
let (right, up) = cube_label_axes(tg.dir);
assert!(right.dot(r) > 0.99 && up.dot(u) > 0.99, "{}: right·r {} up·u {}", tg.name, right.dot(r), up.dot(u));
}
}
fn face_labels(targets: &[CubeTarget]) -> Vec<String> {
targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect()
}
#[test]
fn view_cube_top_bottom_choose_nearest_square_heading() {
fn axis_view_top_bottom_choose_nearest_square_heading() {
for (from, to) in [
(0.0, 0.0),
(30.0, 0.0),
@@ -3105,72 +3009,136 @@ mod tests {
assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0);
}
/// The widest label spans 90% of its face, looking straight at it.
#[test]
fn view_cube_widest_label_spans_most_of_its_face() {
let ctx = egui::Context::default();
let mut span = 0.0f32;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets()));
let front = Vec3::new(0.0, -1.0, 0.0);
let (r, u, _) = basis_from(front);
let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]);
let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
span = (hi - lo) / (2.0 * CUBE_SCALE as f32);
});
assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face");
fn shift_orbit_accumulates_and_returns_to_free_rotation() {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view":"front"}));
let rect = h.app.viewport.rect.unwrap();
let before = h.app.cam;
for _ in 0..100 {
orbit_drag(&mut h.app, rect, 2.0, 0.0, None, true, false);
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
}
for _ in 0..120 {
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
}
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
assert_eq!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.distance, before.distance);
orbit_drag(&mut h.app, rect, 0.0, 0.0, None, false, false);
h.settle();
assert!((h.app.cam.yaw - 1.6).abs() < 1e-6);
assert!(h.app.viewport.orbit_free.is_none());
}
/// From any direction, each label is printed within its face.
#[test]
fn view_cube_labels_stay_on_their_faces() {
let ctx = egui::Context::default();
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let targets = cube_targets();
let size = cube_label_size(ui.ctx(), &face_labels(&targets));
let c = pos2(100.0, 100.0);
for dir in orbit_directions().step_by(7) {
let (r, u, b) = basis_from(dir);
for (i, pts) in cube_projected(&targets, (r, u, b), c) {
let tg = &targets[i];
if tg.corner {
continue;
}
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) {
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
worst = worst.max(outside);
drawn += 1;
}
}
fn axis_snap_selects_all_directions_in_both_up_frames() {
assert_eq!(snap_directions().count(), 26);
for up in [solvecraft_engine::render::UpAxis::Z, solvecraft_engine::render::UpAxis::Y] {
for d in snap_directions() {
let mut cam = Camera { up, ..Camera::default() };
cam = cam.looking_from(cam.from_frame(d + Vec3::new(0.05, 0.03, 0.02)));
let to = snapped_axis_camera(cam);
assert!(to.back().dist(cam.from_frame(d)) < 1e-6);
assert_eq!(to.target, cam.target);
assert_eq!(to.distance, cam.distance);
assert_eq!(to.fov, cam.fov);
}
});
assert!(drawn > 10_000, "only {drawn} label vertices drawn");
assert!(worst <= 0.5, "a label vertex {worst} px outside its face");
}
}
#[test]
fn view_cube_edges_are_not_targets() {
let targets = cube_targets();
let c = pos2(100.0, 100.0);
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.2, 0.9)), c);
let (r, u, _) = basis_from(Vec3::new(1.0, -1.2, 0.9));
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
let name = |i: Option<usize>| i.map(|i| targets[i].name.clone()).unwrap_or_default();
// On the top-front edge, its middle and near its ends: a neighbouring face (or facet).
for x in [-0.5, 0.0, 0.5] {
let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0))));
assert!(hit == "top" || hit == "front", "edge point {x}: {hit}");
fn camera_resets_cancel_pending_axis_motion() {
for reset in 0..3 {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
let rect = h.app.viewport.rect.unwrap();
orbit_drag(&mut h.app, rect, 120.0, 40.0, None, true, false);
assert!(h.app.viewport.orbit_snap.is_some());
match reset {
0 => h.app.fit_view(),
1 => h.app.set_up_axis(solvecraft_engine::render::UpAxis::Y),
_ => crate::documents::new_design(&mut h.app),
}
let before = h.app.cam;
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
assert_eq!(h.app.cam.yaw, before.yaw);
assert_eq!(h.app.cam.pitch, before.pitch);
assert_eq!(h.app.cam.target, before.target);
assert!(h.app.viewport.orbit_snap.is_none());
assert!(h.app.viewport.orbit_free.is_none());
}
let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0))));
assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}");
// Every direction a click turns to is a face's or a corner's: none along an edge.
for tg in &targets {
let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count();
assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir);
}
#[test]
fn axis_snap_spring_eases_in_and_settles_at_each_frame_rate() {
for fps in [30, 60, 120] {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view":"front"}));
let rect = h.app.viewport.rect.unwrap();
orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false);
assert_eq!(h.app.cam.yaw, 0.0, "choosing a diagonal must not jump the camera");
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
assert!(h.app.cam.yaw > 0.0 && h.app.cam.yaw < std::f64::consts::FRAC_PI_4 * 0.25);
for _ in 0..fps * 2 {
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
assert!(h.app.cam.yaw <= std::f64::consts::FRAC_PI_4 + 0.03, "elastic finish stays small");
}
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6);
assert!(h.app.viewport.orbit_snap.is_none());
}
}
#[test]
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.orbit_cursor = true;
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
h.app.fit_view();
let rect = h.app.viewport.rect.unwrap();
let at = rect.center() + vec2(50.0, -20.0);
orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), false, true);
let pivot = h.app.viewport.orbit_pivot.unwrap();
let before = projection(&h.app, rect).to_screen(pivot).unwrap();
for _ in 0..20 {
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
assert!(after.distance(before) < 2.0);
}
h.app.viewport.orbit_free = None;
h.app.viewport.orbit_pivot = None;
h.call("ui.view", json!({"view":"front"}));
let before = h.app.cam;
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
h.call("ui.view", json!({"view":"front"}));
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
assert_eq!(h.app.cam.yaw, before.yaw);
assert_eq!(h.app.cam.pitch, before.pitch);
assert_ne!(h.app.cam.target, before.target);
}
#[test]
fn axis_view_click_again_flips_and_drag_snaps() {
let mut h = crate::scenario::Harness::new();
h.run(&json!([
{"call":"ui.view","params":{"view":"home"}},
{"click":{"handle":"axis_view:z"}},
{"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}},
{"click":{"handle":"axis_view:z"}},
{"expect":{"camera":{"pitch_deg":-90,"yaw_deg":0}}},
{"call":"ui.view","params":{"view":"front"}}
]))
.unwrap();
let c = axis_center(h.app.viewport.rect.unwrap());
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
assert!(h.app.viewport.axis_drag.is_none());
assert!(h.app.viewport.orbit_free.is_none());
assert!(h.app.viewport.boxsel.is_none());
}
#[test]
fn construction_axes_span_the_model_and_points_show() {
let mut s = solvecraft_engine::Session::default();