Files
OneCAD-Draft/src/app/update/viewport.rs
T
Hakan SevenandClaude Opus 4.8 57710eec2b fix(precision): keep typed coordinates exact in f64 (#311)
Typed values were quantized to the f32 grid before reaching the
entity (e.g. a length of 2000.8 committed as 2000.8020), worsening
with magnitude. The interaction point layer was all glam::Vec3 (f32).

Two-layer fix:

1. Coordinate backbone -> f64. last_point, last_cursor_world,
   dyn_anchor, dyn_ref become DVec3; UcsXform transforms, parse_coord,
   the ucs helper fns, dyn_resolve_point, the OTRACK typed-distance
   paths, command-line coordinate entry and the coordinate readout all
   compute in f64. Screen/GPU/alignment stays f32 (snap engine, render
   geometry, projection, UCS-icon/viewcube drawing) with casts only at
   those boundaries.

2. Per-command committed storage -> DVec3. on_point already received a
   DVec3, but many command impls narrowed it back to f32 (let pt =
   pt.as_vec3(); Vec<Vec3> fields) and re-widened at build, quantizing
   the committed coordinate. Migrated PLINE, SPLINE, MLINE, LENGTHEN,
   INSERT, 3D primitives, every dimension/leader/mleader, ray, revcloud,
   wipeout, attdef, table, tolerance, array center, mview, plot_window,
   plus the QDIM_PLACE handler and qdim_collect_points. Preview,
   hit-test and rubber-band paths keep f32 at the boundary.

Hatch manual boundaries are left for a follow-up: HatchModel.boundary
is structurally Arc<Vec<[f32;2]>> and needs a model-level widen.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 21:26:40 +03:00

3099 lines
156 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `viewport` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
#![allow(unused_imports)]
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply;
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor;
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
/// sign. A relative size is stored negative; absolute positive. Switching to
/// absolute with an empty/zero size seeds a positive value from the current
/// on-screen size so the point stays representable (PDSIZE 0 always reads as
/// relative, so absolute needs a non-zero magnitude).
pub(in crate::app) fn apply_point_size(&mut self) {
let i = self.active_tab;
let mut mag = self
.point_size_buf
.trim()
.parse::<f64>()
.unwrap_or(0.0)
.abs();
if !self.point_size_relative && mag == 0.0 {
let wpp = self.tabs[i].scene.world_per_pixel().unwrap_or(0.0);
mag = if wpp > 0.0 {
crate::entities::point::relative_world_size(0.0, wpp)
} else {
1.0
};
self.point_size_buf = format!("{mag:.4}");
}
let next = if self.point_size_relative { -mag } else { mag };
self.push_undo_snapshot(i, "PDSIZE");
self.tabs[i].scene.document.header.point_display_size = next;
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
/// Replace the `mask` bits of PDMODE with `value`, rebuild the point glyphs
/// and mark the document dirty. Used by the Point Style (DDPTYPE) dialog.
pub(in crate::app) fn set_point_mode_bits(&mut self, mask: i16, value: i16) {
let i = self.active_tab;
let cur = self.tabs[i].scene.document.header.point_display_mode;
let next = (cur & !mask) | (value & mask);
if next == cur {
return;
}
self.push_undo_snapshot(i, "PDMODE");
self.tabs[i].scene.document.header.point_display_mode = next;
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
}
/// Mirror the live grid display + grid-snap toggles onto tab `i`'s active
/// model tile so a save writes them to that viewport's VPort entry (#121).
pub(in crate::app) fn sync_vport_display(&mut self, i: usize) {
let grid_on = self.show_grid;
let snap_on = self.snapper.grid_snap();
self.tabs[i].scene.set_active_tile_grid_snap(grid_on, snap_on);
}
/// Adopt the active viewport's grid *display* into the live toggle. Called
/// on load and whenever the active tab or viewport changes, so the grid
/// drawing follows the active viewport.
///
/// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays
/// off by default everywhere and is controlled solely by the user's snap
/// toggle, so it never silently leaks into object-snap when entering a
/// viewport whose stored `snap_on` flag happens to be set.
pub(in crate::app) fn adopt_view_display(&mut self, i: usize) {
if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() {
self.show_grid = grid_on;
}
}
pub(in crate::app) fn sync_render_mode_to_active_tile(&mut self, i: usize) {
use acadrust::entities::ViewportRenderMode as M;
if self.tabs[i].scene.current_layout != "Model" {
return;
}
let mode = self.tabs[i].scene.active_model_tile_render_mode();
if self.tabs[i].render_mode == mode {
return;
}
let label = match mode {
M::Wireframe2D => "Wireframe 2D",
M::Wireframe3D => "Wireframe 3D",
M::HiddenLine => "Hidden Line",
M::FlatShaded => "Flat Shaded",
M::GouraudShaded => "Gouraud Shaded",
M::FlatShadedWithEdges => "Flat Shaded + Edges",
M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
};
self.tabs[i].render_mode = mode;
let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D);
self.tabs[i].wireframe = wf;
self.ribbon.set_wireframe(wf);
self.tabs[i].visual_style = label.into();
self.tabs[i].scene.bump_geometry();
}
/// Project a pane-local cursor onto the active drawing plane and return a
/// **model-space** point. Inside a floating viewport `edit_cam` is the
/// viewport's own camera (a target-plane / UCS pick there already yields
/// model coords); otherwise the paper camera projects onto the sheet and
/// the result is mapped paper→model. Used by the readout, snap and click
/// paths so all three agree on the cursor's model location.
pub(in crate::app) fn cursor_model_point(
&self,
i: usize,
edit_cam: &Option<crate::scene::view::camera::Camera>,
p: iced::Point,
bounds: iced::Rectangle,
) -> glam::DVec3 {
// Constrain to the UCS plane only where the UCS applies (model space or
// inside a viewport); plain paper space uses the target plane.
let plane = self
.tabs[i]
.active_ucs
.as_ref()
.filter(|_| self.tabs[i].editing_model_space())
.map(|ucs| {
(
ucs_z_axis(ucs),
glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z),
)
});
let pick = |cam: &crate::scene::view::camera::Camera| match plane {
Some((normal, origin)) => cam.pick_on_plane(p, bounds, normal.as_vec3(), origin),
None => cam.pick_on_target_plane(p, bounds),
};
match edit_cam {
Some(cam) => pick(cam),
None => {
let paper = {
let c = self.tabs[i].scene.camera.borrow();
pick(&c)
};
self.tabs[i].scene.paper_to_model(paper)
}
}
}
/// Projection + hit-test wires for the active pane. Inside a floating
/// viewport (`edit_cam` Some) it returns the viewport camera and the live
/// **model** wires, so wire / hatch picking lands on the entity under the
/// cursor exactly where the GPU draws it; otherwise the model/paper camera
/// and the normal hit-test wires. `bounds` is the pane-local rectangle.
pub(in crate::app) fn pick_view(
&self,
i: usize,
edit_cam: &Option<crate::scene::view::camera::Camera>,
bounds: iced::Rectangle,
) -> (glam::Mat4, glam::DVec3, std::sync::Arc<Vec<crate::scene::WireModel>>) {
match edit_cam {
Some(cam) => {
let wires = match self.tabs[i].scene.active_viewport {
Some(h) => self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height),
None => self.tabs[i].scene.hit_test_wires(),
};
(cam.view_proj_rte(bounds), cam.eye(), wires)
}
None => {
let (view_rot, eye) = {
let c = self.tabs[i].scene.camera.borrow();
(c.view_proj_rte(bounds), c.eye())
};
(view_rot, eye, self.tabs[i].scene.hit_test_wires())
}
}
}
pub(in crate::app) fn update_grip_hover(&mut self, i: usize, p: iced::Point) {
const HOVER_OPEN_MS: u128 = 600;
const POPUP_DISMISS_PX: f32 = 80.0;
if self.tabs[i].active_cmd.is_some()
|| self.tabs[i].active_grip.is_some()
|| self.tabs[i].selected_grips.is_empty()
{
self.grip_hover = None;
self.grip_popup = None;
return;
}
let Some(handle) = self.tabs[i].selected_handle else {
self.grip_hover = None;
self.grip_popup = None;
return;
};
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let is_paper = self.tabs[i].scene.current_layout != "Model";
// In-viewport grips are model-space — project with the viewport camera
// at the viewport's own rect; the cursor is mapped into that rect.
let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh));
let hit = if let Some((cam, full)) = &edit_frame {
let local = iced::Rectangle {
x: 0.0,
y: 0.0,
width: full.width,
height: full.height,
};
let p_local = iced::Point::new(p.x - full.x, p.y - full.y);
find_hit_grip_rte(
p_local,
&self.tabs[i].selected_grips,
cam.view_proj_rte(local),
cam.eye(),
local,
)
} else if is_paper {
let cam = self.tabs[i].scene.camera.borrow();
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x as f32;
let ty = cam.target.y as f32;
drop(cam);
find_hit_grip_paper(
p,
&self.tabs[i].selected_grips,
tx,
ty,
half_w,
half_h,
bounds,
)
} else {
let cam = self.tabs[i].scene.camera.borrow();
find_hit_grip(p, &self.tabs[i].selected_grips, &cam, bounds)
};
match hit {
Some((grip_id, _, _)) => {
let same = self
.grip_hover
.as_ref()
.map_or(false, |h| h.handle == handle && h.grip_id == grip_id);
if !same {
self.grip_hover = Some(crate::app::GripHover {
handle,
grip_id,
screen: p,
started: iced::time::Instant::now(),
});
self.grip_popup = None;
} else if let Some(h) = self.grip_hover.as_mut() {
h.screen = p;
}
// Open popup once dwell crosses the threshold. The visibility
// grip has its own click-to-open dropdown, so it gets no
// hover grip-menu.
if self.grip_popup.is_none()
&& grip_id != crate::app::visibility::VIS_GRIP_ID
&& self
.grip_hover
.as_ref()
.map_or(false, |h| h.started.elapsed().as_millis() >= HOVER_OPEN_MS)
{
let entity_opt = self.tabs[i].scene.document.get_entity(handle);
if let Some(e) = entity_opt {
use crate::entities::traits::EntityTypeOps;
let items = e.grip_menu(grip_id);
if !items.is_empty() {
self.grip_popup = Some(crate::app::GripPopup {
handle,
grip_id,
anchor: p,
items,
selected: 0,
});
}
}
}
}
None => {
self.grip_hover = None;
if let Some(popup) = &self.grip_popup {
let dx = p.x - popup.anchor.x;
let dy = p.y - popup.anchor.y;
if (dx * dx + dy * dy).sqrt() > POPUP_DISMISS_PX {
self.grip_popup = None;
}
}
}
}
}
pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].scene.update(t - self.start);
// If the camera moved since we last synced, write it back to
// the document and mark the file dirty.
let gen = self.tabs[i].scene.camera_generation;
if gen != self.tabs[i].last_synced_camera_gen {
self.tabs[i].last_synced_camera_gen = gen;
if self.tabs[i].scene.sync_camera_to_document() {
self.tabs[i].dirty = true;
}
}
// Surface any plugin-guard panic messages that piled up since
// the last tick (the host singleton isn't reachable from inside
// the plugin hooks, so they queue and we flush here). (#145)
#[cfg(not(target_arch = "wasm32"))]
for msg in crate::plugin::drain_errors() {
self.command_line.push_error(&msg);
}
Task::none()
}
pub(super) fn on_set_render_mode(&mut self, mode: acadrust::entities::ViewportRenderMode) -> Task<Message> {
use acadrust::entities::ViewportRenderMode as M;
let i = self.active_tab;
let label = match mode {
M::Wireframe2D => "Wireframe 2D",
M::Wireframe3D => "Wireframe 3D",
M::HiddenLine => "Hidden Line",
M::FlatShaded => "Flat Shaded",
M::GouraudShaded => "Gouraud Shaded",
M::FlatShadedWithEdges => "Flat Shaded + Edges",
M::GouraudShadedWithEdges => "Gouraud Shaded + Edges",
};
// In a paper layout with an active (double-clicked)
// viewport, the picker drives that viewport entity's own
// render mode; the model-layout tab style is untouched.
if self.tabs[i].scene.set_active_viewport_render_mode(mode) {
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("Viewport visual style: {label}"));
return Task::none();
}
self.tabs[i].render_mode = mode;
// Write the style onto the active Model tile alone so it
// sticks when that tile loses focus and the other tiles keep
// their own styles.
self.tabs[i].scene.set_active_model_tile_render_mode(mode);
// Keep the legacy `wireframe` bool synced — both wireframe
// modes set it, everything else clears it.
let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D);
self.tabs[i].wireframe = wf;
self.ribbon.set_wireframe(wf);
self.tabs[i].visual_style = label.into();
// Re-upload face3d fills on the next frame — the render
// pipeline keys its upload cache off `geometry_epoch`.
self.tabs[i].scene.bump_geometry();
self.command_line
.push_output(&format!("Visual style: {label}"));
Task::none()
}
pub(super) fn on_cursor_moved(&mut self, p: Point) -> Task<Message> {
// `p` is relative to the ViewCube hit area's top-left. Map
// it back to full-canvas coordinates so ViewportClick's
// hit-test lines up. The hit area sits in the top-right of
// the full canvas in model space, or of the active
// viewport's screen rectangle in a paper layout.
let i = self.active_tab;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let (ox, oy) = match self.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, vh)))
{
Some(rect) => (
rect.x + rect.width - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE,
rect.y + VIEWCUBE_PAD,
),
None => {
// Model layout: the cube sits in the active tile's
// top-right corner.
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(
tb.x + tb.width - VIEWCUBE_PAD - VIEWCUBE_HIT_SIZE,
tb.y + VIEWCUBE_PAD,
)
}
};
self.cursor_pos = iced::Point::new(ox + p.x, oy + p.y);
// Drive the ViewCube hover highlight directly from this
// message — it fires whenever the cube's hit-area overlay
// sees motion, so we don't depend on the shader widget's
// `Program::update` receiving the same event (overlays sit
// above the shader and can mask it). Map the cursor into
// the active viewport's local box and use that box's size,
// since that's where the cube is actually drawn.
let tile = match self.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (vw, vh)))
{
Some(rect) => rect,
None => self.tabs[i].scene.active_model_tile_bounds(vw, vh),
};
let cam_rot = self.tabs[i].scene.active_view_rotation_mat();
let hover = hover_id(
self.cursor_pos.x - tile.x,
self.cursor_pos.y - tile.y,
tile.width,
tile.height,
cam_rot,
VIEWCUBE_PX,
);
self.tabs[i].scene.viewcube_hover.set(hover);
Task::none()
}
pub(super) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
// A ribbon dropdown is open over the viewport. Its backdrop
// cannot swallow cursor motion — in iced 0.14 mouse_area/opaque
// capture only button presses, never CursorMoved — so the move
// leaks through the stack to the pane mouse_area beneath and
// would track the crosshair over the dropdown's empty areas.
// Drop the move here instead. (#227)
if self.ribbon.open_dropdown.is_some() {
return Task::none();
}
let i = self.active_tab;
// 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
// any stale highlight from the previous `CursorMoved`.
let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size;
let cube_tile = match self.tabs[i]
.scene
.active_viewport
.and_then(|h| self.tabs[i].scene.viewport_screen_rect(h, (svw, svh)))
{
Some(rect) => rect,
None => self.tabs[i].scene.active_model_tile_bounds(svw, svh),
};
let cam_rot = self.tabs[i].scene.active_view_rotation_mat();
self.tabs[i].scene.viewcube_hover.set(hover_id(
p.x - cube_tile.x,
p.y - cube_tile.y,
cube_tile.width,
cube_tile.height,
cam_rot,
VIEWCUBE_PX,
));
// Multi-functional grip hover: detect cursor sitting on a
// selected entity's grip and, after a dwell, open the
// 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);
if sel.left_down {
let press = sel.left_press_pos.unwrap_or(p);
let dx = p.x - press.x;
let dy = p.y - press.y;
let dist2 = dx * dx + dy * dy;
let elapsed_ms = sel
.left_press_time
.map(|t| Instant::now().duration_since(t).as_millis())
.unwrap_or(u128::MAX);
if !sel.left_dragging && elapsed_ms >= POLY_START_DELAY_MS && dist2 > 9.0 {
sel.left_dragging = true;
sel.poly_active = true;
sel.poly_crossing = p.x < press.x;
sel.poly_points.clear();
sel.poly_points.push(press);
sel.poly_points.push(p);
} else if sel.left_dragging && sel.poly_active {
if sel.poly_points.last().map_or(true, |lp| {
let ddx = p.x - lp.x;
let ddy = p.y - lp.y;
ddx * ddx + ddy * ddy > 16.0
}) {
sel.poly_points.push(p);
}
}
} else if sel.box_anchor.is_some() {
sel.box_current = Some(p);
if let Some(a) = sel.box_anchor {
sel.box_crossing = p.x < a.x;
}
}
let (mid_down, mid_last, vp_size) =
(sel.middle_down, sel.middle_last_pos, sel.vp_size);
if mid_down {
if let Some(last) = mid_last {
let (dx, dy) = (p.x - last.x, p.y - last.y);
// Shift+MMB drag orbits the model view instead of panning
// — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB
// scheme (#229). Floating viewports and paper keep the
// plain MMB pan.
if self.shift_down {
if self.tabs[i].scene.active_viewport.is_some() {
// Orbit the floating viewport's own model view.
drop(sel);
self.tabs[i].scene.orbit_active_viewport(dx, dy);
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].scene.selection.borrow_mut().middle_last_pos =
Some(p);
return Task::none();
} else if self.tabs[i].scene.current_layout == "Model" {
if sel.orbit_pivot.is_none() {
// Selection centre when something is selected,
// otherwise the point under the cursor. (#229)
sel.orbit_pivot = self.tabs[i]
.scene
.orbit_pivot()
.or(Some(self.tabs[i].last_cursor_world));
}
let pivot = sel.orbit_pivot;
drop(sel);
self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy, pivot);
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].scene.selection.borrow_mut().middle_last_pos =
Some(p);
return Task::none();
}
// Paper sheet is top-locked: Shift+MMB falls through
// to the plain pan below.
}
// Pan scale uses the active tile's size (ortho size
// is relative to viewport height), so a tiled pane
// pans at the correct rate.
let bounds = self.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
// Drop `sel` before calling mutable scene methods.
drop(sel);
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, dy, bounds);
// Bump so the GPU re-uploads the viewport's re-culled
// wire set — otherwise newly-revealed lines stay
// invisible until MSPACE is exited.
self.tabs[i].scene.camera_generation += 1;
} else {
// `bounds` is the active tile; pan by its height so
// the point under the cursor tracks correctly.
self.tabs[i].scene.camera.borrow_mut().pan_screen(
dx,
dy,
bounds.height,
);
self.tabs[i].scene.camera_generation += 1;
// Keep an in-progress box selection pinned to the
// drawing as the view pans under it (#234).
self.reproject_box_anchor(i, vp_size.0, vp_size.1);
}
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
return Task::none();
}
sel.middle_last_pos = Some(p);
}
let dragging = sel.left_down || sel.right_down || sel.middle_down;
let vp_size = sel.vp_size;
drop(sel);
// The active pane already follows the cursor (each pane's own
// mouse_area focuses it via `PaneMove` → `focus_model_pane`), so
// the camera + tile bounds used for picking below are already the
// pane the cursor is in.
// Tile-relative picking: shadow `p` with the cursor mapped
// into the active Model tile and `vp_size` with the tile's
// size, so every pick / snap / view_proj below operates in
// the active pane. `p_full` keeps the canvas-space cursor
// for screen overlays (cursor marker, snap glyph).
let p_full = p;
// Inside a floating viewport (MSPACE) the active "pane" is the
// viewport's own screen rectangle and its own camera — the very
// camera the GPU draws the content with. Routing picking / snap
// / projection through it makes in-viewport editing behave like
// the main model view (model coords, no paper round-trip) and
// track the viewport's pan / zoom / twist exactly.
let edit_frame = self.tabs[i].scene.viewport_edit_frame(vp_size);
let tile_b = match &edit_frame {
Some((_, full)) => *full,
None => self.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1),
};
let edit_cam = edit_frame.map(|(cam, _)| cam);
let p = iced::Point {
x: p_full.x - tile_b.x,
y: p_full.y - tile_b.y,
};
let vp_size = (tile_b.width, tile_b.height);
// ── Grip drag ─────────────────────────────────────────────
if let Some(grip) = self.tabs[i].active_grip.clone() {
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
// In a viewport, pick the cursor's model point with the
// viewport camera directly; otherwise project on the paper
// sheet and map to model. Either way `raw` is model space.
let raw = match &edit_cam {
Some(cam) => cam.pick_on_target_plane(p, bounds),
None => {
let paper = self
.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(p, bounds);
self.tabs[i].scene.paper_to_model(paper)
}
};
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())
}
};
// First move of this drag: hide the edited entity from the
// base tessellation (one re-tess) so subsequent moves only
// refresh a cheap overlay preview instead of re-tessellating
// the whole model on every move.
if self.grip_preview_handle != Some(grip.handle) {
if let Some(prev) = self.grip_preview_handle.take() {
self.tabs[i].scene.hidden.remove(&prev);
}
// Back up the original geometry so Esc can cancel the drag.
self.grip_original =
self.tabs[i].scene.document.get_entity(grip.handle).cloned();
self.tabs[i].scene.hidden.insert(grip.handle);
// Grip drag never changes a block definition — keep the
// block cache so the hide doesn't re-tessellate blocks.
self.tabs[i].scene.bump_geometry_no_blocks();
self.grip_preview_handle = Some(grip.handle);
// Snapshot the entity's glyph quads once so each move can
// slide the already-shaped text rather than re-shaping it
// (issue #316). The fast slide path only runs for a rigid
// whole-entity move of pure text: no wire geometry (so a
// dimension re-tessellates) and a Square insertion grip (so
// an MTEXT width handle, a Triangle, still re-tessellates so
// the re-wrap is exact).
let snap = self.tabs[i].scene.wire_models_for(&[grip.handle]);
self.grip_text_verts =
snap.iter().flat_map(|w| w.text_verts.iter().copied()).collect();
let square_grip = self.tabs[i]
.selected_grips
.iter()
.find(|g| g.id == grip.grip_id)
.map(|g| g.shape == crate::scene::model::object::GripShape::Square)
.unwrap_or(false);
self.grip_text_slide = !self.grip_text_verts.is_empty()
&& snap.iter().all(|w| w.points.is_empty())
&& square_grip;
}
// The edited entity is hidden, so it's already absent from
// `hit_test_wires` — snap against the set directly, no clone
// and no self-snap.
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_near(raw, view_rot, bounds)
};
// Grip drag has no single rubber-band origin for a perp foot.
self.snapper.from_point = None;
let (go, gr) = self.tabs[i].ucs_grid_basis();
// `raw` is already model space (viewport camera or paper→model),
// and the wires are model space, so the snap result is model.
let snap_hit = self
.snapper
.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr);
let mut snapped = 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() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
}
if snap_hit.is_none() {
let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
snapped = ortho_constrain(snapped, base, &ucs_xf);
} else if self.polar_mode {
snapped = polar_constrain_near(
snapped,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
);
}
}
let apply = if grip.is_translate {
GripApply::Translate(snapped - grip.last_world)
} else {
GripApply::Absolute(snapped)
};
self.tabs[i]
.scene
.apply_grip(grip.handle, grip.grip_id, apply);
self.tabs[i].dirty = true;
self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped;
// Overlay the moved entity (hidden from the base). Pure text
// moved as a whole slides its drag-start glyphs — no re-shaping
// and no wire re-tess. Anything else (wire geometry, or a point
// grip that reshapes text) is re-tessellated for an exact
// preview; either way the glyphs ride the preview-text buffer
// so dragged text never vanishes mid-drag (issue #316).
if self.grip_text_slide {
let d = snapped - grip.origin_world;
let slid = crate::scene::pipeline::text_gpu::translate_verts(
&self.grip_text_verts,
[d.x, d.y, d.z],
);
self.tabs[i].scene.set_preview_text(slid);
self.tabs[i].scene.set_preview_wires(Vec::new());
} else {
// Wire geometry, or a reshape grip: re-tessellate. The
// preview WireModels carry the entity's glyphs (gathered
// for the preview-text buffer in the render path), so a
// dimension / MTEXT-width drag keeps its text visible too.
let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]);
self.tabs[i].scene.set_preview_wires(preview);
}
self.refresh_selected_grips();
self.refresh_properties();
return Task::none();
}
// Keep the coordinate readout live on every move, even with no
// active command. When a command is running the snap path below
// overwrites this with the snapped point.
{
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
let world = self.cursor_model_point(i, &edit_cam, p, bounds);
self.tabs[i].last_cursor_world = world;
}
// Rollover highlight: when idle (no active command, no
// drag), defer the pick until the cursor stops. The full
// pick (wires + hatches + block hatches + shaded meshes) is
// O(N) per frame and stalls the cursor on large drawings,
// so each move clears the current highlight and resets the
// dwell timer — `HoverDwellTick` runs the hit-test only
// once the cursor has been still for `HOVER_DWELL_MS`.
if !dragging && self.tabs[i].active_cmd.is_none() {
self.tabs[i].scene.set_hover_highlight(None);
self.hover_dwell = Some(crate::app::HoverDwell {
last_move_at: Instant::now(),
point: p,
tile_size: vp_size,
tab: i,
});
} else {
// Suppress the rollover during a command or a drag.
self.tabs[i].scene.set_hover_highlight(None);
self.hover_dwell = None;
}
if self.tabs[i].active_cmd.is_some() {
let (vw, vh) = vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
// Inside a floating viewport the cursor, camera and wires are
// all model-space (the viewport's own camera draws the
// content), so snap / hit-test / preview run exactly like the
// main model view — no paper projection, tracks pan/zoom/twist.
let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds);
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())
}
};
// Sync grid-snap spacing to the adaptive spacing of the visible grid.
self.snapper.grid_spacing =
crate::ui::overlay::compute_grid_step(view_rot, bounds);
// Cursor and wires are model-space; the snap result is model.
let snap_cursor = cursor_world;
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_near(snap_cursor, view_rot, bounds)
};
let needs_entity = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
let needs_structure = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_structure_point_pick())
.unwrap_or(false);
let is_gathering = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.is_selection_gathering())
.unwrap_or(false);
// A selection-window corner (STRETCH crossing window) is a
// free point — Ortho/Polar must not pin it to an axis through
// the first corner, or the rectangle collapses to a line
// (#291).
let is_window_corner = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.window_corner_pick())
.unwrap_or(false);
let needs_tan = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false);
self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure {
None
} else if needs_tan {
self.snapper.snap_tangent_only(
snap_cursor.as_vec3(),
p,
&all_wires[..],
view_rot,
eye,
bounds,
)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
// The snapper is a screen-space (f32) engine; the f64
// base only matters for typed-input precision, so hand it
// the downcast point here.
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
self.snapper
.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
};
// Object Snap Tracking: update dwell, then align the cursor
// to a tracking ray (and store the alignment so a typed
// distance can place a point along it — issue #69).
let otrack_hit = {
let snap_world = self.tabs[i].snap_result.map(|s| s.world.as_vec3());
self.snapper.update_otrack_dwell(
snap_world,
&all_wires[..],
view_rot,
eye,
bounds,
Instant::now(),
);
// Parallel snap: acquire the reference line under the
// cursor (independent of OTRACK). (#277)
self.snapper.update_parallel(
cursor_world.as_vec3(),
&all_wires[..],
view_rot,
eye,
bounds,
Instant::now(),
);
if self.tabs[i].snap_result.is_none() {
// A window corner is a free point, so neither the
// Polar increment nor the Ortho lock may pull the
// OTRACK alignment onto an axis (#291).
let step = if self.polar_mode && !is_window_corner {
Some(self.polar_increment_deg)
} else {
None
};
let ucs = self.tabs[i].scene.viewcube_ucs_mat();
self.snapper.otrack_snap(
cursor_world.as_vec3(),
view_rot,
eye,
bounds,
step,
self.last_point.map(|p| p.as_vec3()),
self.ortho_mode && !is_window_corner,
ucs,
)
} else {
None
}
};
self.otrack_active = otrack_hit.map(|h| (h.base, h.dir));
// Parallel snap: with nothing else snapped or tracked, lock
// the point onto the line through last_point parallel to the
// acquired reference, and drive the alignment guide off it.
// (#277)
if self.tabs[i].snap_result.is_none() && otrack_hit.is_none() {
if let Some(par) = self.snapper.parallel_snap(
cursor_world.as_vec3(),
self.last_point.map(|p| p.as_vec3()),
view_rot,
eye,
bounds,
) {
if let (Some(base), Some((dir, _))) =
(self.last_point, self.snapper.parallel_ref)
{
self.otrack_active = Some((base.as_vec3(), dir));
}
self.tabs[i].snap_result = Some(par);
}
}
let effective = {
let mut pt: glam::DVec3 = if let Some(h) = otrack_hit {
// Tracking alignment wins over the free cursor;
// ortho/polar don't re-constrain it.
h.aligned.as_dvec3()
} else {
// Snap runs in model space (viewport camera or the
// model/paper view), so the result is already model.
let mut pt = self.tabs[i]
.snap_result
.map(|s| s.world)
.unwrap_or(cursor_world);
// Object snap wins over ortho/polar: a snapped point
// is taken as-is so it isn't pulled onto the ortho
// axis. Grid snap is positional, not an object snap,
// so it still combines with ortho/polar. (#132)
let osnap_locked = self.tabs[i]
.snap_result
.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid);
if !osnap_locked && !is_window_corner {
if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
pt = ortho_constrain(pt, base, &ucs_xf);
} else if self.polar_mode {
pt = polar_constrain_near(
pt,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
);
}
}
}
pt
};
// Clamp to world XY only when no UCS is active; with a
// UCS the point already lies on the UCS XY plane.
if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
pt
};
self.tabs[i].last_cursor_world = effective;
self.tabs[i].last_cursor_screen = p_full;
// Project the step anchor (an explicit `dyn_anchor` or the
// last point) so the dynamic-input overlay can place its
// guide geometry and labels.
// `proj` returns a pane-local pixel; shift by the active pane
// origin (`tile_b`, the viewport rect inside a viewport) so
// the DYN guide/labels share the canvas frame with
// `last_cursor_screen` (= p_full, canvas space).
let proj = |bp: glam::DVec3| {
let ndc = view_rot.project_point3((bp - eye).as_vec3());
iced::Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width + tile_b.x,
(1.0 - ndc.y) * 0.5 * bounds.height + tile_b.y,
)
};
// Anchors are stored in model coords and `proj` (view_rot /
// eye) is the model→screen view — the viewport camera inside
// a viewport, the model/paper camera otherwise — so feed them
// straight through; no paper mapping needed.
let anchor = self.tabs[i].dyn_anchor.or(self.last_point);
let dyn_ref = self.tabs[i].dyn_ref;
let lps = anchor.map(|a| proj(a));
let drs = dyn_ref.map(|r| proj(r));
self.tabs[i].last_point_screen = lps;
self.tabs[i].dyn_ref_screen = drs;
// Point-picked selection window (STRETCH): draw a filled
// crossing marquee from the first corner to the cursor so it
// reads like a normal box selection, not a bare outline (#291).
let window_first = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|c| c.window_first_corner());
self.tabs[i].scene.selection.borrow_mut().preview_box =
if is_window_corner {
window_first.map(|c1| (proj(c1), p_full, true))
} else {
None
};
// Entity-pick previews (TRIM/EXTEND/FILLET…) compare the
// cursor against WCS document entities and return WCS wires.
// `effective` is offset-relative, so build a WCS copy for
// the click and shift the returned wires back to the
// offset-relative frame the renderer expects (model space
// only; paper-space entities use sheet coordinates).
let wo_local = if self.tabs[i].scene.current_layout == "Model" {
[0.0_f64; 3]
} else {
[0.0; 3]
};
let wo_f = glam::DVec3::new(wo_local[0], wo_local[1], wo_local[2]);
let effective_wcs = effective + wo_f;
// Orange object snap (plugin commands implement resolve_object_pick).
if needs_structure {
use crate::snap::{SnapResult, SnapType};
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
c.resolve_object_pick(
&self.tabs[i].scene,
effective.x as f64,
effective.y as f64,
)
});
if let Some(pick) = pick {
let world = glam::DVec3::new(pick.x, pick.y, effective.z);
let ndc = view_rot.project_point3((world - eye).as_vec3());
let screen = iced::Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
self.tabs[i].snap_result = Some(SnapResult {
world,
screen,
snap_type: SnapType::ObjectPick,
tangent_obj: None,
extension_base: None,
extension_base2: None,
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label));
}
} else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(None);
}
}
// Snap glyph is positioned in canvas space; shift the
// tile-local snap screen point back to the full canvas.
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
}
// Give the command the current UCS + Ctrl state before it
// builds its rubber-band preview (and, by persistence, its
// commit).
self.push_ucs_to_cmd(i);
self.push_ctrl_to_cmd(i);
let mut previews = if needs_structure {
let mut p = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective))
.unwrap_or_default();
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
p.extend(cmd.on_preview_wires(effective));
}
p
} else if needs_entity {
let hover_handle =
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.unwrap_or(acadrust::Handle::NULL);
let mut p = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_hover_entity(hover_handle, effective_wcs))
.unwrap_or_default();
// on_hover_entity returns WCS wires; shift to the
// offset-relative frame so the preview lands on the
// geometry on large-coordinate drawings.
if wo_f != glam::DVec3::ZERO {
for w in p.iter_mut() {
for pt in w.points.iter_mut() {
pt[0] -= wo_f.x as f32;
pt[1] -= wo_f.y as f32;
pt[2] -= wo_f.z as f32;
}
}
}
if !hover_handle.is_null() {
if let Some(cmd) = self.tabs[i].active_cmd.as_ref() {
p.extend(cmd.entity_pick_acquire_previews(
&self.tabs[i].scene,
hover_handle,
));
}
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
if let Some(hint) = cmd.entity_pick_acquire_hint(hover_handle) {
cmd.set_acquisition_hint(Some(hint));
}
}
}
p
} else {
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(effective))
.unwrap_or_default()
};
// Polar tracking guide line: dotted line from last_point along
// the snapped angle direction, extending across the drawing.
if self.polar_mode && !needs_entity {
if let Some(base) = self.last_point {
// Screen-space dotted guide (render geometry is f32).
let base = base.as_vec3();
let effective = effective.as_vec3();
let dx = effective.x - base.x;
let dy = effective.y - base.y;
// Only show the guide while POLAR is actually engaged
// — i.e. the point is snapped onto a polar ray, not
// floating free near no angle (issue #70).
let step = self.polar_increment_deg.to_radians();
let angle = dy.atan2(dx);
let snapped =
step > 1e-6 && ((angle / step).round() * step - angle).abs() < 1e-3;
if snapped && (dx * dx + dy * dy).sqrt() > 1e-4 {
let far = 1e5_f32;
let dir = glam::Vec3::new(dx, dy, 0.0).normalize();
let far_pos = base + dir * far;
let far_neg = base - dir * far;
let guide = crate::scene::WireModel {
text_verts: Vec::new(),
name: "__polar_guide__".into(),
points: vec![
[far_neg.x, far_neg.y, far_neg.z],
[far_pos.x, far_pos.y, far_pos.z],
],
points_low: Vec::new(),
color: [0.2, 0.7, 0.9, 0.6],
selected: false,
aci: 0,
pattern_length: 0.8,
pattern: [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: crate::scene::WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
};
previews.push(guide);
}
}
}
// OTRACK alignment guide: dashed ray through the tracking
// point along the aligned direction (issue #69).
if let Some(h) = otrack_hit {
if !needs_entity {
let far = 1e5_f32;
let far_pos = h.base + h.dir * far;
let far_neg = h.base - h.dir * far;
previews.push(crate::scene::WireModel {
text_verts: Vec::new(),
name: "__otrack_guide__".into(),
points: vec![
[far_neg.x, far_neg.y, far_neg.z],
[far_pos.x, far_pos.y, far_pos.z],
],
points_low: Vec::new(),
color: [0.2, 0.9, 0.5, 0.6],
selected: false,
aci: 0,
pattern_length: 0.8,
pattern: [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: crate::scene::WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
}
}
self.tabs[i].scene.set_preview_wires(previews);
} else {
self.tabs[i].snap_result = None;
}
self.sync_dyn_fields();
Task::none()
}
pub(super) fn on_viewport_exit(&mut self) -> Task<Message> {
let i = self.active_tab;
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
sel.right_down = false;
sel.right_press_pos = None;
sel.right_press_time = None;
sel.right_last_pos = None;
sel.right_dragging = false;
sel.right_click_entered = false;
sel.middle_down = false;
sel.middle_last_pos = None;
sel.orbit_pivot = None;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing = false;
sel.poly_active = false;
sel.poly_points.clear();
sel.poly_crossing = false;
drop(sel);
// Clear the rollover highlight when the cursor leaves the
// viewport so it doesn't stick while the mouse is over the
// ribbon / panels.
self.tabs[i].scene.set_hover_highlight(None);
// Don't touch `context_menu` here. ViewportExit also fires
// when an upper overlay (the right-click menu panel) takes
// the cursor, so clearing the menu state on every exit
// would close the menu the moment it opens. Outside-click
// dismiss is handled in `ViewportLeftPress`.
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.as_vec3(), uy.as_vec3(), uz.as_vec3()),
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_near(raw, view_rot, bounds)
};
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;
}
// ── Per-pane Model viewport (pane_grid) ───────────────────────────────
/// Focus Model pane `idx` (cursor entered it): swap in its camera, sync the
/// render-mode / grid display. No-op if already active.
pub(super) fn focus_model_pane(&mut self, idx: usize) {
let i = self.active_tab;
if self.tabs[i].scene.set_active_model_tile(idx) {
self.tabs[i].scene.camera_generation += 1;
self.sync_render_mode_to_active_tile(i);
self.adopt_view_display(i);
}
}
/// Convert a pane-local cursor point (from the pane's `mouse_area`) to the
/// canvas-relative point the viewport handlers expect.
pub(super) fn pane_canvas_point(&self, idx: usize, local: Point) -> Point {
let i = self.active_tab;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let o = self.tabs[i].scene.pane_origin_px(idx, vw, vh);
Point::new(o.x + local.x, o.y + local.y)
}
pub(super) fn on_pane_resized(
&mut self,
ev: iced::widget::pane_grid::ResizeEvent,
) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].scene.model_panes.resize(ev.split, ev.ratio);
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
self.tabs[i].scene.sync_tiles_from_panes(vw, vh);
self.tabs[i].scene.camera_generation += 1;
Task::none()
}
pub(super) fn on_pane_clicked(
&mut self,
pane: iced::widget::pane_grid::Pane,
) -> Task<Message> {
let i = self.active_tab;
if let Some(&idx) = self.tabs[i].scene.model_panes.get(pane) {
self.focus_model_pane(idx);
}
Task::none()
}
pub(super) fn on_pane_dragged(
&mut self,
ev: iced::widget::pane_grid::DragEvent,
) -> Task<Message> {
if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = ev {
let i = self.active_tab;
self.tabs[i].scene.model_panes.drop(pane, target);
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
self.tabs[i].scene.sync_tiles_from_panes(vw, vh);
self.tabs[i].scene.camera_generation += 1;
}
Task::none()
}
pub(super) fn on_viewport_left_press(&mut self) -> Task<Message> {
let i = self.active_tab;
// A left-click during a command resets the right-click cycle, so
// the next right-click acts as Enter again rather than opening
// the context menu.
self.tabs[i].scene.selection.borrow_mut().right_click_entered = false;
// A click in the viewport dismisses any open ribbon dropdown
// (e.g. the annotation style combo), which has no backdrop of
// its own to catch outside clicks.
self.ribbon.close_dropdown();
// Likewise dismiss the Properties color dropdowns — a viewport
// press starts a box selection, so without this they'd only
// close on the second click (issue #104).
self.tabs[i].properties.color_picker_open = false;
self.tabs[i].properties.color_palette_open = false;
// Click anywhere outside the popup dismisses it. The
// menu's own buttons live above this mouse_area, so a
// press that reaches here means the cursor is not on
// any menu item.
if self.grip_popup.take().is_some() {
self.grip_hover = None;
return Task::none();
}
// Outside-click dismiss for the visibility-state dropdown
// (its buttons sit above this mouse_area).
if self.visibility_popup.take().is_some() {
return Task::none();
}
// Same dismiss-on-outside-click for the right-click
// context menu: its panel is opaque, so a press that
// reaches here is outside the menu.
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
if sel.context_menu.take().is_some() {
return Task::none();
}
}
let (p, vp_size) = {
let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos {
Some(p) => p,
None => return Task::none(),
};
(p, sel.vp_size)
};
let (vw, vh) = vp_size;
// PAN mode: a left press begins a pan drag. Reuse the middle-
// button pan path (the move handler pans whenever `middle_down`),
// so no selection/pick logic runs while panning.
if self.tabs[i].pan_mode {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = true;
sel.middle_last_pos = Some(p);
return Task::none();
}
if vw > 1.0 && vh > 1.0 {
let rot = self.tabs[i].scene.active_view_rotation_mat();
// Map the cursor into whichever area owns the cube (active
// viewport rect in paper, active tile in model) so the
// consume-check lines up with the gizmo — same framing as
// ViewportClick's snap hit-test.
let (cx, cy, w, h) = match self.tabs[i]
.scene
.active_viewport
.and_then(|hndl| self.tabs[i].scene.viewport_screen_rect(hndl, (vw, vh)))
{
Some(rect) => (p.x - rect.x, p.y - rect.y, rect.width, rect.height),
None => {
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(p.x - tb.x, p.y - tb.y, tb.width, tb.height)
}
};
if scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX).is_some() {
return Task::none();
}
}
// 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;
}
// Divider resize is handled natively by the input pane_grid
// (`on_resize`); the active pane already follows the cursor via
// `focus_model_pane`. So a press here goes straight to picking.
// From here the click targets the active tile: map the
// cursor into it and use the tile's size for picking, so
// grip / selection hit-tests land in the right pane.
let p_full = p;
// Inside a floating viewport the pane is the viewport's own rect
// + camera (matches the GPU); otherwise the active model tile.
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 = iced::Point {
x: p_full.x - tile_b.x,
y: p_full.y - tile_b.y,
};
let (vw, vh) = (tile_b.width, tile_b.height);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if self.tabs[i].active_cmd.is_none()
&& self.tabs[i].active_grip.is_none()
&& !self.tabs[i].selected_grips.is_empty()
{
if let Some(handle) = self.tabs[i].selected_handle {
let is_paper = self.tabs[i].scene.current_layout != "Model";
// In-viewport grips are model-space; project them with the
// viewport camera so they hit-test where the GPU draws
// them. Paper-space entities use the 2-D paper transform;
// the model tab uses the model camera.
let grip_hit = if let Some(cam) = &edit_cam {
find_hit_grip_rte(
p,
&self.tabs[i].selected_grips,
cam.view_proj_rte(bounds),
cam.eye(),
bounds,
)
} else if is_paper {
let cam = self.tabs[i].scene.camera.borrow();
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x as f32;
let ty = cam.target.y as f32;
drop(cam);
find_hit_grip_paper(
p,
&self.tabs[i].selected_grips,
tx,
ty,
half_w,
half_h,
bounds,
)
} else {
let cam = self.tabs[i].scene.camera.borrow();
find_hit_grip(p, &self.tabs[i].selected_grips, &cam, bounds)
};
if let Some((grip_id, is_translate, world)) = grip_hit {
// The visibility (lookup) grip opens a state
// dropdown instead of starting a stretch drag.
if grip_id == crate::app::visibility::VIS_GRIP_ID {
self.open_visibility_popup(p);
self.grip_hover = None;
self.grip_popup = None;
return Task::none();
}
self.tabs[i].active_grip = Some(GripEdit {
handle,
grip_id,
is_translate,
origin_world: world,
last_world: world,
});
self.grip_hover = None;
self.grip_popup = None;
return Task::none();
}
}
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = true;
// Stored in full-canvas space (like ViewportMove's cursor and
// the overlay box / lasso drawing); release maps it into the
// active tile. Tile-local here would double-offset the anchor.
sel.left_press_pos = Some(p_full);
sel.left_press_time = Some(Instant::now());
sel.left_dragging = false;
Task::none()
}
pub(super) fn on_viewport_left_release(&mut self) -> Task<Message> {
let i = self.active_tab;
// PAN mode: end the pan drag but stay in pan mode for the next
// drag (exit is Esc / another command). Mirror of the press.
if self.tabs[i].pan_mode {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
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 {
Some(p) => p,
None => return Task::none(),
};
(p, !sel.left_dragging, sel.left_down)
};
// Grip editing: click-move-click (plus legacy press-drag).
// The grip engages on press (active_grip set). This release
// commits only if the grip has actually moved, or if it was a
// press-drag. A bare engaging click (no movement yet) keeps the
// grip hot so the user can move the cursor and click again to
// place it. Escape cancels (handled elsewhere).
if let Some(grip) = self.tabs[i].active_grip.clone() {
// Reset mouse state so the lingering press from the engaging
// click doesn't read as an in-progress drag on later moves.
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
}
let moved = grip.last_world != grip.origin_world;
if is_click && !moved {
// Engaging click — stay hot, wait for the placement click.
return Task::none();
}
self.tabs[i].active_grip = None;
// Commit the grip drag: keep the doc's dragged geometry
// (drop the cancel backup), un-hide the edited entity and
// re-tessellate the base once, dropping the overlay preview.
if let Some(h) = self.grip_preview_handle.take() {
self.grip_original = None;
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
self.tabs[i].scene.hidden.remove(&h);
self.tabs[i].scene.clear_preview_wire();
// Only the dragged entity changed — re-tessellate just it.
self.tabs[i].scene.mark_entity_dirty(h);
self.tabs[i].scene.bump_geometry_no_blocks();
}
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
self.refresh_properties();
return Task::none();
}
// Map the release point into the active Model tile so the
// click's pick / on_point / selection use the active pane's
// camera + bounds. `p_full` keeps the canvas point for the
// box/poly selection rectangle (drawn in canvas space).
let p_full = p;
// Inside a floating viewport the pane is the viewport's own rect
// and camera (see the MoveCursor path); pick / snap then run in
// model space exactly where the GPU draws the content.
let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size;
let edit_frame = self.tabs[i].scene.viewport_edit_frame(canvas_sz);
let (tile_vw, tile_vh, tile_off) = match &edit_frame {
Some((_, full)) => (full.width, full.height, iced::Point::new(full.x, full.y)),
None => {
let tb = self
.tabs[i]
.scene
.active_model_tile_bounds(canvas_sz.0, canvas_sz.1);
(tb.width, tb.height, iced::Point::new(tb.x, tb.y))
}
};
let edit_cam = edit_frame.map(|(cam, _)| cam);
let p = iced::Point {
x: p_full.x - tile_off.x,
y: p_full.y - tile_off.y,
};
let is_gathering = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.is_selection_gathering())
.unwrap_or(false);
// A committed window corner must stay free of the Ortho/Polar
// lock too, so the picked rectangle isn't flattened (#291).
let is_window_corner = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.window_corner_pick())
.unwrap_or(false);
if is_down && is_click && self.tabs[i].active_cmd.is_some() && !is_gathering {
let (vw, vh) = (tile_vw, tile_vh);
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let snap_taken = self.tabs[i].snap_result.take();
let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj);
let world_pt = {
// Cursor → model point (viewport camera inside a viewport,
// else paper sheet → model). Model space throughout.
let raw = self.cursor_model_point(i, &edit_cam, p, bounds);
let (view_rot, eye) = match &edit_cam {
Some(cam) => (cam.view_proj_rte(bounds), cam.eye()),
None => {
let c = self.tabs[i].scene.camera.borrow();
(c.view_proj_rte(bounds), c.eye())
}
};
let snap_cursor = raw;
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_near(snap_cursor, view_rot, bounds)
};
let needs_tan = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false);
let needs_entity_click = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
let snap_hit = if needs_entity_click {
None
} else if needs_tan {
self.snapper
.snap_tangent_only(snap_cursor.as_vec3(), p, &all_wires[..], view_rot, eye, bounds)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
};
// Snap runs in model space; the result is already model.
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);
// When no UCS is active clamp to world XY; with a UCS the point is
// already constrained to that plane by the ray–plane intersection.
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
// OTRACK alignment wins over ortho/polar; otherwise apply
// ortho/polar relative to the last point.
let otrack = if snap_hit.is_none() {
let step = if self.polar_mode && !is_window_corner {
Some(self.polar_increment_deg)
} else {
None
};
let ucs = self.tabs[i].scene.viewcube_ucs_mat();
self.snapper
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point.map(|p| p.as_vec3()), self.ortho_mode && !is_window_corner, ucs)
} else {
None
};
if let Some(h) = otrack {
pt = h.aligned.as_dvec3();
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
} else if !is_window_corner
&& !snap_hit
.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid)
{
// Object snap wins over ortho/polar — a snapped point
// commits as-is. Grid snap still combines. (#132)
if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
pt = ortho_constrain(pt, base, &ucs_xf);
} else if self.polar_mode {
pt = polar_constrain_near(
pt,
base,
self.polar_increment_deg,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
);
}
}
}
pt
};
// `world_pt` is in offset-relative (local) space, matching
// the camera and the point-creation commands. Entity-pick /
// tangent / structure-pick commands instead compare the
// click against WCS document entities, so they need the
// world_offset added back (model space only — paper-space
// entities are already in sheet coordinates). Without this,
// TRIM/EXTEND/FILLET pick the wrong side on UTM-scale files.
let pick_wcs = {
let wo = if self.tabs[i].scene.current_layout == "Model" {
[0.0_f64; 3]
} else {
[0.0; 3]
};
world_pt + glam::DVec3::new(wo[0], wo[1], wo[2])
};
let result = if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_structure_point_pick())
.unwrap_or(false)
{
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
c.resolve_object_pick(
&self.tabs[i].scene,
pick_wcs.x as f64,
pick_wcs.y as f64,
)
});
if let Some(pick) = pick {
let center = glam::DVec3::new(pick.x, pick.y, pick_wcs.z);
let result = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_structure_pick(pick.handle, center));
self.command_line
.push_info(&format!("{} acquired.", pick.label));
result
} else {
let msg = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.object_pick_miss_message())
.unwrap_or("No object near click.");
self.command_line.push_error(msg);
None
}
} else if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false)
{
let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds);
let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds)
.and_then(|s| Scene::handle_from_wire_name(s));
if let Some(handle) = hit {
// Some commands (e.g. SS_CATCHMENT) need the entity
// body before `on_entity_pick` can advance.
let inject_first = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.inject_before_entity_pick())
.unwrap_or(false);
if inject_first {
if let Some(entity) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.inject_picked_entity(entity);
}
}
}
let result = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_entity_pick(handle, pick_wcs));
// HATCHEDIT: after pick, inject hatch model data into the command.
if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.name() == "HATCHEDIT")
.unwrap_or(false)
{
if let Some(model) =
self.tabs[i].scene.hatches.get(&handle).cloned()
{
use crate::command::CadCommand;
use crate::modules::draw::draw::hatchedit::HatcheditCommand;
let cmd: Box<dyn CadCommand> =
Box::new(HatcheditCommand::with_handle(
handle,
model.name.clone(),
model.scale,
model.angle_offset,
));
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(cmd);
} else {
self.command_line
.push_error("HATCHEDIT: not a hatch entity.");
self.tabs[i].active_cmd = None;
}
}
// DIMTEDIT / MLEADERADD / MLEADERREMOVE: inject cloned entity via trait.
{
let needs_inject = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| {
matches!(
c.name(),
"DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE"
)
})
.unwrap_or(false);
if needs_inject {
if let Some(entity) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.inject_picked_entity(entity);
let prompt = cmd.prompt();
self.command_line.push_info(&prompt);
}
}
}
}
result
} else {
self.command_line.push_info("Nothing found at that point.");
None
}
} else if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false)
{
if let Some(obj) = tangent_obj_at_click {
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_tangent_point(obj, pick_wcs))
} else {
self.command_line.push_info("Select a tangent object.");
None
}
} else {
// A scalar typed into the dynamic-input box but not
// yet confirmed with Enter is applied before the
// point pick — e.g. an OFFSET distance typed and then
// clicked takes effect rather than being discarded.
let wants_text = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false);
if wants_text {
if let Some(text) = self.tabs[i]
.dyn_fields
.iter()
.find_map(|f| f.buffer.clone())
{
let text = crate::app::expr_eval::eval_to_string(text.trim());
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
c.on_text_input(&text);
}
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
}
self.tabs[i].dyn_active = 0;
}
}
self.last_point = Some(world_pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
// A running Tangent snap may carry a tangent object; a
// command can consume it to resolve a deferred tangent
// (LINE tangent to two circles, which needs both). When
// it does, sync last_point to the command's resolved
// anchor since it replaced the picked coordinate.
let handled = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|c| c.on_point_with_tangent(world_pt, tangent_obj_at_click));
if handled.is_some() {
if let Some(a) = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|c| c.resolved_anchor())
{
self.last_point = Some(a);
}
handled
} else {
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_point(world_pt))
}
};
if let Some(r) = result {
let task = self.apply_cmd_result(r);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
return task;
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
return Task::none();
}
let (is_down2, is_dragging, box_anchor, box_crossing, _vp_size, elapsed_ms) = {
let sel = self.tabs[i].scene.selection.borrow();
let elapsed = sel
.left_press_time
.map(|t| Instant::now().duration_since(t).as_millis())
.unwrap_or(u128::MAX);
(
sel.left_down,
sel.left_dragging,
sel.box_anchor,
sel.box_crossing,
sel.vp_size,
elapsed,
)
};
let mut selection_just_completed = false;
// Active-tile-local selection: tile-sized bounds and the box
// anchor mapped into the tile, so box / crossing selection
// matches the active pane (p is already tile-local).
let vp_size = (tile_vw, tile_vh);
let box_anchor = box_anchor.map(|a| iced::Point {
x: a.x - tile_off.x,
y: a.y - tile_off.y,
});
if is_down2 {
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
if is_dragging {
if elapsed_ms < POLY_START_DELAY_MS {
if let Some(a) = box_anchor {
let crossing = box_crossing;
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a,
p,
crossing,
&all_wires[..],
view_rot,
eye,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
handles.extend(scene::pick::hit_test::box_hit_hatch(
a,
p,
crossing,
&self.tabs[i].scene.visible_hatches_for_click(),
view_rot,
eye,
bounds,
));
handles.extend(
self.tabs[i].scene.mesh_box_hit(a, p, crossing, view_rot, eye, bounds),
);
handles.extend(self.tabs[i].scene.block_mesh_box_hit(
a, p, crossing, view_rot, eye, bounds,
));
// Objects on a locked layer aren't selectable.
handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h));
// Box/lasso accumulates like individual picks
// (issue #83): a plain box adds to the current
// selection, Shift+box removes the boxed
// entities. Esc / empty-space click still clears.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
self.refresh_properties();
selection_just_completed = true;
}
} else {
let (poly_pts, crossing) = {
let sel = self.tabs[i].scene.selection.borrow();
// Map lasso points into the active tile.
let pts: Vec<iced::Point> = sel
.poly_points
.iter()
.map(|pp| iced::Point {
x: pp.x - tile_off.x,
y: pp.y - tile_off.y,
})
.collect();
(pts, sel.poly_crossing)
};
self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing;
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let mut handles: Vec<Handle> = scene::pick::hit_test::poly_hit(
&poly_pts,
crossing,
&all_wires[..],
view_rot,
eye,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
handles.extend(scene::pick::hit_test::poly_hit_hatch(
&poly_pts,
crossing,
&self.tabs[i].scene.visible_hatches_for_click(),
view_rot,
eye,
bounds,
));
handles.extend(
self.tabs[i].scene.mesh_poly_hit(&poly_pts, crossing, view_rot, eye, bounds),
);
handles.extend(self.tabs[i].scene.block_mesh_poly_hit(
&poly_pts, crossing, view_rot, eye, bounds,
));
// Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
// Objects on a locked layer aren't selectable.
handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h));
// Accumulate like the box path (issue #83): plain
// lasso adds, Shift+lasso removes. An empty lasso
// leaves the current selection untouched so a stray
// drag never discards hard-won picks.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
self.refresh_properties();
selection_just_completed = true;
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.poly_active = false;
sel.poly_points.clear();
sel.poly_crossing = false;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
} else {
if box_anchor.is_none() {
let (view_rot, eye, all_wires) =
self.pick_view(i, &edit_cam, bounds);
// Selection cycling: where two or more objects
// overlap, open a list box to pick which one; a
// single object falls through to the normal click.
// Gated behind the toggle, so default picking is
// unchanged when off.
let mut handled_by_cycling = false;
if self.selection_cycling {
let cands: Vec<Handle> = scene::pick::hit_test::click_hits_all(
p,
&all_wires[..],
view_rot,
eye,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.filter(|&h| self.tabs[i].scene.passes_selection_filter(h))
.filter(|&h| !self.tabs[i].scene.is_layer_locked(h))
.collect();
if cands.len() >= 2 {
// Overlap: open the list box at the cursor.
self.cycle_candidates = Some((p_full, cands));
handled_by_cycling = true;
}
}
if !handled_by_cycling {
let hit =
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
scene::pick::hit_test::click_hit_hatch(
p,
&self.tabs[i].scene.visible_hatches_for_click(),
view_rot,
eye,
bounds,
)
})
.or_else(|| {
// Block-internal hatch: resolve to the
// parent Insert (AutoCAD behaviour).
scene::pick::hit_test::click_hit_insert_hatch(
p,
&self.tabs[i].scene.insert_hatches_for_click()[..],
view_rot,
eye,
bounds,
)
})
.or_else(|| {
// 3D solids: click anywhere on the shaded
// body — top-level solids and block-internal
// ones together, front-most wins (a block in
// front of a solid resolves to the block).
self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds)
});
// Selection filter: drop a pick whose type is excluded.
let hit =
hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h));
if let Some(handle) = hit {
if let Some(layer) =
self.tabs[i].scene.locked_layer_name(handle)
{
// Locked layer: visible + snappable but
// not selectable. Report and do nothing
// else — in particular do NOT set
// `selection_just_completed`, or a
// gather command (MOVE's "select
// objects") would wrongly finish.
self.command_line.push_info(&format!(
"Object is on locked layer \"{layer}\" — unlock the layer to select or edit it."
));
} else {
// Individual picks accumulate (issue #47):
// each plain click adds to the selection,
// Shift+click removes the picked entity.
if self.shift_down {
self.tabs[i].scene.deselect_entity(handle);
} else {
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
}
self.refresh_properties();
selection_just_completed = true;
}
} else {
// Empty-space click only ARMS a box here; it
// no longer clears the selection, so a box can
// add to it (issue #83). The box completion
// (or Esc) decides what happens to the
// selection.
// Pin the anchor to the world point under it
// so a zoom/pan mid-drag re-projects it
// instead of leaving it frozen in pixels
// (#234). Computed before the selection
// borrow so the &self projection can't clash.
let anchor_world =
self.cursor_model_point(i, &edit_cam, p, bounds);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
// Full-canvas space: ViewportMove updates
// box_current in canvas coords and the overlay
// draws there; release maps back into the tile.
sel.box_anchor = Some(p_full);
sel.box_current = Some(p_full);
sel.box_anchor_world = Some(anchor_world);
sel.box_crossing = false;
}
}
} else {
let a = box_anchor.unwrap();
let crossing = box_crossing;
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a,
p,
crossing,
&all_wires[..],
view_rot,
eye,
bounds,
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
handles.extend(scene::pick::hit_test::box_hit_hatch(
a,
p,
crossing,
&self.tabs[i].scene.visible_hatches_for_click(),
view_rot,
eye,
bounds,
));
handles.extend(
self.tabs[i].scene.mesh_box_hit(a, p, crossing, view_rot, eye, bounds),
);
handles.extend(self.tabs[i].scene.block_mesh_box_hit(
a, p, crossing, view_rot, eye, bounds,
));
// Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
// Objects on a locked layer aren't selectable.
handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h));
// Accumulate (issue #83): a plain box adds to the
// current selection, Shift+box removes the boxed
// entities. An empty box leaves the selection alone
// so an accidental empty drag never discards it.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
self.refresh_properties();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.box_last = Some((a, p));
sel.box_last_crossing = crossing;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing = false;
selection_just_completed = true;
}
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.left_down = false;
sel.left_press_pos = None;
sel.left_press_time = None;
sel.left_dragging = false;
}
if is_gathering && selection_just_completed {
let handles: Vec<Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
let result = cmd.on_selection_complete(handles);
return self.apply_cmd_result(result);
}
}
// ── Double-click in Model Space: DDEDIT for Text/MText ────
if is_click
&& is_down
&& self.tabs[i].active_cmd.is_none()
&& self.tabs[i].scene.current_layout == "Model"
{
let now = Instant::now();
let is_double_model = self
.last_vp_click_time
.map(|t| {
let dt = now.duration_since(t).as_millis();
let last = self.last_vp_click_pos.unwrap_or(p);
let d = (p.x - last.x).hypot(p.y - last.y);
dt < 400 && d < 8.0
})
.unwrap_or(false);
self.last_vp_click_time = Some(now);
self.last_vp_click_pos = Some(p);
if is_double_model {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye()) };
let all_wires = self.tabs[i].scene.hit_test_wires();
// Resolve the double-clicked object — its wire, or (for a
// block/solid with no wire under the cursor) its shaded
// body, which maps to the parent INSERT.
let hit = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds));
if let Some(handle) = hit {
// Locked layer: double-click must not open any editor
// (text / attribute / in-place block edit).
if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) {
self.command_line.push_info(&format!(
"Object is on locked layer \"{layer}\" — unlock the layer to edit it."
));
return Task::none();
}
// Any text-bearing entity opens its in-place editor
// (plain box or rich MText editor, per type). A
// Leader resolves to the entity it annotates.
let is_editable_text = self.tabs[i]
.scene
.document
.get_entity(handle)
.is_some_and(|e| {
crate::app::text_inline::read_text_field(e).is_some()
|| matches!(e, AcadEntityType::Leader(_))
});
if is_editable_text {
return self.begin_text_edit(handle);
}
// Double-clicking a block with attributes opens the
// attribute editor (edit its values); a block with
// no attributes enters in-place block edit (REFEDIT),
// so its geometry can be edited and the change
// reflects in every instance. (#136, #192)
let insert_has_attrs = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(ins)) if !ins.attributes.is_empty()
);
if insert_has_attrs {
return Task::done(Message::AttrEditorOpen(handle));
}
let is_insert = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(_))
);
if is_insert && self.tabs[i].refedit_session.is_none() {
return Task::done(Message::Command(format!(
"REFEDIT_BEGIN:{}",
handle.value()
)));
}
}
}
}
// ── Double-click: enter/exit MSPACE ───────────────────────
// Only when no command is running, no drag, and we're in paper space.
if is_click
&& is_down // ensures there was a matching left-press
&& self.tabs[i].active_cmd.is_none()
&& self.tabs[i].scene.current_layout != "Model"
{
let now = Instant::now();
let is_double = self
.last_vp_click_time
.map(|t| {
let dt = now.duration_since(t).as_millis();
let last = self.last_vp_click_pos.unwrap_or(p);
let d = (p.x - last.x).hypot(p.y - last.y);
dt < 400 && d < 8.0
})
.unwrap_or(false);
self.last_vp_click_time = Some(now);
self.last_vp_click_pos = Some(p);
if is_double {
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
// 1) Try direct wire hit — works when the border is clicked.
let wire_hit: Option<acadrust::Handle> = {
let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye()) };
let all_wires = self.tabs[i].scene.hit_test_wires();
scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.and_then(|h| {
if let Some(AcadEntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity(h)
{
if Scene::is_content_viewport(vp) {
Some(h)
} else {
None
}
} else {
None
}
})
};
// 2) Decide which viewport (if any) the click enters,
// keyed on the *visible* on-screen rectangle. Screen-space
// (not the full paper rect) so a click on the empty area
// beside an off-screen viewport doesn't match its
// partly-off-canvas rect and switch to it by mistake.
// The wire hit only refines *which* visible viewport
// (border precision / overlap), and only when the click
// actually lands inside THAT viewport's visible rect —
// otherwise its border-wire pick tolerance could match a
// far viewport whose edge merely passes near the cursor
// (e.g. clicking the overlap of two viewports while a
// third's border runs nearby) and enter the wrong one.
let screen_hit = self
.tabs[i]
.scene
.viewport_at_screen_point(p.x, p.y, (vw, vh));
let wire_in_visible = wire_hit.filter(|&h| {
self.tabs[i]
.scene
.viewport_screen_rect(h, (vw, vh))
.is_some_and(|r| {
let x0 = r.x.max(0.0);
let y0 = r.y.max(0.0);
let x1 = (r.x + r.width).min(vw);
let y1 = (r.y + r.height).min(vh);
p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1
})
});
let hit_vp = wire_in_visible.or(screen_hit);
if let Some(handle) = hit_vp {
return Task::done(Message::EnterViewport(handle));
} else if self.tabs[i].scene.active_viewport.is_some() {
// Double-clicked outside all viewports while in MSPACE → exit.
return Task::done(Message::ExitViewport);
}
}
}
Task::none()
}
pub(super) fn on_viewport_middle_press(&mut self) -> Task<Message> {
let i = self.active_tab;
self.ribbon.close_dropdown();
let now = Instant::now();
let is_double = {
let sel = self.tabs[i].scene.selection.borrow();
sel.middle_last_press_time
.map(|t| now.duration_since(t).as_millis() < 300)
.unwrap_or(false)
};
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
let Some(p) = sel.last_move_pos else {
return Task::none();
};
sel.middle_down = true;
sel.middle_last_pos = Some(p);
sel.middle_last_press_time = Some(now);
}
if is_double {
self.tabs[i].scene.fit_all();
self.command_line.push_output("Zoom Extents");
}
Task::none()
}
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
let s = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01,
};
let i = self.active_tab;
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
if self.tabs[i].scene.active_viewport.is_some() {
// In MSPACE: zoom the active viewport's model-space view,
// keeping the model point under the cursor stationary.
let cursor_paper = cursor.map(|cp| {
let pt = self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(cp, bounds);
glam::Vec2::new(pt.x as f32, pt.y as f32)
});
self.tabs[i].scene.zoom_active_viewport(s, cursor_paper);
// Bump so the GPU re-uploads the viewport's re-culled wire
// set after zooming inside it.
self.tabs[i].scene.camera_generation += 1;
} else {
// Model space: zoom about the cursor within the active
// tile so the point under it stays put in that pane.
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
let mut cam = self.tabs[i].scene.camera.borrow_mut();
if let Some(cursor) = cursor {
let local = iced::Point {
x: cursor.x - tile_b.x,
y: cursor.y - tile_b.y,
};
let tb = iced::Rectangle {
x: 0.0,
y: 0.0,
width: tile_b.width,
height: tile_b.height,
};
cam.zoom_about_point(local, tb, s);
} else {
cam.zoom(s);
}
drop(cam);
self.tabs[i].scene.camera_generation += 1;
// Keep an in-progress box selection pinned to the drawing
// as the view zooms under it (#234).
self.reproject_box_anchor(i, vw, vh);
}
Task::none()
}
/// Re-pin an active box-selection anchor to its stored world point after the
/// model-space view moves (zoom/pan), so the selection rectangle tracks the
/// drawing instead of staying frozen at its original pixel. No-op when no
/// box is in progress. (#234)
pub(super) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) {
let world = self.tabs[i].scene.selection.borrow().box_anchor_world;
let Some(world) = world else { return };
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
let tile_local = iced::Rectangle {
x: 0.0,
y: 0.0,
width: tile_b.width,
height: tile_b.height,
};
if let Some(sp) = self.tabs[i].scene.camera.borrow().project(world, tile_local) {
self.tabs[i].scene.selection.borrow_mut().box_anchor =
Some(iced::Point::new(sp.x + tile_b.x, sp.y + tile_b.y));
}
}
pub(super) fn on_viewport_click(&mut self) -> Task<Message> {
let i = self.active_tab;
let rot = self.tabs[i].scene.active_view_rotation_mat();
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
// The ViewCube draws in the top-right of whichever area
// owns it: the full canvas in model space, or the active
// viewport's screen rectangle in a paper layout. Map the
// cursor into that area before hit-testing so paper-space
// picks line up with the gizmo.
let (cx, cy, w, h) = match self.tabs[i]
.scene
.active_viewport
.and_then(|hndl| self.tabs[i].scene.viewport_screen_rect(hndl, (vw, vh)))
{
Some(rect) => (
self.cursor_pos.x - rect.x,
self.cursor_pos.y - rect.y,
rect.width,
rect.height,
),
None => {
// Model layout: hit-test within the active tile.
let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
(
self.cursor_pos.x - tb.x,
self.cursor_pos.y - tb.y,
tb.width,
tb.height,
)
}
};
// 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));
}
// 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;
// "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());
let cur_dir = self.tabs[i].scene.active_gaze_dir();
if cur_dir.dot(target_dir) > 0.9999 {
region = region.opposite();
}
let eye_dir = r_ucs.transform_vector3(region.snap_direction());
// Faces snap to a canonical upright orientation (never upside
// down); edges/corners keep the current up-sense so they spin
// smoothly around the clicked feature.
let is_face = matches!(region, scene::CubeRegion::Face(_));
if self.tabs[i].scene.active_viewport.is_some() {
if is_face {
self.tabs[i]
.scene
.mutate_active_viewport_camera(|c| c.snap_to_face(eye_dir, r_ucs));
} else {
self.tabs[i]
.scene
.snap_active_viewport_to_direction(eye_dir, r_ucs);
}
} else {
let mut cam = self.tabs[i].scene.camera.borrow_mut();
if is_face {
cam.snap_to_face(eye_dir, r_ucs);
} else {
cam.snap_to_direction(eye_dir, r_ucs);
}
}
self.tabs[i].scene.camera_generation += 1;
self.command_line
.push_output(&format!("View: {}", region.label()));
Task::none()
}
pub(super) fn on_hover_dwell_tick(&mut self) -> Task<Message> {
let Some(dwell) = self.hover_dwell.clone() else {
return Task::none();
};
if Instant::now()
.duration_since(dwell.last_move_at)
.as_millis()
< crate::app::HOVER_DWELL_MS
{
return Task::none();
}
let i = dwell.tab;
// Re-check the gate — drag / command may have started
// between the move that armed the dwell and this tick.
if i >= self.tabs.len() || self.tabs[i].active_cmd.is_some() {
self.hover_dwell = None;
return Task::none();
}
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: dwell.tile_size.0,
height: dwell.tile_size.1,
};
let p = dwell.point;
// Inside a viewport, hover-pick through the viewport camera +
// model wires so the rollover highlights the entity under the
// cursor (dwell.point / dwell.tile_size are already pane-local).
let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size;
let edit_cam = self
.tabs[i]
.scene
.viewport_edit_frame(canvas_sz)
.map(|(cam, _)| cam);
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
// Mirror the click-selection pick order so the rollover
// highlights every selectable object: wire → hatch →
// block-internal hatch → shaded 3D solid body.
let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
scene::pick::hit_test::click_hit_hatch(
p,
&self.tabs[i].scene.visible_hatches_for_click(),
view_rot,
eye,
bounds,
)
})
.or_else(|| {
scene::pick::hit_test::click_hit_insert_hatch(
p,
&self.tabs[i].scene.insert_hatches_for_click()[..],
view_rot,
eye,
bounds,
)
})
.or_else(|| self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds));
self.tabs[i].scene.set_hover_highlight(hovered);
self.hover_dwell = None;
Task::none()
}
pub(super) fn on_layout_switch(&mut self, name: String) -> Task<Message> {
let i = self.active_tab;
let going_to_paper = name != "Model";
// Persist the camera of the layout we're leaving BEFORE switching
// so returning to it restores where the user left off (the
// periodic sync only fires on a tick, which may not have run
// since the last pan/zoom).
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
// Cancel any pending rename/context-menu and active viewport when switching.
self.layout_rename_state = None;
self.layout_context_menu = None;
self.tabs[i].scene.active_viewport = None;
self.tabs[i].scene.set_current_layout(name);
self.tabs[i].scene.deselect_all();
// UCS follows the pane: model header UCS in the Model tab, none
// in plain paper space (a viewport's UCS is adopted on entry).
self.tabs[i].refresh_active_ucs();
self.tabs[i].scene.restore_saved_camera();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
// Grid/snap are per-view: load the layout we just entered (its
// sheet viewport in paper space, the model tile in model space)
// so model and each layout keep independent grid state.
self.adopt_view_display(i);
// Paper-space tools live in the right-edge side toolbar now, so
// entering/leaving a layout no longer hijacks the ribbon tab.
let _ = going_to_paper;
// Refresh VP freeze columns for the new layout.
let doc_layers = self.tabs[i].scene.document.layers.clone();
let vp_info = self.tabs[i].scene.viewport_list();
self.tabs[i]
.layers
.sync_with_viewports(&doc_layers, vp_info);
Task::none()
}
pub(super) fn on_layout_create(&mut self) -> Task<Message> {
let i = self.active_tab;
// Find a unique name (e.g. Layout2, Layout3, ...).
let existing = self.tabs[i].scene.layout_names();
let mut idx = existing.len();
let new_name = loop {
let candidate = format!("Layout{}", idx);
if !existing.contains(&candidate) {
break candidate;
}
idx += 1;
};
self.push_undo_snapshot(i, "LAYOUT");
match self.tabs[i].scene.document.add_layout(&new_name) {
Ok(_) => {
// Override the acadrust default limits (12×9 imperial) with A4 landscape.
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(l) = obj {
if l.name == new_name {
l.min_limits = (0.0, 0.0);
l.max_limits = (297.0, 210.0);
l.min_extents = (0.0, 0.0, 0.0);
l.max_extents = (297.0, 210.0, 0.0);
break;
}
}
}
self.tabs[i].scene.current_layout = new_name.clone();
// Safety net — `add_layout` already creates the overall
// sheet viewport; this covers any path that doesn't.
self.tabs[i].scene.ensure_sheet_viewport(&new_name);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.fit_all();
self.command_line.push_output(&format!(
"Layout \"{new_name}\" created — use MVIEW to add a viewport"
));
self.tabs[i].dirty = true;
}
Err(e) => self
.command_line
.push_error(&format!("Failed to create layout: {e}")),
}
Task::none()
}
pub(super) fn on_layout_rename_commit(&mut self) -> Task<Message> {
if let Some((orig, new_name)) = self.layout_rename_state.take() {
let new_name = new_name.trim().to_string();
if !new_name.is_empty() && new_name != orig {
let i = self.active_tab;
let exists = self.tabs[i]
.scene
.layout_names()
.iter()
.any(|n| *n == new_name);
if exists {
self.command_line
.push_error(&format!("\"{}\" name already in use", new_name));
} else {
self.push_undo_snapshot(i, "LAYOUT RENAME");
self.tabs[i].scene.rename_layout(&orig, &new_name);
if self.tabs[i].scene.current_layout == orig {
self.tabs[i].scene.current_layout = new_name.clone();
}
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Layout \"{orig}\" → \"{new_name}\""));
}
}
}
Task::none()
}
}