forked from 1b-agent/opencadstudio-upstream
feat(ucs): interactive UCS icon with origin-snap grips, world-fixed compass
The UCS icon now anchors to the projected UCS origin when it is on-screen (falling back to the bottom-left corner), and becomes a selectable object: hover highlights it, a click shows draggable grips, and dragging the origin grip relocates the UCS while the X/Y tip grips rotate it. Drags snap to geometry/grid through the existing snapper, and the new UCS is persisted on release. The icon stays selectable while parked in the corner. Also make the ViewCube compass (N/E/S/W) world-fixed: the cube body still reorients with the active UCS, but the cardinals project through a camera-only rotation and snap in world frame, so the directions no longer spin with the UCS. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
a502379048
commit
edcf24e324
10 files changed
+525
-41
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TileDrag>,
|
||||
/// 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<UcsGripKind>,
|
||||
/// `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<QSelectState>,
|
||||
/// 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,
|
||||
|
||||
@@ -402,6 +402,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+250
-6
@@ -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<crate::app::UcsGripKind> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
// 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<Message> {
|
||||
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<crate::ui::overlay::UcsIconHit> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
// 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<Message> {
|
||||
self.snap_view_region(region, glam::Mat4::IDENTITY)
|
||||
}
|
||||
|
||||
fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
|
||||
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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<usize>,
|
||||
@@ -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<u32>) {
|
||||
|
||||
@@ -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<usize>,
|
||||
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,
|
||||
|
||||
+177
-28
@@ -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<Point>,
|
||||
/// 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<Message> 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<Point>,
|
||||
) -> 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<Point>,
|
||||
) -> Option<UcsIconHit> {
|
||||
// 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<Point>,
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user