From f00c48bebb755b2c7a8880d22447b29f223809b2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 25 Jun 2026 23:46:43 +0300 Subject: [PATCH] feat(viewcube): add compass ring, cardinals, and nav controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the ViewCube out into a full navigation widget: - Compass ring painted in the cube's ground plane (z = base) so it reads as a ground compass and foreshortens with the view. - N/E/S/W cardinals painted flat onto the ring band; clicking one snaps to that side elevation. Added S/W glyphs to the bitmap atlas. - Home button (top-down view), roll arrows (90° roll about the view axis), and four nudge triangles (90° tip/spin) as SVG iced controls laid out around the cube. New home.svg. - WCS / named-UCS selector under the cube, switching the active UCS. The cube hit area now shares one stack with the control buttons, so a click on a button is caught by the button while a click on the cube falls through. Face clicks snap to the highlighted region rather than a cursor position the viewport's move handler can overwrite. Camera gains home_view / roll_by / nudge_90; floating viewports drive the same ops through mutate_active_viewport_camera, which re-encodes the result to the stored view direction + twist. Co-Authored-By: Claude Opus 4.8 --- assets/icons/ui/home.svg | 1 + src/app/mod.rs | 8 + src/app/update.rs | 91 ++++++++++++ src/app/view.rs | 261 ++++++++++++++++++++++++++++++--- src/scene/mod.rs | 42 +++++- src/scene/pipeline/viewcube.rs | 209 ++++++++++++++++++++++++-- src/scene/view/camera.rs | 33 +++++ src/ui/icons.rs | 12 ++ src/ui/overlay.rs | 16 +- 9 files changed, 633 insertions(+), 40 deletions(-) create mode 100644 assets/icons/ui/home.svg diff --git a/assets/icons/ui/home.svg b/assets/icons/ui/home.svg new file mode 100644 index 000000000..b0d4cf3f3 --- /dev/null +++ b/assets/icons/ui/home.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index 1989ec249..2f79ea93c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1114,6 +1114,14 @@ pub enum Message { ViewportScroll(mouse::ScrollDelta), ViewportExit, ViewCubeSnap(CubeRegion), + /// ViewCube home button → jump to the default isometric view. + ViewCubeHome, + /// ViewCube roll arrow → roll the view 90° (true = clockwise). + ViewCubeRoll(bool), + /// ViewCube nudge triangle → tip / spin the view 90°. + ViewCubeNudge(crate::scene::NudgeDir), + /// WCS/UCS selector under the cube — empty string = World. + SetViewcubeUcs(String), /// User picked an item in the multi-functional grip popup menu — /// the index is into `grip_popup.items`. GripMenuPick(usize), diff --git a/src/app/update.rs b/src/app/update.rs index 8ba5ea195..23b81cb46 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -4468,9 +4468,26 @@ impl OpenCADStudio { ) } }; + // Prefer the currently-highlighted region: hover is recomputed + // on every move straight from the cube's own overlay, so it is + // immune to `cursor_pos` being overwritten by the viewport's + // move handler between the last move and this press. + if let Some(id) = self.tabs[i].scene.viewcube_hover.get() { + let region = if id < 6 { + scene::CubeRegion::Face(id) + } else if id < 18 { + scene::CubeRegion::Edge(id) + } else { + scene::CubeRegion::Corner(id) + }; + return Task::done(Message::ViewCubeSnap(region)); + } if let Some(region) = scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX) { return Task::done(Message::ViewCubeSnap(region)); } + if let Some(card) = scene::hit_test_cardinal(cx, cy, w, h, rot, VIEWCUBE_PX) { + return Task::done(Message::ViewCubeSnap(card.face_region())); + } Task::none() } @@ -4509,6 +4526,80 @@ impl OpenCADStudio { Task::none() } + Message::ViewCubeHome => { + let i = self.active_tab; + let r_ucs = self.tabs[i].scene.viewcube_ucs_mat(); + if self.tabs[i].scene.active_viewport.is_some() { + self.tabs[i] + .scene + .mutate_active_viewport_camera(|c| c.home_view(r_ucs)); + } else { + self.tabs[i].scene.camera.borrow_mut().home_view(r_ucs); + } + self.tabs[i].scene.camera_generation += 1; + self.command_line.push_output("View: Home"); + Task::none() + } + + Message::ViewCubeRoll(cw) => { + let i = self.active_tab; + let ang = if cw { + std::f32::consts::FRAC_PI_2 + } else { + -std::f32::consts::FRAC_PI_2 + }; + if self.tabs[i].scene.active_viewport.is_some() { + self.tabs[i] + .scene + .mutate_active_viewport_camera(|c| c.roll_by(ang)); + } else { + self.tabs[i].scene.camera.borrow_mut().roll_by(ang); + } + self.tabs[i].scene.camera_generation += 1; + Task::none() + } + + Message::ViewCubeNudge(dir) => { + use crate::scene::NudgeDir; + let (horizontal, positive) = match dir { + NudgeDir::Up => (false, false), + NudgeDir::Down => (false, true), + NudgeDir::Left => (true, false), + NudgeDir::Right => (true, true), + }; + let i = self.active_tab; + if self.tabs[i].scene.active_viewport.is_some() { + self.tabs[i] + .scene + .mutate_active_viewport_camera(|c| c.nudge_90(horizontal, positive)); + } else { + self.tabs[i] + .scene + .camera + .borrow_mut() + .nudge_90(horizontal, positive); + } + self.tabs[i].scene.camera_generation += 1; + Task::none() + } + + Message::SetViewcubeUcs(name) => { + let i = self.active_tab; + if name.is_empty() || name == "WCS" { + self.tabs[i].active_ucs = None; + self.command_line.push_output("UCS: World"); + } else if let Some(named) = + self.tabs[i].scene.document.ucss.get(&name).cloned() + { + self.tabs[i].active_ucs = Some(named); + self.command_line.push_output(&format!("UCS: {}", name)); + } + self.tabs[i].sync_ucs_to_scene(); + self.tabs[i].scene.camera_generation += 1; + self.tabs[i].dirty = true; + Task::none() + } + Message::GripDwellTick => { let i = self.active_tab; // Reuse the move-time logic — `p` is the last cursor diff --git a/src/app/view.rs b/src/app/view.rs index dc22ab692..921220ea7 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -5,7 +5,7 @@ use super::history::history_dropdown_labels; use super::{Message, OpenCADStudio}; use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; -use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; +use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_PX, VIEWCUBE_REGION_PX}; use crate::ui::overlay; use iced::widget::{ button, column, container, mouse_area, pick_list, row, shader, stack, text, text_input, Row, @@ -14,7 +14,7 @@ use iced::widget::{ use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; -const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; +const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; /// `pick_list` requires its items to implement `Display`; acadrust's /// `ViewportRenderMode` enum carries the raw DXF integers, not a label, @@ -532,22 +532,42 @@ impl OpenCADStudio { if self.show_viewcube { let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - let cube_click = column![ + + let controls = column![ Space::new().height(iced::Length::Fixed(cube_y)), row![ Space::new().width(iced::Length::Fixed(cube_x)), - mouse_area( - iced::widget::Space::new() - .width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)) - .height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)), - ) - .on_move(Message::CursorMoved) - .on_press(Message::ViewportClick), + viewcube_nav_controls(), ], ] .width(Fill) .height(Fill); - viewport_stack = viewport_stack.push(cube_click); + viewport_stack = viewport_stack.push(controls); + + let ucs_current = tab + .active_ucs + .as_ref() + .map(|u| u.name.clone()) + .unwrap_or_default(); + let ucs_names: Vec = tab + .scene + .document + .ucss + .iter() + .map(|u| u.name.clone()) + .filter(|n| !n.is_empty()) + .collect(); + let picker = column![ + Space::new().height(iced::Length::Fixed(cube_y + VIEWCUBE_HIT_SIZE - 8.0)), + row![ + Space::new() + .width(iced::Length::Fixed(cube_x + VIEWCUBE_HIT_SIZE * 0.5 - 42.0)), + iced::widget::opaque(viewcube_ucs_picker(ucs_current, ucs_names)), + ], + ] + .width(Fill) + .height(Fill); + viewport_stack = viewport_stack.push(picker); } } @@ -559,22 +579,43 @@ impl OpenCADStudio { let rect = tab.scene.active_model_tile_bounds(vw, vh); let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - let cube_click = column![ + + // Cube hit area + nav controls (home / roll / nudge) as one layer. + let controls = column![ Space::new().height(iced::Length::Fixed(cube_y)), row![ Space::new().width(iced::Length::Fixed(cube_x)), - mouse_area( - iced::widget::Space::new() - .width(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)) - .height(iced::Length::Fixed(VIEWCUBE_HIT_SIZE)), - ) - .on_move(Message::CursorMoved) - .on_press(Message::ViewportClick), + viewcube_nav_controls(), ], ] .width(Fill) .height(Fill); - viewport_stack = viewport_stack.push(cube_click); + viewport_stack = viewport_stack.push(controls); + + // WCS / named-UCS selector under the cube. + let ucs_current = tab + .active_ucs + .as_ref() + .map(|u| u.name.clone()) + .unwrap_or_default(); + let ucs_names: Vec = tab + .scene + .document + .ucss + .iter() + .map(|u| u.name.clone()) + .filter(|n| !n.is_empty()) + .collect(); + let picker = column![ + Space::new().height(iced::Length::Fixed(cube_y + VIEWCUBE_HIT_SIZE - 8.0)), + row![ + Space::new().width(iced::Length::Fixed(cube_x + VIEWCUBE_HIT_SIZE * 0.5 - 42.0)), + iced::widget::opaque(viewcube_ucs_picker(ucs_current, ucs_names)), + ], + ] + .width(Fill) + .height(Fill); + viewport_stack = viewport_stack.push(picker); } if let Some(dyn_ol) = dyn_input_overlay { @@ -4280,6 +4321,186 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme /// grid-snap toggles. `include_split` is off for paper-space viewports, which /// have no model-tile splitting. Grid / snap reflect the active viewport's /// state and emit `ToggleGrid` / `ToggleGridSnap`. +// ── ViewCube navigation controls (home / roll / nudge / UCS) ─────────────── + +/// Place `el` at pixel offset (x, y) inside a Fill layer (top-left origin). +fn vc_place<'a>(x: f32, y: f32, el: Element<'a, Message>) -> Element<'a, Message> { + column![ + Space::new().height(iced::Length::Fixed(y.max(0.0))), + row![Space::new().width(iced::Length::Fixed(x.max(0.0))), el], + ] + .width(Fill) + .height(Fill) + .into() +} + +/// Borderless square icon button used by the ViewCube nav controls. +fn vc_btn<'a>(content: Element<'a, Message>, size: f32, msg: Message) -> Element<'a, Message> { + button( + container(content) + .width(iced::Length::Fixed(size)) + .height(iced::Length::Fixed(size)) + .center_x(iced::Length::Fixed(size)) + .center_y(iced::Length::Fixed(size)), + ) + .padding(0) + .on_press(msg) + .style(|_: &Theme, status| iced::widget::button::Style { + background: Some(Background::Color(match status { + iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed => Color { + r: 0.45, + g: 0.62, + b: 0.95, + a: 0.30, + }, + _ => Color::TRANSPARENT, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +/// Overlay of home / roll / nudge controls sized to the whole nav region, so +/// the caller can position it exactly like the cube hit area. +fn viewcube_nav_controls<'a>() -> Element<'a, Message> { + use crate::scene::NudgeDir; + use crate::ui::icons; + let tint = Color { + r: 0.86, + g: 0.89, + b: 0.96, + a: 1.0, + }; + let r = VIEWCUBE_REGION_PX; + let c = r * 0.5; + let cube_half = VIEWCUBE_PX as f32 * 0.36; // VIEWCUBE_PX * VIEWCUBE_SCALE + let nr = cube_half + 8.0; // nudge triangle distance from centre + const BTN: f32 = 18.0; + const TRI: f32 = 13.0; + let ctr = |cx: f32, cy: f32, s: f32| (cx - s * 0.5, cy - s * 0.5); + + // Home top-left, roll arrows top-right. + let (rax, ray) = (r - 2.0 * BTN - 4.0, 2.0); + let (rbx, rby) = (r - BTN - 2.0, 2.0); + // Nudge triangles pointing inward at the four cube faces. + let (tux, tuy) = ctr(c, c - nr, TRI); + let (tdx, tdy) = ctr(c, c + nr, TRI); + let (tlx, tly) = ctr(c - nr, c, TRI); + let (trx, try_) = ctr(c + nr, c, TRI); + + // Bottom layer: the cube/cardinal hit area covering the whole region. The + // control buttons sit ABOVE it in the same stack, so a click on a button is + // caught by the button while a click on the cube (or empty space) falls + // through to this mouse_area → ViewportClick. Moves keep cursor_pos current. + let cube_hit = mouse_area( + Space::new() + .width(iced::Length::Fixed(r)) + .height(iced::Length::Fixed(r)), + ) + .on_move(Message::CursorMoved) + .on_press(Message::ViewportClick); + + let controls = stack![ + cube_hit, + vc_place( + 3.0, + 3.0, + vc_btn(icons::home(15.0, tint), BTN, Message::ViewCubeHome) + ), + vc_place( + rax, + ray, + vc_btn(icons::undo(14.0, tint), BTN, Message::ViewCubeRoll(false)) + ), + vc_place( + rbx, + rby, + vc_btn(icons::redo(14.0, tint), BTN, Message::ViewCubeRoll(true)) + ), + vc_place( + tux, + tuy, + vc_btn( + icons::arrow_down(11.0, tint), + TRI, + Message::ViewCubeNudge(NudgeDir::Up) + ) + ), + vc_place( + tdx, + tdy, + vc_btn( + icons::arrow_up(11.0, tint), + TRI, + Message::ViewCubeNudge(NudgeDir::Down) + ) + ), + vc_place( + tlx, + tly, + vc_btn( + icons::arrow_right(11.0, tint), + TRI, + Message::ViewCubeNudge(NudgeDir::Left) + ) + ), + vc_place( + trx, + try_, + vc_btn( + icons::arrow_left(11.0, tint), + TRI, + Message::ViewCubeNudge(NudgeDir::Right) + ) + ), + ]; + + container(controls) + .width(iced::Length::Fixed(r)) + .height(iced::Length::Fixed(r)) + .into() +} + +/// The WCS / named-UCS selector shown under the cube. +fn viewcube_ucs_picker<'a>(current: String, names: Vec) -> Element<'a, Message> { + let light = Color { + r: 0.85, + g: 0.87, + b: 0.93, + a: 1.0, + }; + let mut options = vec!["WCS".to_string()]; + options.extend(names); + let selected = if current.is_empty() { + "WCS".to_string() + } else { + current + }; + pick_list(options, Some(selected), Message::SetViewcubeUcs) + .text_size(11) + .padding([2, 6]) + .style(move |_: &Theme, _| iced::widget::pick_list::Style { + background: Background::Color(Color { + r: 0.16, + g: 0.17, + b: 0.20, + a: 0.92, + }), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + text_color: light, + placeholder_color: light, + handle_color: light, + }) + .into() +} + fn viewport_controls<'a>( render_mode: acadrust::entities::ViewportRenderMode, show_grid: bool, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 030b13762..5502f2dfc 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -29,7 +29,8 @@ pub use model::mesh_model::MeshLodSet; pub use model::object::{GripApply, GripDef}; pub use pipeline::uniforms::Uniforms; pub use pipeline::viewcube::{ - hit_test, hover_id, CubeRegion, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, + hit_test, hit_test_cardinal, hover_id, CubeRegion, NudgeDir, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, + VIEWCUBE_PX, VIEWCUBE_REGION_PX, }; pub use pick::selection::SelectionState; pub use model::wire_model::WireModel; @@ -3939,6 +3940,45 @@ impl Scene { } } + /// Mutate the active viewport's camera through a closure, then re-encode the + /// result to the stored `(view_direction, twist_angle)` — the same decode + /// the ViewCube snap uses. Lets the home / roll / nudge controls drive a + /// floating viewport just like the model camera. Returns `false` if there is + /// no active (unlocked) viewport. + pub fn mutate_active_viewport_camera( + &mut self, + f: impl FnOnce(&mut view::camera::Camera), + ) -> bool { + let Some(vp_handle) = self.active_viewport else { + return false; + }; + let mut tmp = self.camera_for_viewport(vp_handle).unwrap_or_default(); + f(&mut tmp); + let dir = (tmp.rotation * glam::Vec3::Z).normalize_or(glam::Vec3::Z); + let desired_up = (tmp.rotation * glam::Vec3::Y).normalize_or(glam::Vec3::Y); + let pitch = dir.z.clamp(-1.0, 1.0).asin(); + let yaw = if dir.x.abs() < 1e-6 && dir.y.abs() < 1e-6 { + 0.0 + } else { + dir.x.atan2(-dir.y) + }; + let up0 = (view::camera::yaw_pitch_to_quat(yaw, pitch, 0.0) * glam::Vec3::Y) + .normalize_or(glam::Vec3::Y); + let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up)); + let twist = -roll as f64; + if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { + if vp.status.locked { + return false; + } + vp.view_direction.x = dir.x as f64; + vp.view_direction.y = dir.y as f64; + vp.view_direction.z = dir.z as f64; + vp.twist_angle = twist; + return true; + } + false + } + /// Render mode of the active paper-space viewport, or `None` when no /// viewport is active (PSPACE / model layout). pub fn active_viewport_render_mode( diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index e5085883b..45403e8cf 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -19,6 +19,37 @@ pub const VIEWCUBE_PX: u32 = 120; pub const VIEWCUBE_SCALE: f32 = 0.36; pub const VIEWCUBE_DRAW_PX: f32 = VIEWCUBE_PX as f32 * VIEWCUBE_SCALE * 2.0; pub const VIEWCUBE_PAD: f32 = 12.0; +/// The cube centre is inset from the screen corner by this multiple of the +/// cube half-size, leaving room for the compass ring and the nav controls +/// (home / roll / nudge) drawn around it. +pub const NAV_INSET_F: f32 = 2.2; +/// Side of the whole nav widget (cube + compass ring + controls) in pixels. +pub const VIEWCUBE_REGION_PX: f32 = VIEWCUBE_DRAW_PX * NAV_INSET_F; +/// Z height of the compass ring + cardinals in cube-local space. The cube +/// spans ±1, so −1 parks the ring at the cube's base — it sits *under* the +/// cube in 3D views and reads as a ground compass. +const RING_Z: f32 = -1.0; +/// Horizontal radius of the N/E/S/W cardinal letters — the ring's mid-line, so +/// they sit painted on the ring band. +const R_CARD: f32 = 1.57; + +/// Compass cardinal directions, mapped to a side-face snap when clicked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cardinal { + North, + East, + South, + West, +} + +/// 90° view nudge directions (tip the cube up/down or spin it left/right). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NudgeDir { + Up, + Down, + Left, + Right, +} const FACE_LABELS: [&str; 6] = ["TOP", "BOTTOM", "FRONT", "BACK", "RIGHT", "LEFT"]; const FACE_CENTERS: [[f32; 3]; 6] = [ @@ -172,8 +203,10 @@ impl CubeUniforms { ) -> Self { let (hw, hh) = (vp_w as f32 * 0.5, vp_h as f32 * 0.5); let cube_half = cube_px as f32 * VIEWCUBE_SCALE; - let cx = hw - cube_half - VIEWCUBE_PAD; - let cy = hh - cube_half - VIEWCUBE_PAD; + // Inset the cube centre to leave room for the ring + controls. + let inset = cube_half * NAV_INSET_F; + let cx = hw - inset - VIEWCUBE_PAD; + let cy = hh - inset - VIEWCUBE_PAD; let view_proj = Mat4::orthographic_rh(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new(cx, cy, 0.0)) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); @@ -197,10 +230,11 @@ const GLYPH_H: usize = 7; const CELL_W: usize = 6; const CELL_H: usize = 8; const ATLAS_COLS: usize = 8; -const ATLAS_ROWS: usize = 2; +const ATLAS_ROWS: usize = 3; const MAX_LABEL_CHARS: usize = 6; const LABEL_COUNT: usize = 6; -const MAX_GLYPHS: usize = MAX_LABEL_CHARS * LABEL_COUNT; +// Face labels + the four compass cardinals (one glyph each). +const MAX_GLYPHS: usize = MAX_LABEL_CHARS * LABEL_COUNT + 4; const MAX_VERTS: usize = MAX_GLYPHS * 6; #[repr(C)] @@ -249,6 +283,8 @@ fn glyph_index(c: char) -> Option { 'P' => Some(13), 'R' => Some(14), 'T' => Some(15), + 'S' => Some(16), + 'W' => Some(17), _ => None, } } @@ -303,6 +339,12 @@ fn glyph_rows(c: char) -> [u8; GLYPH_H] { 'T' => [ 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, ], + 'S' => [ + 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110, + ], + 'W' => [ + 0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001, + ], _ => [0; GLYPH_H], } } @@ -312,7 +354,7 @@ fn build_atlas() -> (Vec, u32, u32) { let h = (ATLAS_ROWS * CELL_H) as u32; let mut data = vec![0u8; (w * h) as usize]; let glyphs = [ - 'A', 'B', 'C', 'E', 'F', 'G', 'H', 'I', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'T', + 'A', 'B', 'C', 'E', 'F', 'G', 'H', 'I', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'T', 'S', 'W', ]; for (i, &ch) in glyphs.iter().enumerate() { let col = i % ATLAS_COLS; @@ -534,11 +576,12 @@ impl ViewCubeText { ) { let (vw, vh) = (vp_w as f32, vp_h as f32); let cube_half = cube_px as f32 * VIEWCUBE_SCALE; + let inset = cube_half * NAV_INSET_F; let (hw, hh) = (vw * 0.5, vh * 0.5); let view_proj = Mat4::orthographic_rh(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new( - hw - cube_half - VIEWCUBE_PAD, - hh - cube_half - VIEWCUBE_PAD, + hw - inset - VIEWCUBE_PAD, + hh - inset - VIEWCUBE_PAD, 0.0, )) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); @@ -629,6 +672,50 @@ impl ViewCubeText { break; } } + + // ── Compass cardinals (N / E / S / W) ────────────────────────────── + // Painted flat onto the ring band (z = RING_Z) so they foreshorten with + // the ring and read upright in plan. Local axes u = +X, v = +Y give + // u × v = +Z → never mirrored when seen from above. + const CARD_GW: f32 = 0.16; // glyph size in cube-local units + const CARD_GH: f32 = 0.22; + let cardinals = [ + ('N', Vec3::new(0.0, 1.0, 0.0)), + ('E', Vec3::new(1.0, 0.0, 0.0)), + ('S', Vec3::new(0.0, -1.0, 0.0)), + ('W', Vec3::new(-1.0, 0.0, 0.0)), + ]; + for (ch, dir) in cardinals { + let Some(gi) = glyph_index(ch) else { + continue; + }; + let center = Vec3::new(dir.x * R_CARD, dir.y * R_CARD, RING_Z); + // Dim a cardinal whose ring point sits behind the cube. + let alpha = if cam_rotation.transform_point3(center).z >= -0.15 { + 1.0 + } else { + 0.5 + }; + let color = [1.0, 1.0, 1.0, alpha]; + let (u0, v0, u1, v1) = glyph_uv(gi, self.atlas_w, self.atlas_h); + let corner = |lx: f32, ly: f32| center + Vec3::X * lx + Vec3::Y * ly; + let tl = project(corner(-CARD_GW * 0.5, CARD_GH * 0.5)); + let tr = project(corner(CARD_GW * 0.5, CARD_GH * 0.5)); + let br = project(corner(CARD_GW * 0.5, -CARD_GH * 0.5)); + let bl = project(corner(-CARD_GW * 0.5, -CARD_GH * 0.5)); + if let (Some(tl), Some(tr), Some(br), Some(bl)) = (tl, tr, br, bl) { + let mk = |pos: [f32; 2], uv: [f32; 2]| TextVertex { pos, uv, color }; + verts.push(mk(tl, [u0, v0])); + verts.push(mk(tr, [u1, v0])); + verts.push(mk(br, [u1, v1])); + verts.push(mk(tl, [u0, v0])); + verts.push(mk(br, [u1, v1])); + verts.push(mk(bl, [u0, v1])); + } + if verts.len() >= self.vertex_capacity as usize { + break; + } + } self.vertex_count = verts.len() as u32; if self.vertex_count > 0 { queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&verts)); @@ -918,9 +1005,42 @@ pub fn build_geometry() -> (Vec, Vec) { &mut is, ); } + build_ring(&mut vs, &mut is); (vs, is) } +/// A flat compass ring in the cube's local XY plane (the ground plane), +/// surrounding the cube. Pushed with a sentinel `region_f = -1.0` so the +/// shader never highlights it on hover, and a constant grey colour. +fn build_ring(vs: &mut Vec, is: &mut Vec) { + const SEG: usize = 64; + const R0: f32 = 1.40; // inner radius — clear gap to the cube faces + const R1: f32 = 1.74; // outer radius — wider, thicker band + const RING_RGB: [f32; 3] = [0.22, 0.23, 0.26]; + for s in 0..SEG { + let a0 = s as f32 / SEG as f32 * std::f32::consts::TAU; + let a1 = (s + 1) as f32 / SEG as f32 * std::f32::consts::TAU; + let (c0, s0) = (a0.cos(), a0.sin()); + let (c1, s1) = (a1.cos(), a1.sin()); + let quad = [ + [c0 * R0, s0 * R0, RING_Z], + [c1 * R0, s1 * R0, RING_Z], + [c1 * R1, s1 * R1, RING_Z], + [c0 * R1, s0 * R1, RING_Z], + ]; + let base = vs.len() as u32; + for pos in quad { + vs.push(CubeVertex { + pos, + normal: [0.0, 0.0, 1.0], + color: RING_RGB, + region_f: -1.0, + }); + } + is.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } +} + pub fn region_centroids() -> [[f32; 3]; NUM_REGIONS] { let m = (F + E) * 0.5; [ @@ -1203,8 +1323,9 @@ pub fn hit_test( cube_px: u32, ) -> Option { let half = cube_px as f32 * VIEWCUBE_SCALE; - let cx = vp_w - half - VIEWCUBE_PAD; - let cy = half + VIEWCUBE_PAD; + let inset = half * NAV_INSET_F; + let cx = vp_w - inset - VIEWCUBE_PAD; + let cy = inset + VIEWCUBE_PAD; if (mx - cx).abs() > half || (my - cy).abs() > half { return None; } @@ -1212,8 +1333,8 @@ pub fn hit_test( let (hw, hh) = (vp_w * 0.5, vp_h * 0.5); let vp = Mat4::orthographic_rh(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new( - hw - half - VIEWCUBE_PAD, - hh - half - VIEWCUBE_PAD, + hw - inset - VIEWCUBE_PAD, + hh - inset - VIEWCUBE_PAD, 0.0, )) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); @@ -1259,3 +1380,69 @@ pub fn hover_id( ) -> Option { hit_test(mx, my, vp_w, vp_h, cam_rotation, cube_px).map(|r| r.id()) } + +impl Cardinal { + /// The side-elevation face a compass letter snaps to when clicked. + pub fn face_region(self) -> CubeRegion { + CubeRegion::Face(match self { + Cardinal::North => FACE_BACK, + Cardinal::South => FACE_FRONT, + Cardinal::East => FACE_RIGHT, + Cardinal::West => FACE_LEFT, + }) + } +} + +/// Returns the compass cardinal under (mx, my), or None. Projects each of the +/// four ring letters and accepts the nearest within a small pixel radius — the +/// caller tries the cube body first, so this only fires out on the ring. +pub fn hit_test_cardinal( + mx: f32, + my: f32, + vp_w: f32, + vp_h: f32, + cam_rotation: Mat4, + cube_px: u32, +) -> Option { + let half = cube_px as f32 * VIEWCUBE_SCALE; + let inset = half * NAV_INSET_F; + let cx = vp_w - inset - VIEWCUBE_PAD; + let cy = inset + VIEWCUBE_PAD; + if (mx - cx).abs() > inset || (my - cy).abs() > inset { + return None; + } + + let (hw, hh) = (vp_w * 0.5, vp_h * 0.5); + let vp = Mat4::orthographic_rh(-hw, hw, -hh, hh, -2000.0, 2000.0) + * Mat4::from_translation(Vec3::new( + hw - inset - VIEWCUBE_PAD, + hh - inset - VIEWCUBE_PAD, + 0.0, + )) + * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); + + let dirs = [ + (Cardinal::North, Vec3::new(0.0, 1.0, 0.0)), + (Cardinal::East, Vec3::new(1.0, 0.0, 0.0)), + (Cardinal::South, Vec3::new(0.0, -1.0, 0.0)), + (Cardinal::West, Vec3::new(-1.0, 0.0, 0.0)), + ]; + let thresh = (half * 0.34).powi(2); + let (mut best, mut best_d) = (None, f32::MAX); + for (card, dir) in dirs { + let anchor = Vec3::new(dir.x * R_CARD, dir.y * R_CARD, RING_Z); + let world = cam_rotation.transform_point3(anchor); + let clip = vp * Vec4::new(world.x, world.y, world.z, 1.0); + if clip.w.abs() < 1e-6 { + continue; + } + let sx = (clip.x / clip.w + 1.0) * 0.5 * vp_w; + let sy = (1.0 - clip.y / clip.w) * 0.5 * vp_h; + let d = (sx - mx).powi(2) + (sy - my).powi(2); + if d < thresh && d < best_d { + best_d = d; + best = Some(card); + } + } + best +} diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index bd1065d02..01108afcd 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -390,6 +390,39 @@ impl Camera { self.sync_yaw_pitch(); } + /// Jump to the default "home" view — top-down (looking along −Z), expressed + /// in the active UCS so it lands square to the user's coordinate frame. + pub fn home_view(&mut self, ucs: glam::Mat4) { + let dir = ucs.transform_vector3(Vec3::Z); + self.snap_to_direction(dir, ucs); + } + + /// Roll the camera about its own view axis by `angle` radians. The gaze + /// direction is unchanged; only the up-sense twists. + pub fn roll_by(&mut self, angle: f32) { + self.rotation = (self.rotation * Quat::from_rotation_z(angle)).normalize(); + self.sync_yaw_pitch(); + } + + /// Tip / spin the view 90° about a screen axis. `horizontal = false` tips + /// up/down (rotation about the camera's right axis); `true` spins + /// left/right (about the camera's up axis). + pub fn nudge_90(&mut self, horizontal: bool, positive: bool) { + let axis = if horizontal { + self.rotation * Vec3::Y + } else { + self.rotation * Vec3::X + }; + let ang = if positive { + std::f32::consts::FRAC_PI_2 + } else { + -std::f32::consts::FRAC_PI_2 + }; + let delta = Quat::from_axis_angle(axis, ang); + self.rotation = (delta * self.rotation).normalize(); + self.sync_yaw_pitch(); + } + // ── Internal helpers ─────────────────────────────────────────────────── /// Derive yaw and pitch from the current quaternion for the ViewCube diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 5a95628c5..f16dfc7ea 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -12,6 +12,8 @@ use iced::{Color, Element, Length, Theme}; const TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg"); const TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg"); const TRI_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/tri_right.svg"); +const TRI_LEFT: &[u8] = include_bytes!("../../assets/icons/ui/tri_left.svg"); +const HOME: &[u8] = include_bytes!("../../assets/icons/ui/home.svg"); const UNDO: &[u8] = include_bytes!("../../assets/icons/ui/undo.svg"); const REDO: &[u8] = include_bytes!("../../assets/icons/ui/redo.svg"); @@ -166,6 +168,16 @@ pub fn arrow_right<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { icon(TRI_RIGHT, size, color) } +/// Leftward caret. +pub fn arrow_left<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { + icon(TRI_LEFT, size, color) +} + +/// House glyph — the ViewCube "home view" button. +pub fn home<'a, M: 'a>(size: f32, color: Color) -> Element<'a, M> { + icon(HOME, size, color) +} + /// Caret that flips up/down with `open`. pub fn arrow_toggle<'a, M: 'a>(open: bool, size: f32, color: Color) -> Element<'a, M> { if open { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index fb283fb97..505c690a6 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -223,13 +223,13 @@ impl canvas::Program for SelectionCanvas { } if self.show_viewcube { if let Some(pos) = cursor.position_in(bounds) { - use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; - let vc_x = bounds.width - VIEWCUBE_DRAW_PX - VIEWCUBE_PAD; + use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; + let vc_x = bounds.width - VIEWCUBE_REGION_PX - VIEWCUBE_PAD; let vc_y = VIEWCUBE_PAD; if pos.x >= vc_x - && pos.x <= vc_x + VIEWCUBE_DRAW_PX + && pos.x <= vc_x + VIEWCUBE_REGION_PX && pos.y >= vc_y - && pos.y <= vc_y + VIEWCUBE_DRAW_PX + && pos.y <= vc_y + VIEWCUBE_REGION_PX { return mouse::Interaction::None; } @@ -757,14 +757,14 @@ impl canvas::Program for SelectionCanvas { // ── CAD crosshair cursor ────────────────────────────────────────────── let over_viewcube = self.show_viewcube && { - use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; + use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; cursor.position_in(bounds).map_or(false, |pos| { - let vc_x = bounds.width - VIEWCUBE_DRAW_PX - VIEWCUBE_PAD; + let vc_x = bounds.width - VIEWCUBE_REGION_PX - VIEWCUBE_PAD; let vc_y = VIEWCUBE_PAD; pos.x >= vc_x - && pos.x <= vc_x + VIEWCUBE_DRAW_PX + && pos.x <= vc_x + VIEWCUBE_REGION_PX && pos.y >= vc_y - && pos.y <= vc_y + VIEWCUBE_DRAW_PX + && pos.y <= vc_y + VIEWCUBE_REGION_PX }) }; // Over a Model-tile divider the OS cursor switches to a resize