feat(viewcube): add compass ring, cardinals, and nav controls
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 <noreply@anthropic.com>
This commit is contained in:
parent
6c4b981424
commit
f00c48bebb
9 changed files with 633 additions and 40 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
261
src/app/view.rs
261
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<String> = 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<String> = 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<String>) -> 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue