diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index ca3c1acd8..867a7b207 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -85,8 +85,11 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "UCSICON"); let visible = sub != "OFF"; let at_origin = sub == "ORIGIN"; - // Update model-space icon flag. + // Update model-space icon flags. self.show_ucs_icon = visible; + if sub == "NOORIGIN" || sub == "ORIGIN" { + self.ucs_icon_at_origin = at_origin; + } let mut count = 0usize; for entity in self.tabs[i].scene.document.entities_mut() { if let acadrust::EntityType::Viewport(vp) = entity { diff --git a/src/app/document.rs b/src/app/document.rs index e18ed9b7a..b5997039a 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -176,6 +176,16 @@ impl DocumentTab { super::helpers::UcsXform::from_active(self.active_ucs.as_ref()) } + /// World-space UCS origin in full f64 precision (stored f64 in the header / + /// viewport). Used to anchor the UCS icon and as the cursor-pick plane point + /// — `UcsXform`'s origin is f32 and would quantize the anchor at UTM scale. + pub(super) fn ucs_origin_world(&self) -> glam::DVec3 { + self.active_ucs + .as_ref() + .map(|u| glam::dvec3(u.origin.x, u.origin.y, u.origin.z)) + .unwrap_or(glam::DVec3::ZERO) + } + /// True when the active pane edits **model-space** geometry: the Model tab, /// or inside a floating viewport (MSPACE). The UCS applies in both; plain /// paper space (no active viewport) is excluded. Single predicate so every diff --git a/src/app/mod.rs b/src/app/mod.rs index 6deee19ec..d72407375 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -39,6 +39,17 @@ pub struct TileDrag { pub last_applied: f32, } +/// Which UCS-icon grip is being dragged. `Origin` slides the UCS origin within +/// its own plane; `XAxis`/`YAxis` rotate the UCS so that axis points at the +/// cursor (the other in-plane axis follows, Z fixed). The cursor is mapped onto +/// the UCS plane every move, so no extra drag-start state is needed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UcsGripKind { + Origin, + XAxis, + YAxis, +} + /// Cursor dwelling over a grip. When `started.elapsed() >=` the popup /// threshold the multi-functional menu opens (`grip_popup`). #[derive(Clone, Debug)] @@ -258,6 +269,13 @@ pub(super) struct OpenCADStudio { /// edge, updated on move, cleared on release (which also runs the /// collapse pass). tile_drag: Option, + /// Cursor is hovering over the UCS icon body — drives the hover highlight. + ucs_icon_hover: bool, + /// UCS icon is selected (clicked): its grips are shown and draggable. + ucs_icon_selected: bool, + /// Active direct-drag of a UCS icon grip (origin slide or axis rotate). + /// Set on press over a grip, updated on move, committed on release. + ucs_grip_drag: Option, /// `true` once the user has reshaped the dynamic-input field set via /// the `,` separator during the current command iteration. Tells /// `sync_dyn_fields` to preserve the user's chosen shape instead of @@ -302,6 +320,9 @@ pub(super) struct OpenCADStudio { qselect: Option, /// Show the UCS icon in the bottom-left corner of model space (UCSICON). show_ucs_icon: bool, + /// Anchor the UCS icon to the projected UCS origin when it is on-screen, + /// falling back to the corner otherwise (UCSICON ORigin / NOorigin). + ucs_icon_at_origin: bool, /// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE). show_viewcube: bool, /// Whether the Properties panel is shown on the left (PROPERTIES). @@ -1123,6 +1144,9 @@ pub enum Message { ViewportScroll(mouse::ScrollDelta), ViewportExit, ViewCubeSnap(CubeRegion), + /// World-frame view snap from a compass cardinal (N/E/S/W), bypassing the + /// UCS so the compass stays world-aligned. + ViewCubeSnapWorld(CubeRegion), /// ViewCube home button → jump to the default isometric view. ViewCubeHome, /// ViewCube roll arrow → roll the view 90° (true = clockwise). @@ -1783,6 +1807,9 @@ impl OpenCADStudio { default_paper_bg_color: None, awaiting_vports: false, tile_drag: None, + ucs_icon_hover: false, + ucs_icon_selected: false, + ucs_grip_drag: None, dyn_user_reshaped: false, grip_hover: None, grip_popup: None, @@ -1794,6 +1821,7 @@ impl OpenCADStudio { grip_original: None, qselect: None, show_ucs_icon: true, + ucs_icon_at_origin: true, show_viewcube: true, show_properties: true, show_file_tabs: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 6ae408647..e5fd19c55 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -402,6 +402,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.text_inline_cancel(); return self.post_editor_closed(false); } + // UCS icon: Esc ends any grip drag and clears the selection + // (only when no command owns Escape). + if self.tabs[self.active_tab].active_cmd.is_none() + && (self.ucs_grip_drag.is_some() || self.ucs_icon_selected) + { + let i = self.active_tab; + self.ucs_grip_drag = None; + self.ucs_icon_selected = false; + self.ucs_icon_hover = false; + self.tabs[i].snap_result = None; + return Task::none(); + } // Leave interactive PAN mode (and end any in-flight pan drag). if self.tabs[self.active_tab].pan_mode { let i = self.active_tab; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 30c6b8664..5fcb3a893 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1079,6 +1079,7 @@ impl OpenCADStudio { } Message::ViewCubeSnap(region) => self.on_view_cube_snap(region), + Message::ViewCubeSnapWorld(region) => self.on_view_cube_snap_world(region), Message::ViewCubeHome => { let i = self.active_tab; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ad59cf0d7..946d5ea68 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -20,6 +20,54 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; +/// Pixel radius for grabbing a UCS-icon grip (origin dot or an axis tip). +const UCS_GRIP_HIT_PX: f32 = 9.0; +/// Pixel reach for hovering/clicking the icon body (origin, tips, or an arm). +const UCS_ICON_PICK_PX: f32 = 10.0; +/// Pixel half-width for clicking an icon arm (line segment). +const UCS_ICON_ARM_PX: f32 = 6.0; + +fn pt_pt_d2(a: Point, b: Point) -> f32 { + (a.x - b.x).powi(2) + (a.y - b.y).powi(2) +} + +/// Squared distance from `p` to the segment `a`–`b`. +fn pt_seg_d2(p: Point, a: Point, b: Point) -> f32 { + let (vx, vy) = (b.x - a.x, b.y - a.y); + let len2 = vx * vx + vy * vy; + let t = if len2 > 1e-6 { + (((p.x - a.x) * vx + (p.y - a.y) * vy) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + pt_pt_d2(p, Point::new(a.x + t * vx, a.y + t * vy)) +} + +/// Which UCS grip (if any) the cursor `p` is on. +fn ucs_grip_under(p: Point, h: &crate::ui::overlay::UcsIconHit) -> Option { + let r2 = UCS_GRIP_HIT_PX * UCS_GRIP_HIT_PX; + if pt_pt_d2(p, h.origin) <= r2 { + Some(crate::app::UcsGripKind::Origin) + } else if pt_pt_d2(p, h.tips[0]) <= r2 { + Some(crate::app::UcsGripKind::XAxis) + } else if pt_pt_d2(p, h.tips[1]) <= r2 { + Some(crate::app::UcsGripKind::YAxis) + } else { + None + } +} + +/// True when `p` is over the icon body (origin, a tip, or an arm) — the pick +/// region for hover-highlight and select. +fn over_ucs_icon(p: Point, h: &crate::ui::overlay::UcsIconHit) -> bool { + let pick2 = UCS_ICON_PICK_PX * UCS_ICON_PICK_PX; + if pt_pt_d2(p, h.origin) <= pick2 || h.tips.iter().any(|t| pt_pt_d2(p, *t) <= pick2) { + return true; + } + let arm2 = UCS_ICON_ARM_PX * UCS_ICON_ARM_PX; + h.tips.iter().any(|t| pt_seg_d2(p, h.origin, *t) <= arm2) +} + impl OpenCADStudio { /// Write PDSIZE from the dialog buffer with the current relative/absolute @@ -460,6 +508,14 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { return Task::none(); } + // UCS icon grip drag: map the cursor onto the UCS plane and + // slide the origin / rotate the axis. Short-circuits pan & snap. + if let Some(kind) = self.ucs_grip_drag { + self.drag_ucs_grip(i, kind, p); + self.tabs[i].scene.selection.borrow_mut().last_move_pos = Some(p); + return Task::none(); + } + // Keep the ViewCube hover in sync as the cursor leaves the // hit-area overlay and moves over the rest of the viewport. // `hover_id` returns None outside the cube box, which clears @@ -488,6 +544,13 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { // popup menu. See scene::model::object::GripMenuItem. self.update_grip_hover(i, p); + // UCS icon hover highlight (suppressed mid grip-drag). + self.ucs_icon_hover = self.ucs_grip_drag.is_none() + && self + .ucs_icon_hit_info(i, svw, svh) + .map(|h| over_ucs_icon(p, &h)) + .unwrap_or(false); + let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.last_move_pos = Some(p); @@ -1203,6 +1266,138 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { Task::none() } + /// Screen positions of the UCS-icon grips (origin + axis tips, absolute px) + /// for the active pane, or `None` when the icon is not shown / not anchored + /// at its on-screen origin / a command is active. The single source of truth + /// for both hover and grip hit-testing, so they match what is drawn. + fn ucs_icon_hit_info(&self, i: usize, vw: f32, vh: f32) -> Option { + if !self.show_ucs_icon || self.tabs[i].active_cmd.is_some() { + return None; + } + let tab = &self.tabs[i]; + let (_, ux, uy, uz) = tab.ucs_xform().axes(); + // Project through whichever pane owns the icon — a floating viewport's + // own camera, else the active model tile. Bare paper space shows none. + let (cam, bounds) = if let Some((c, full)) = tab.scene.viewport_edit_frame((vw, vh)) { + (c, full) + } else if tab.scene.current_layout == "Model" { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + } else { + return None; + }; + // Mirror the draw: anchor at the projected origin only when ORigin mode + // is on AND the origin is on-screen; otherwise `None` parks it in the + // corner — still selectable/draggable there. + let os = if self.ucs_icon_at_origin { + cam.project(tab.ucs_origin_world(), bounds) + .map(|q| Point::new(bounds.x + q.x, bounds.y + q.y)) + } else { + None + }; + crate::ui::overlay::ucs_icon_hit(cam.view_proj_rte(bounds), bounds, (ux, uy, uz), os) + } + + /// Apply one frame of a UCS-icon grip drag: map the cursor onto the active + /// UCS plane (so the move stays in-plane) and either slide the origin there + /// or rotate the chosen axis to point at it, keeping a right-handed frame + /// with Z fixed. Live — the commit (persist) happens on release. + fn drag_ucs_grip(&mut self, i: usize, kind: crate::app::UcsGripKind, p_full: Point) { + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + if vw <= 1.0 || vh <= 1.0 { + return; + } + // Same pane framing as the rest of the move handler: pane-local cursor + // and origin-zero bounds, with the floating-viewport camera when inside + // one. `cursor_model_point` handles the paper→model round-trip. + let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); + let tile_b = match &edit_frame { + Some((_, full)) => *full, + None => self.tabs[i].scene.active_model_tile_bounds(vw, vh), + }; + let edit_cam = edit_frame.map(|(cam, _)| cam); + let p = Point::new(p_full.x - tile_b.x, p_full.y - tile_b.y); + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: tile_b.width, + height: tile_b.height, + }; + let raw = self.cursor_model_point(i, &edit_cam, p, bounds); + + // Object/grid snap, same path as an entity grip or command drag: the + // dragged UCS point sticks to endpoints/midpoints/grid under the cursor, + // and the snap marker is published via `snap_result`. + let (view_rot, eye) = match &edit_cam { + Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + } + }; + let all_wires = if let (Some(_), Some(h)) = + (&edit_cam, self.tabs[i].scene.active_viewport) + { + self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height) + } else { + self.tabs[i].scene.hit_test_wires() + }; + self.snapper.grid_spacing = crate::ui::overlay::compute_grid_step(view_rot, bounds); + // No rubber-band origin (perp/extension feet don't apply to a free drag). + self.snapper.from_point = None; + let (go, gr) = self.tabs[i].ucs_grid_basis(); + let snap_hit = self + .snapper + .snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr); + let world = snap_hit.map(|s| s.world).unwrap_or(raw); + self.tabs[i].snap_result = snap_hit; + if let Some(s) = self.tabs[i].snap_result.as_mut() { + // Snap marker is pane-local; lift it to absolute canvas px. + s.screen.x += tile_b.x; + s.screen.y += tile_b.y; + } + + use acadrust::types::Vector3; + let v3 = |d: glam::DVec3| Vector3::new(d.x, d.y, d.z); + { + let ucs = self.tabs[i] + .active_ucs + .get_or_insert_with(|| acadrust::tables::Ucs::new("*ACTIVE*")); + match kind { + crate::app::UcsGripKind::Origin => { + ucs.origin = v3(world); + } + crate::app::UcsGripKind::XAxis | crate::app::UcsGripKind::YAxis => { + let o = glam::dvec3(ucs.origin.x, ucs.origin.y, ucs.origin.z); + let x = glam::dvec3(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z); + let y = glam::dvec3(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z); + let z = x.cross(y).normalize_or(glam::DVec3::Z); + // Direction from origin to cursor, flattened into the UCS + // plane; bail on a degenerate (cursor on the origin). + let dir = world - o; + let d = dir - z * dir.dot(z); + if d.length_squared() < 1e-12 { + return; + } + let d = d.normalize(); + // Z fixed; the dragged axis = d, the other = right-handed + // completion (z×x = y, y×z = x). + let (nx, ny) = match kind { + crate::app::UcsGripKind::XAxis => (d, z.cross(d)), + _ => (d.cross(z), d), + }; + ucs.x_axis = v3(nx); + ucs.y_axis = v3(ny); + } + } + } + self.tabs[i].sync_ucs_to_scene(); + self.tabs[i].dirty = true; + self.tabs[i].scene.camera_generation += 1; + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; // A click in the viewport dismisses any open ribbon dropdown @@ -1278,6 +1473,30 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } } + // UCS icon: click to select (grips appear), then drag a grip to + // move/rotate. A click off the icon clears the selection. Works + // in the corner too (parked icon), not just at the origin. + if self.show_ucs_icon && self.tabs[i].active_cmd.is_none() { + if let Some(hit) = self.ucs_icon_hit_info(i, vw, vh) { + // Already selected → a press on a grip starts the drag. + if self.ucs_icon_selected { + if let Some(kind) = ucs_grip_under(p, &hit) { + self.ucs_grip_drag = Some(kind); + return Task::none(); + } + } + // Press on the icon body selects it (and shows grips). + if over_ucs_icon(p, &hit) { + self.ucs_icon_selected = true; + self.ucs_icon_hover = true; + return Task::none(); + } + } + // Press elsewhere drops the selection, then falls through to + // the normal pick / box-select so the click still lands. + self.ucs_icon_selected = false; + } + // Tiled Model layout: an inner divider near the cursor // becomes a resize grip. Start the drag and short-circuit // (no pick / select / camera swap). Released on @@ -1438,6 +1657,19 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { return Task::none(); } + // Commit a UCS icon grip drag: persist the new UCS so it + // round-trips, and clear the lingering press state. + if self.ucs_grip_drag.take().is_some() { + self.tabs[i].persist_active_ucs(); + self.tabs[i].snap_result = None; + self.snapper.from_point = None; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.left_down = false; + sel.left_press_pos = None; + sel.left_dragging = false; + return Task::none(); + } + let (p, is_click, is_down) = { let sel = self.tabs[i].scene.selection.borrow(); let p = match sel.last_move_pos { @@ -2435,19 +2667,31 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { 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())); + // Compass cardinals are world-fixed: hit-test through the camera- + // only rotation (strip the UCS) so the target matches the drawn + // N/E/S/W, and snap in world frame. + let rot_world = rot * self.tabs[i].scene.viewcube_ucs_mat().inverse(); + if let Some(card) = scene::hit_test_cardinal(cx, cy, w, h, rot_world, VIEWCUBE_PX) { + return Task::done(Message::ViewCubeSnapWorld(card.face_region())); } Task::none() } pub(super) fn on_view_cube_snap(&mut self, region: CubeRegion) -> Task { + // The cube is oriented in the active UCS, so snap in the UCS frame. + let r_ucs = self.tabs[self.active_tab].scene.viewcube_ucs_mat(); + self.snap_view_region(region, r_ucs) + } + + /// Compass cardinals are world-fixed, so they snap in the world frame + /// (no UCS composition). + pub(super) fn on_view_cube_snap_world(&mut self, region: CubeRegion) -> Task { + self.snap_view_region(region, glam::Mat4::IDENTITY) + } + + fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task { let i = self.active_tab; let mut region = region; - // The cube is oriented in the active UCS, so its face directions - // are UCS-frame — rotate them into world before comparing / - // snapping (identity outside model space). - let r_ucs = self.tabs[i].scene.viewcube_ucs_mat(); // "Already there → flip to opposite" check: compare the // current gaze direction with the region's target gaze. let target_dir = r_ucs.transform_vector3(region.snap_direction()); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bdcfc7b8a..7be801a8b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -215,18 +215,33 @@ impl OpenCADStudio { None } else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { let (_, ux, uy, uz) = tab.ucs_xform().axes(); + let origin_screen = self.ucs_icon_at_origin.then(|| { + vp_cam + .project(tab.ucs_origin_world(), full) + .map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) + }).flatten(); Some(crate::ui::overlay::UcsIconParams { view_proj: vp_cam.view_proj_rte(full), bounds: full, axes: (ux, uy, uz), + origin_screen, + hover: self.ucs_icon_hover, + selected: self.ucs_icon_selected, }) } else if !is_paper { let cam = tab.scene.camera.borrow(); let (_, ux, uy, uz) = tab.ucs_xform().axes(); + let origin_screen = self.ucs_icon_at_origin.then(|| { + cam.project(tab.ucs_origin_world(), vp_bounds) + .map(|p| iced::Point::new(vp_bounds.x + p.x, vp_bounds.y + p.y)) + }).flatten(); Some(crate::ui::overlay::UcsIconParams { view_proj: cam.view_proj_rte(vp_bounds), bounds: vp_bounds, axes: (ux, uy, uz), + origin_screen, + hover: self.ucs_icon_hover, + selected: self.ucs_icon_selected, }) } else { None diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index 45403e8cf..2f5b83164 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -570,6 +570,7 @@ impl ViewCubeText { &mut self, queue: &wgpu::Queue, cam_rotation: Mat4, + compass_rotation: Mat4, vp_w: u32, vp_h: u32, cube_px: u32, @@ -677,6 +678,21 @@ impl ViewCubeText { // 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. + // + // The compass is world-fixed: it projects through `compass_rotation` + // (camera only, no UCS), so N/E/S/W stay aligned to world even when the + // cube reorients with the active UCS. + let project_world = |local: Vec3| -> Option<[f32; 2]> { + let world = compass_rotation.transform_point3(local); + let clip = view_proj * Vec4::new(world.x, world.y, world.z, 1.0); + if clip.w.abs() < 1e-6 { + return None; + } + Some([ + (clip.x / clip.w + 1.0) * 0.5 * vw, + (1.0 - clip.y / clip.w) * 0.5 * vh, + ]) + }; const CARD_GW: f32 = 0.16; // glyph size in cube-local units const CARD_GH: f32 = 0.22; let cardinals = [ @@ -691,7 +707,7 @@ impl ViewCubeText { }; 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 { + let alpha = if compass_rotation.transform_point3(center).z >= -0.15 { 1.0 } else { 0.5 @@ -699,10 +715,10 @@ impl ViewCubeText { 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)); + let tl = project_world(corner(-CARD_GW * 0.5, CARD_GH * 0.5)); + let tr = project_world(corner(CARD_GW * 0.5, CARD_GH * 0.5)); + let br = project_world(corner(CARD_GW * 0.5, -CARD_GH * 0.5)); + let bl = project_world(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])); @@ -1210,6 +1226,7 @@ impl ViewCubePipeline { &mut self, queue: &wgpu::Queue, cam_rotation: Mat4, + compass_rotation: Mat4, vp_w: u32, vp_h: u32, hover: Option, @@ -1226,7 +1243,7 @@ impl ViewCubePipeline { )), ); self.text - .update(queue, cam_rotation, vp_w, vp_h, self.cube_px); + .update(queue, cam_rotation, compass_rotation, vp_w, vp_h, self.cube_px); } pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size) { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index dc2dc04ae..e57245bcd 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -53,6 +53,9 @@ pub struct ViewportData { /// Camera rotation matrix derived from the quaternion. /// Used by the ViewCube pipeline — no gimbal lock. pub(in crate::scene) cam_rotation: Mat4, + /// Camera-only rotation (no UCS) for the world-fixed compass cardinals, so + /// N/E/S/W stay aligned to world even as the cube reorients with the UCS. + pub(in crate::scene) compass_rotation: Mat4, pub(in crate::scene) hover_region: Option, pub(in crate::scene) show_viewcube: bool, /// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill @@ -323,6 +326,7 @@ impl shader::Primitive for Primitive { inner.viewcube.upload( queue, vp.cam_rotation, + vp.compass_rotation, (vp.screen_rect.width * bounds.width) as u32, (vp.screen_rect.height * bounds.height) as u32, vp.hover_region, @@ -808,6 +812,7 @@ impl Scene { meshes: self.meshes_arc(), uniforms, cam_rotation: inst.camera.view_rotation_mat() * self.viewcube_ucs_mat(), + compass_rotation: inst.camera.view_rotation_mat(), // Only the active viewport gets the hovered-region highlight. hover_region: if inst.active { hover_region } else { None }, show_viewcube: inst.active, diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 2e1332e1e..bef7ad78d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -109,6 +109,14 @@ pub struct UcsIconParams { /// The active UCS axis directions in world space (X, Y, Z). Plain WCS is /// `(Vec3::X, Vec3::Y, Vec3::Z)`; a UCS rotates the tripod to match. pub axes: (Vec3, Vec3, Vec3), + /// Absolute screen position of the UCS origin, when the icon should track + /// it (UCSICON ORigin). `None` → pin to the corner. The tripod still snaps + /// back to the corner if this point falls outside the viewport bounds. + pub origin_screen: Option, + /// Cursor is over the icon — brighten the tripod (hover affordance). + pub hover: bool, + /// Icon is selected — draw draggable grip squares at the origin and tips. + pub selected: bool, } // ── Selection overlay ─────────────────────────────────────────────────── @@ -843,7 +851,15 @@ impl canvas::Program for SelectionCanvas { // ── UCS icon ────────────────────────────────────────────────────── if let Some(ref ucs) = self.ucs_icon { - draw_ucs_icon(&mut frame, ucs.view_proj, ucs.bounds, ucs.axes); + draw_ucs_icon( + &mut frame, + ucs.view_proj, + ucs.bounds, + ucs.axes, + ucs.origin_screen, + ucs.hover, + ucs.selected, + ); } // ── Object Snap Tracking lines ──────────────────────────────────── @@ -1086,15 +1102,37 @@ fn draw_axes( const UCS_ICON_MARGIN: f32 = 50.0; const UCS_ICON_LEN: f32 = 38.0; // longest axis arm in screen pixels const UCS_ICON_TIP: f32 = 7.0; // arrowhead size in pixels +const UCS_GRIP_BOX: f32 = 7.0; // selected-grip square size in pixels -fn draw_ucs_icon( - frame: &mut canvas::Frame, +/// One projected UCS axis arm: scaled screen delta from the anchor plus its +/// depth (for back-to-front draw order). +struct IconAxis { + dx: f32, + dy: f32, + sc_len: f32, + depth: f32, +} + +/// Screen positions of the UCS icon grips, present only when the tripod is +/// anchored at the on-screen UCS origin (i.e. draggable). `tips` is X, Y, Z. +pub struct UcsIconHit { + pub origin: Point, + pub tips: [Point; 3], +} + +/// Shared icon geometry used by both the renderer and the grip hit-test, so the +/// two never drift. Projects the UCS axis directions to screen, picks the +/// anchor (on-screen origin when available, else the corner) and returns each +/// axis's scaled screen delta. The bool is `at_origin` — true when anchored at +/// the projected origin, which is the only state where grips are live. +fn ucs_icon_geometry( vp: Mat4, bounds: iced::Rectangle, axes: (Vec3, Vec3, Vec3), -) { + origin_screen: Option, +) -> Option<(Point, bool, [IconAxis; 3])> { if bounds.width < 10.0 || bounds.height < 10.0 { - return; + return None; } // Project to NDC (including depth) then to screen pixels. @@ -1116,16 +1154,31 @@ fn draw_ucs_icon( // rotates to the active UCS. Directions are translation-invariant, so a // common origin is fine. let (ax, ay, az) = axes; - let Some(org) = w2ndc(Vec3::ZERO) else { return }; - let Some(xn) = w2ndc(ax) else { return }; - let Some(yn) = w2ndc(ay) else { return }; - let Some(zn) = w2ndc(az) else { return }; + let org = w2ndc(Vec3::ZERO)?; + let xn = w2ndc(ax)?; + let yn = w2ndc(ay)?; + let zn = w2ndc(az)?; let org_s = ndc2s(org); - let icon_origin = Point::new( + let corner = Point::new( bounds.x + UCS_ICON_MARGIN, bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN), ); + // Snap the tripod to the projected UCS origin when it is on-screen + // (UCSICON ORigin); otherwise keep it in the corner. `org_s` (the + // rotation-only origin) stays the reference for axis-tip directions — + // those are translation-invariant, so only the anchor point moves. + let (icon_origin, at_origin) = match origin_screen { + Some(p) + if p.x >= bounds.x + && p.x <= bounds.x + bounds.width + && p.y >= bounds.y + && p.y <= bounds.y + bounds.height => + { + (p, true) + } + _ => (corner, false), + }; // Raw screen-space displacement for each axis tip. let raw = |ndc_tip: Vec3| -> (f32, f32, f32) { @@ -1144,8 +1197,66 @@ fn draw_ucs_icon( let max_len = xlen.max(ylen).max(zlen).max(1e-4); let sc = UCS_ICON_LEN / max_len; - // depth > 0 → tip is farther from viewer than origin (axis going into screen). - // depth < 0 → tip is closer (axis coming toward viewer). + let mk = |dx: f32, dy: f32, len: f32, tip_z: f32| IconAxis { + dx: dx * sc, + dy: dy * sc, + sc_len: len * sc, + // depth > 0 → tip farther than origin (into screen); < 0 → toward viewer. + depth: tip_z - org.z, + }; + Some(( + icon_origin, + at_origin, + [ + mk(xdx, xdy, xlen, xn.z), + mk(ydx, ydy, ylen, yn.z), + mk(zdx, zdy, zlen, zn.z), + ], + )) +} + +/// Screen grip targets for the UCS icon, or `None` when it is pinned to the +/// corner (origin off-screen) and therefore not draggable. +pub fn ucs_icon_hit( + vp: Mat4, + bounds: iced::Rectangle, + axes: (Vec3, Vec3, Vec3), + origin_screen: Option, +) -> Option { + // Grips are available wherever the icon is drawn — including parked in the + // corner (origin off-screen), where dragging the origin grip relocates it. + let (o, _at_origin, g) = ucs_icon_geometry(vp, bounds, axes, origin_screen)?; + Some(UcsIconHit { + origin: o, + tips: [ + Point::new(o.x + g[0].dx, o.y + g[0].dy), + Point::new(o.x + g[1].dx, o.y + g[1].dy), + Point::new(o.x + g[2].dx, o.y + g[2].dy), + ], + }) +} + +#[allow(clippy::too_many_arguments)] +fn draw_ucs_icon( + frame: &mut canvas::Frame, + vp: Mat4, + bounds: iced::Rectangle, + axes: (Vec3, Vec3, Vec3), + origin_screen: Option, + hover: bool, + selected: bool, +) { + let Some((icon_origin, at_origin, geom)) = + ucs_icon_geometry(vp, bounds, axes, origin_screen) + else { + return; + }; + + // The icon is interactive wherever it is drawn (including parked in the + // corner), so hover/selection highlight applies there too. + let _ = at_origin; + let highlight = hover || selected; + struct AxisInfo { dx: f32, dy: f32, @@ -1158,30 +1269,30 @@ fn draw_ucs_icon( } let mut axes = [ AxisInfo { - dx: xdx * sc, - dy: xdy * sc, - sc_len: xlen * sc, - depth: xn.z - org.z, + dx: geom[0].dx, + dy: geom[0].dy, + sc_len: geom[0].sc_len, + depth: geom[0].depth, r: 0.90, g: 0.22, b: 0.22, label: "X", }, AxisInfo { - dx: ydx * sc, - dy: ydy * sc, - sc_len: ylen * sc, - depth: yn.z - org.z, + dx: geom[1].dx, + dy: geom[1].dy, + sc_len: geom[1].sc_len, + depth: geom[1].depth, r: 0.22, g: 0.85, b: 0.22, label: "Y", }, AxisInfo { - dx: zdx * sc, - dy: zdy * sc, - sc_len: zlen * sc, - depth: zn.z - org.z, + dx: geom[2].dx, + dy: geom[2].dy, + sc_len: geom[2].sc_len, + depth: geom[2].depth, r: 0.22, g: 0.45, b: 0.90, @@ -1196,10 +1307,13 @@ fn draw_ucs_icon( }); for ax in &axes { + // On highlight, lerp the axis colour toward white and thicken the shaft + // so the whole tripod reads as "live". + let mix = if highlight { 0.45 } else { 0.0 }; let col = Color { - r: ax.r, - g: ax.g, - b: ax.b, + r: ax.r + (1.0 - ax.r) * mix, + g: ax.g + (1.0 - ax.g) * mix, + b: ax.b + (1.0 - ax.b) * mix, a: 1.0, }; let tip = Point::new(icon_origin.x + ax.dx, icon_origin.y + ax.dy); @@ -1213,7 +1327,7 @@ fn draw_ucs_icon( frame.stroke( &path, canvas::Stroke { - width: 2.0, + width: if highlight { 3.0 } else { 2.0 }, style: canvas::Style::Solid(col), line_cap: canvas::LineCap::Butt, ..Default::default() @@ -1278,6 +1392,41 @@ fn draw_ucs_icon( a: 0.95, }, ); + + // Draggable grips when selected: a square at the origin and at the X / Y + // tips. Warm grip colour with a light border, like an entity grip. + if selected { + let x_tip = Point::new(icon_origin.x + geom[0].dx, icon_origin.y + geom[0].dy); + let y_tip = Point::new(icon_origin.x + geom[1].dx, icon_origin.y + geom[1].dy); + let fill = Color { + r: 0.20, + g: 0.85, + b: 0.95, + a: 1.0, + }; + let border = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 0.9, + }; + for c in [icon_origin, x_tip, y_tip] { + let h = UCS_GRIP_BOX / 2.0; + let sq = canvas::Path::rectangle( + Point::new(c.x - h, c.y - h), + iced::Size::new(UCS_GRIP_BOX, UCS_GRIP_BOX), + ); + frame.fill(&sq, fill); + frame.stroke( + &sq, + canvas::Stroke { + width: 1.0, + style: canvas::Style::Solid(border), + ..Default::default() + }, + ); + } + } } // ── Dynamic Input overlay ─────────────────────────────────────────────────