From 322a357dee4be0c6c69f8c55060dc36ac5e86b37 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 21:58:43 +0000 Subject: [PATCH] Y-up worlds in the camera: an up axis on the camera (Z by default, or Y), with yaw and pitch measured about it; standard views, the home view, Look At, orbit, pan, the ground grid (XZ with Y up), the ground shadow and snapshots follow it. ui upAxis setting (app.set_up_axis re-aims and frames); cam.up_dir / to_frame / from_frame for the view cube and others Part of #3 (the camera half; the preference and import offer are shell's) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/engine/src/view.rs | 20 ++++++-- crates/render/src/camera.rs | 88 +++++++++++++++++++++++++++++++--- crates/render/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 16 +++++++ crates/ui-egui/src/viewport.rs | 26 ++++++++-- 5 files changed, 136 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 18c7ed72..b857f58d 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -124,16 +124,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() }; let b = bounds(s); sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); - // Grid on XY. + // Grid on the ground plane (XY with Z up, XZ with Y up). let (minor, major) = grid_step(cam.half_height()); let ext = (cam.half_height() * 3.0 / major).ceil() * major; - let c = Vec3::new((cam.target.x / major).round() * major, (cam.target.y / major).round() * major, 0.0); + let tf = cam.to_frame(cam.target); + let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); + let w = |p: Vec3| cam.from_frame(p); let n = ((ext / minor) as i64).clamp(1, 400); for i in -n..=n { let t = i as f64 * minor; let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID }; - sc.lines.push(SceneLine { points: vec![c + Vec3::new(t, -ext, 0.0), c + Vec3::new(t, ext, 0.0)], color: col, width: 0.6, on_top: false }); - sc.lines.push(SceneLine { points: vec![c + Vec3::new(-ext, t, 0.0), c + Vec3::new(ext, t, 0.0)], color: col, width: 0.6, on_top: false }); + sc.lines.push(SceneLine { + points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], + color: col, + width: 0.6, + on_top: false, + }); + sc.lines.push(SceneLine { + points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))], + color: col, + width: 0.6, + on_top: false, + }); } let axis = cam.half_height() * 0.25; for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 6654f366..2b665ac0 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -181,7 +181,42 @@ impl CameraAnim { } } -/// Orbit camera around `target` (Z up). +/// Which world axis points up in the view (#3): Z (the default) or Y, as most STEP files from +/// other systems are modelled. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UpAxis { + #[default] + Z, + Y, +} + +impl UpAxis { + /// The world's up direction. + pub fn dir(self) -> Vec3 { + match self { + UpAxis::Z => Vec3::Z, + UpAxis::Y => Vec3::Y, + } + } + /// A world vector in the camera's Z-up frame (yaw turns about the frame's Z). + pub fn to_frame(self, v: Vec3) -> Vec3 { + match self { + UpAxis::Z => v, + UpAxis::Y => Vec3::new(v.x, -v.z, v.y), + } + } + /// A Z-up frame vector in the world. + pub fn from_frame(self, v: Vec3) -> Vec3 { + match self { + UpAxis::Z => v, + UpAxis::Y => Vec3::new(v.x, v.z, -v.y), + } + } +} + +/// Orbit camera around `target`. Yaw and pitch are about the up axis (Z, or Y for a Y-up +/// world): with Y up, the front view looks along −Z and the top view down −Y. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Camera { pub target: Vec3, @@ -193,12 +228,15 @@ pub struct Camera { pub distance: f64, /// Vertical field of view (radians); 0 = orthographic. pub fov: f64, + /// The world's up axis. + #[serde(default)] + pub up: UpAxis, } impl Default for Camera { fn default() -> Self { let (yaw, pitch) = StandardView::Iso.angles(); - Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 } + Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z } } } @@ -206,7 +244,19 @@ impl Camera { /// Direction from the target toward the eye. pub fn back(&self) -> Vec3 { let (cp, sp) = (self.pitch.cos(), self.pitch.sin()); - Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z) + self.up.from_frame(Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)) + } + /// The world's up direction. + pub fn up_dir(&self) -> Vec3 { + self.up.dir() + } + /// A world vector in the camera's Z-up frame (where yaw and pitch are measured). + pub fn to_frame(&self, v: Vec3) -> Vec3 { + self.up.to_frame(v) + } + /// A Z-up frame vector in the world. + pub fn from_frame(&self, v: Vec3) -> Vec3 { + self.up.from_frame(v) } pub fn eye(&self) -> Vec3 { self.target + self.back() * self.distance @@ -214,8 +264,8 @@ impl Camera { /// Camera basis: right, up, back (orthonormal). pub fn basis(&self) -> (Vec3, Vec3, Vec3) { let b = self.back(); - let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0); - let up = b.cross(right).normalized().unwrap_or(Vec3::Z); + let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0)); + let up = b.cross(right).normalized().unwrap_or(self.up.dir()); (right, up, b) } pub fn view(&self) -> Mat4 { @@ -317,7 +367,7 @@ impl Camera { /// The same camera looking from direction `back` (target → eye). Straight up or down /// keeps the yaw, rounded to the nearest quarter turn so the view stays square. pub fn looking_from(&self, back: Vec3) -> Camera { - let Some(b) = back.normalized() else { return *self }; + let Some(b) = self.up.to_frame(back).normalized() else { return *self }; let lim = std::f64::consts::FRAC_PI_2 - 1e-6; let pitch = b.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim); let yaw = if b.x.hypot(b.y) < 1e-6 { @@ -355,6 +405,7 @@ impl Camera { pitch, distance: a.distance + (b.distance - a.distance) * s, fov: a.fov + (b.fov - a.fov) * s, + up: b.up, } } pub fn is_valid(&self) -> bool { @@ -508,4 +559,29 @@ mod tests { } } } + + /// With Y up: the front view looks along −Z (eye on +Z), the top view looks down −Y with X + /// to the right, orbiting turns about Y, and the picture's up is the world's up. + #[test] + fn y_up_views() { + let mut c = Camera { up: UpAxis::Y, distance: 100.0, ..Default::default() }; + c.set_view(StandardView::Front); + assert!(c.back().dist(Vec3::Z) < 1e-9, "front eye {:?}", c.back()); + let (r, u, b) = c.basis(); + assert!(r.dist(Vec3::X) < 1e-9 && u.dist(Vec3::Y) < 1e-9 && b.dist(Vec3::Z) < 1e-9); + c.set_view(StandardView::Top); + assert!(c.back().dist(Vec3::Y) < 1e-5, "top eye {:?}", c.back()); + assert!(c.basis().0.dist(Vec3::X) < 1e-9); + // Orbiting sideways keeps the eye's height above the XZ ground. + c.set_view(StandardView::Iso); + let h = c.back().y; + c.orbit(120.0, 0.0); + assert!((c.back().y - h).abs() < 1e-12); + // Looking from a world direction lands on it. + let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap(); + assert!(c.looking_from(d).back().dist(d) < 1e-9); + // Frames round-trip. + let v = Vec3::new(1.0, 2.0, 3.0); + assert!(UpAxis::Y.from_frame(UpAxis::Y.to_frame(v)).dist(v) < 1e-12); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 19d3e4e4..38e7a0aa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,5 +7,5 @@ mod camera; mod raster; -pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out}; +pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 968c4488..161f3122 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,8 @@ 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. + pub up_axis: solvecraft_engine::render::UpAxis, /// Sketches hidden one by one, and finished sketches shown although a feature uses them. pub hidden_sketches: Vec, pub shown_sketches: Vec, @@ -128,6 +130,7 @@ impl Default for UiState { pick_bodies: false, visual_style: 0, ground_shadow: true, + up_axis: Default::default(), hidden_sketches: Vec::new(), shown_sketches: Vec::new(), hidden_profiles: Vec::new(), @@ -584,6 +587,16 @@ impl SolveApp { } /// Frame the model at once (programmatic use; the toolbar animates). + /// Make `up` the world's up axis: the camera turns to the same standard view in that world + /// (home) and frames the model. + pub fn set_up_axis(&mut self, up: solvecraft_engine::render::UpAxis) { + self.ui.up_axis = up; + self.cam_anim = None; + self.cam.up = up; + self.cam.set_view(StandardView::Iso); + self.fit_view(); + } + pub fn fit_view(&mut self) { self.cam_anim = None; self.cam = self.fitted(self.cam); @@ -659,6 +672,9 @@ impl SolveApp { self.fitted = true; } self.now = ctx.input(|i| i.time); + if self.cam.up != self.ui.up_axis { + self.set_up_axis(self.ui.up_axis); + } self.step_view_animation(ctx); self.drain_control(ctx); agent_cursor::step(self, ctx); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index abca0199..63fd49c1 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -165,6 +165,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.ui.show_grid.hash(&mut h); app.ui.visual_style.hash(&mut h); app.ui.ground_shadow.hash(&mut h); + app.cam.up.hash(&mut h); (app.cam.pitch > 0.0).hash(&mut h); app.ui.show_sketches.hash(&mut h); app.ui.hidden_bodies.hash(&mut h); @@ -204,15 +205,18 @@ fn build_scene(app: &SolveApp) -> GpuScene { if app.ui.show_grid && !active_view(app).hide_grid { let (minor, major) = grid_step(app.cam.half_height()); let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; - let c = Vec3::new((app.cam.target.x / major).round() * major, (app.cam.target.y / major).round() * major, 0.0); + // The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame). + let tf = app.cam.to_frame(app.cam.target); + let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); + let w3 = |p: Vec3| app.cam.from_frame(p).to_f32(); let n = ((ext / minor) as i64).clamp(1, 300); for i in -n..=n { let t = i as f64 * minor; let maj = (t / major).round() * major == t; let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; let w = if maj { 1.0 } else { 0.7 }; - sc.under_line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w); - sc.under_line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w); + sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w); + sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); } } for b in &st.bodies { @@ -310,9 +314,21 @@ fn build_scene(app: &SolveApp) -> GpuScene { /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) { + // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solvecraft_engine::geom::Aabb3::EMPTY; for x in st.bodies.iter().filter(|x| !app.ui.hidden_bodies.contains(&x.name)) { - b = b.union(&x.mesh().bounds()); + let bb = x.mesh().bounds(); + if bb.is_empty() { + continue; + } + for i in 0..8 { + let p = Vec3::new( + if i & 1 == 0 { bb.min.x } else { bb.max.x }, + if i & 2 == 0 { bb.min.y } else { bb.max.y }, + if i & 4 == 0 { bb.min.z } else { bb.max.z }, + ); + b.add(app.cam.to_frame(p)); + } } if b.is_empty() { return; @@ -330,7 +346,7 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc: let n = 48; let at = |r: f64, k: usize| { let a = k as f64 / n as f64 * std::f64::consts::TAU; - Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z).to_f32() + app.cam.from_frame(Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z)).to_f32() }; for w in rings.windows(2) { let ((r0, a0), (r1, a1)) = (w[0], w[1]);