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