cad-editor/src/app/update/viewport.rs
Hakan Seven f0c1be0db1 fix(plot): correct layout hatch output
Keep Layout output on the selected physical sheet and preserve hatch lineweights and plot styles through paper, viewport, and nested block paths.\n\nRefs #607
2026-07-31 23:08:41 +03:00

4295 lines
187 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::{
axis_lock_apply, axis_lock_capture, 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::model::object::GripApply;
use crate::scene::pick::grip::{
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripTarget,
};
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};
use std::sync::Arc;
/// 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.invalidate_point_dependencies();
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.invalidate_point_dependencies();
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_no_blocks();
}
/// 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())
}
}
}
fn grip_edit_for_hit(
&mut self,
i: usize,
handle: Handle,
grip_id: usize,
is_translate: bool,
world: glam::DVec3,
) -> GripEdit {
if !self.tabs[i].hot_grips.contains(&(handle, grip_id)) {
self.tabs[i].hot_grips.clear();
return GripEdit::single(handle, grip_id, is_translate, world);
}
let mut targets: Vec<GripTarget> = self.tabs[i]
.selected_grip_handles
.iter()
.copied()
.zip(self.tabs[i].selected_grips.iter())
.filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id)))
.filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID)
.map(|(owner, grip)| GripTarget {
handle: owner,
grip_id: grip.id,
is_translate: grip.is_midpoint,
last_world: grip.world,
})
.collect();
// A midpoint/centre grip translates its whole entity. If that entity also
// has hot point grips, applying both would move it twice; one translate
// target owns the entity in that case.
let translate_handles: rustc_hash::FxHashSet<_> = targets
.iter()
.filter(|target| target.is_translate)
.map(|target| target.handle)
.collect();
let mut used_translates = rustc_hash::FxHashSet::default();
targets.retain(|target| {
if translate_handles.contains(&target.handle) {
target.is_translate && used_translates.insert(target.handle)
} else {
true
}
});
if targets.is_empty() {
return GripEdit::single(handle, grip_id, is_translate, world);
}
GripEdit {
handle,
grip_id,
origin_world: world,
last_world: world,
targets,
}
}
pub(in crate::app) fn update_grip_hover(&mut self, i: usize, p: iced::Point) {
const HOVER_OPEN_MS: u128 = 1_000;
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 (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_index, grip_id, _, _)) => {
let Some(&handle) = self.tabs[i].selected_grip_handles.get(grip_index) else {
self.grip_hover = None;
self.grip_popup = None;
return;
};
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].active_block_edit.is_some() {
let camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
session.editor_camera = camera;
}
} else 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_no_blocks();
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_no_blocks();
self.command_line
.push_output(&format!("Visual style: {label}"));
Task::none()
}
pub(super) fn on_cursor_moved(&mut self, p: Point) -> Task<Message> {
if self.color_pick_target.is_some() {
return Task::none();
}
// `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() || self.color_pick_target.is_some() {
return Task::none();
}
let i = self.active_tab;
let perf_move = crate::perf::enabled();
let move_started = Instant::now();
// 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;
if self.pick_drag_rect {
// PICKDRAG 1 (#226): press-drag spans a RECTANGLE
// marquee — drive the existing box machinery (its
// overlay and completion) instead of the lasso.
sel.box_anchor = Some(press);
sel.box_current = Some(p);
sel.box_crossing = p.x < press.x;
} else {
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.left_dragging {
if let Some(a) = sel.box_anchor {
sel.box_current = Some(p);
sel.box_crossing = p.x < a.x;
}
}
} 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
.record_nav_perf(crate::scene::NavPerfOp::Rotate, move_started);
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
.record_nav_perf(crate::scene::NavPerfOp::Rotate, move_started);
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
.record_nav_perf(crate::scene::NavPerfOp::Pan, move_started);
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 grip_started = Instant::now();
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())
}
};
let mut seen_handles = rustc_hash::FxHashSet::default();
let edited_handles: Vec<_> = grip
.targets
.iter()
.map(|target| target.handle)
.filter(|handle| seen_handles.insert(*handle))
.collect();
// First move of this drag: hide every edited entity from the base
// tessellation so subsequent moves refresh only the overlay.
if self.grip_preview_handles != edited_handles {
if self.grip_dirty_before.is_none() {
self.grip_dirty_before = Some(self.tabs[i].dirty);
}
for handle in std::mem::take(&mut self.grip_preview_handles) {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.grip_originals = edited_handles
.iter()
.filter_map(|&handle| {
self.tabs[i]
.scene
.document
.get_entity(handle)
.cloned()
.map(|entity| (handle, entity))
})
.collect();
for &handle in &edited_handles {
self.tabs[i].scene.preview_hidden.insert(handle);
}
let changes: Vec<_> = edited_handles
.iter()
.map(|&handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.grip_preview_handles = edited_handles.clone();
// 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(&edited_handles);
self.grip_text_verts = snap
.iter()
.flat_map(|w| w.text_verts.iter().copied())
.collect();
let square_grip = self.tabs[i]
.selected_grip_handles
.iter()
.copied()
.zip(self.tabs[i].selected_grips.iter())
.find(|(owner, grip_def)| {
*owner == grip.handle && grip_def.id == grip.grip_id
})
.map(|(_, grip_def)| {
grip_def.shape == crate::scene::model::object::GripShape::Square
})
.unwrap_or(false);
self.grip_text_slide = edited_handles.len() == 1
&& grip.targets.len() == 1
&& !self.grip_text_verts.is_empty()
&& snap.iter().all(|w| w.points.is_empty())
&& square_grip;
}
let setup_ms = grip_started.elapsed().as_secs_f64() * 1000.0;
let snap_started = Instant::now();
// 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()
};
let snap_candidates = self.tabs[i].scene.interaction_candidates_near(
all_wires,
raw,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
// 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, &snap_candidates, 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;
}
// Hard axis lock (#312) works for grip drags too: Shift
// pins the drag to the nearest polar/ortho ray from the
// grip's origin, osnap hits included.
if self.shift_down {
if self.axis_lock_dir.is_none() {
let ucs_xf = self.tabs[i].ucs_xform();
self.axis_lock_dir = axis_lock_capture(
raw,
grip.origin_world,
self.polar_mode,
self.polar_increment_deg,
&ucs_xf,
);
}
} else {
self.axis_lock_dir = None;
}
if let Some(dir) = self.axis_lock_dir {
snapped = axis_lock_apply(snapped, grip.origin_world, dir);
} else 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 snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0;
let apply_started = Instant::now();
let delta = snapped - grip.last_world;
let actions: Vec<_> = grip
.targets
.iter()
.map(|target| {
let apply = if target.is_translate {
GripApply::Translate(delta)
} else {
GripApply::Absolute(target.last_world + delta)
};
(target.handle, target.grip_id, apply)
})
.collect();
for (handle, grip_id, apply) in actions {
self.tabs[i].scene.apply_grip(handle, grip_id, apply);
}
self.tabs[i].scene.set_preview_hatches(&edited_handles);
self.tabs[i].dirty = true;
if let Some(active) = self.tabs[i].active_grip.as_mut() {
active.last_world = snapped;
for target in &mut active.targets {
target.last_world += delta;
}
}
let apply_ms = apply_started.elapsed().as_secs_f64() * 1000.0;
let preview_started = Instant::now();
// 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(&edited_handles);
self.tabs[i].scene.set_preview_wires(preview);
}
let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0;
let geometry_ms = grip_started.elapsed().as_secs_f64() * 1000.0;
self.refresh_selected_grips();
let grips_ms = grip_started.elapsed().as_secs_f64() * 1000.0 - geometry_ms;
// Properties are refreshed when the grip is committed or cancelled.
// Rebuilding the inspector on every pointer event adds no drawing
// value and can monopolize the UI thread during a drag.
let total_ms = grip_started.elapsed().as_secs_f64() * 1000.0;
if perf_move && total_ms >= 50.0 {
crate::perf_record!(
"[perf] grip-move {:>7.1}ms setup={:.1} snap={:.1} apply={:.1} preview={:.1} grips={:.1}",
total_ms,
setup_ms,
snap_ms,
apply_ms,
preview_ms,
grips_ms,
);
}
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 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() {
// On dense drawings, clearing a rollover immediately schedules a
// second full scene frame just as motion resumes. Keep the previous
// highlight until the next settled pick replaces it.
if self.tabs[i].scene.last_tess_wires.get()
< crate::app::HOVER_DWELL_DENSE_WIRES
{
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, grid_spacing) = match &edit_cam {
Some(cam) => (
cam.view_proj_rte(bounds),
cam.eye(),
crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds),
),
None => {
let cam = self.tabs[i].scene.camera.borrow();
(
cam.view_proj_rte(bounds),
cam.eye(),
crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds),
)
}
};
// Sync grid-snap spacing to the adaptive spacing of the visible grid.
self.snapper.grid_spacing = grid_spacing;
// 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()
};
let snap_candidates = self.tabs[i].scene.interaction_candidates_near(
all_wires,
snap_cursor,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
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,
&snap_candidates,
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,
&snap_candidates,
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);
self.snapper.update_otrack_dwell(
snap_world,
&snap_candidates,
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(),
&snap_candidates,
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_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
self.snapper.otrack_snap(
cursor_world,
view_rot,
eye,
bounds,
step,
self.last_point,
self.ortho_mode && !is_window_corner,
ucs_x,
ucs_y,
)
} 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, dir.as_dvec3()));
}
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
} 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);
// Hard axis lock (#312): Shift during a rubber-band
// pick captures the nearest polar/ortho direction
// and pins the pick to that ray — even an osnap hit
// contributes only its along-axis component.
// Only while an active command is asking for a
// POINT — Shift has other meanings during entity
// picks (TRIM/EXTEND swap) and selection.
let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|c| {
!c.needs_entity_pick()
&& !c.needs_tangent_pick()
&& !c.is_selection_gathering()
});
if let Some(base) = self.last_point {
if self.shift_down && wants_point && !is_window_corner {
if self.axis_lock_dir.is_none() {
let ucs_xf = self.tabs[i].ucs_xform();
self.axis_lock_dir = axis_lock_capture(
cursor_world,
base,
self.polar_mode,
self.polar_increment_deg,
&ucs_xf,
);
}
} else if !self.shift_down {
self.axis_lock_dir = None;
}
} else if !self.shift_down {
self.axis_lock_dir = None;
}
if let (Some(dir), Some(base)) = (self.axis_lock_dir, self.last_point) {
pt = axis_lock_apply(pt, base, dir);
pt
} else {
// 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
};
// Dynamic-input locked fields constrain the preview point
// (#356): a typed angle pins the direction, a typed
// distance pins the radius — the same resolution the Enter
// commit uses, so preview and commit agree. The lock wins
// over osnap/ortho/polar.
let effective = {
let locked = self.tabs[i].active_cmd.is_some()
&& self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some());
if locked {
self.tabs[i].last_cursor_world = effective;
self.dyn_resolve_point().unwrap_or(effective)
} else {
effective
}
};
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 include_fills = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.entity_pick_includes_fills())
.unwrap_or(false);
let candidate_handles = if include_fills {
self.tabs[i]
.scene
.interaction_candidate_handles(&snap_candidates)
} else {
None
};
let hovered = scene::pick::hit_test::click_hit(
p,
&snap_candidates,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
if !include_fills {
return None;
}
scene::pick::hit_test::click_hit_hatch(
p,
&self.tabs[i]
.scene
.visible_hatches_for_click(candidate_handles.as_ref()),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
)
})
.or_else(|| {
include_fills.then(|| {
scene::pick::hit_test::click_hit_insert_hatch(
p,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
)
})?
})
.or_else(|| {
include_fills.then(|| {
self.tabs[i].scene.solid_hover_hit(
p,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
)
})?
});
let highlights_hover = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.entity_pick_highlights_hover())
.unwrap_or(false);
if highlights_hover {
self.tabs[i].scene.set_hover_highlight(hovered);
}
let hover_handle = hovered.unwrap_or(acadrust::Handle::NULL);
let shift = self.shift_down;
let mut p = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| {
// Live shift state drives TRIM/EXTEND's
// shift-swap preview (#336).
c.set_shift(shift);
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 {
taper_widths: Vec::new(),
world_width: 0.0,
depth_override: None,
fill_is_3d: false,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
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,
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_f64;
let far_pos = h.base + h.dir * far;
let far_neg = h.base - h.dir * far;
let mut guide = crate::scene::WireModel::solid_f64(
"__otrack_guide__".into(),
vec![
[far_neg.x, far_neg.y, far_neg.z],
[far_pos.x, far_pos.y, far_pos.z],
],
[0.2, 0.9, 0.5, 0.6],
false,
);
guide.pattern_length = 0.8;
guide.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
previews.push(guide);
}
}
self.tabs[i].scene.set_preview_wires(previews);
} else {
self.tabs[i].snap_result = None;
}
self.sync_dyn_fields();
let move_ms = move_started.elapsed().as_secs_f64() * 1000.0;
if perf_move
&& (self.tabs[i].active_cmd.is_some()
|| self.tabs[i].scene.active_viewport.is_some())
&& move_ms >= 16.7
{
let mode = if self.tabs[i].active_cmd.is_some() {
"command"
} else {
"MSPACE"
};
crate::perf_record!(
"[perf] pointer-move mode={mode:<7} {:>7.1}ms",
move_ms,
);
}
Task::none()
}
pub(crate) 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, grid_spacing) = match &edit_cam {
Some(cam) => (
cam.view_proj_rte(bounds),
cam.eye(),
crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds),
),
None => {
let cam = self.tabs[i].scene.camera.borrow();
(
cam.view_proj_rte(bounds),
cam.eye(),
crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds),
)
}
};
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()
};
let snap_candidates = self.tabs[i].scene.interaction_candidates_near(
all_wires,
raw,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
self.snapper.grid_spacing = grid_spacing;
// 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, &snap_candidates, 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].scene.camera_generation += 1;
}
/// Commit the live UCS to its owning space. Model/viewport panes persist
/// their normal UCS fields. A BEDIT pane has no persistent UCS of its own:
/// its transient origin and axes are baked into the block definition, then
/// the editor returns to canonical local coordinates.
pub(in crate::app) fn commit_active_ucs_change(&mut self, i: usize, label: &'static str) {
if let Some((session_index, block_record)) = self.tabs[i]
.active_block_edit
.and_then(|index| {
self.tabs[i]
.block_edits
.get(index)
.map(|session| (index, session.br_handle))
})
{
let frame = self.tabs[i].ucs_xform();
if !frame.is_identity() {
self.push_undo_snapshot(i, label);
let local_from_old = frame.to_ucs_transform();
let changed = self.tabs[i]
.scene
.reframe_block_definition(block_record, &local_from_old);
if changed > 0 {
// Carry the editor camera through the same rigid transform
// so changing the block coordinate frame does not make its
// contents jump on screen.
self.tabs[i]
.scene
.camera
.borrow_mut()
.apply_rigid_transform(&local_from_old);
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].dirty = true;
}
}
// Blocks do not own a saved UCS. The chosen frame has now become
// their identity coordinates.
self.tabs[i].active_ucs = None;
let editor_camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(session) = self.tabs[i].block_edits.get_mut(session_index) {
session.editor_ucs = None;
session.editor_camera = editor_camera;
}
self.tabs[i].sync_ucs_to_scene();
return;
}
let persisted = if let Some(handle) = self.tabs[i].scene.active_viewport {
self.tabs[i].ucs_from_viewport(handle)
} else if self.tabs[i].scene.current_layout == "Model" {
self.tabs[i].model_ucs_from_header()
} else {
None
};
let same_basis = |a: Option<&acadrust::tables::Ucs>,
b: Option<&acadrust::tables::Ucs>| {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => {
a.origin == b.origin && a.x_axis == b.x_axis && a.y_axis == b.y_axis
}
_ => false,
}
};
if !same_basis(self.tabs[i].active_ucs.as_ref(), persisted.as_ref()) {
self.push_undo_snapshot(i, label);
self.tabs[i].persist_active_ucs();
self.tabs[i].dirty = true;
}
self.tabs[i].sync_ucs_to_scene();
}
// ── 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()
{
{
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_index, grip_id, is_translate, world)) = grip_hit {
let Some(&handle) = self.tabs[i].selected_grip_handles.get(grip_index) else {
return Task::none();
};
// 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();
}
if self.shift_down {
let key = (handle, grip_id);
if !self.tabs[i].hot_grips.remove(&key) {
self.tabs[i].hot_grips.insert(key);
}
self.grip_hover = None;
self.grip_popup = None;
return Task::none();
}
self.tabs[i].active_grip = Some(self.grip_edit_for_hit(
i,
handle,
grip_id,
is_translate,
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. In BEDIT this
// rebases only the block; elsewhere it writes the model/viewport UCS.
if self.ucs_grip_drag.take().is_some() {
self.commit_active_ucs_change(i, "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 as one undoable group, then put every
// edited entity back into the resident tessellation.
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty);
if !handles.is_empty() {
if !originals.is_empty() {
self.push_entity_group_history(
i,
"GRIP",
originals
.into_iter()
.map(|(handle, entity)| (handle, std::sync::Arc::new(entity)))
.collect(),
dirty_before,
);
self.tabs[i].dirty = true;
}
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
let changes: Vec<_> = handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
}
// 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()
};
let snap_candidates = self.tabs[i].scene.interaction_candidates_near(
all_wires,
snap_cursor,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
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,
&snap_candidates,
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,
&snap_candidates,
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 rayplane intersection.
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
let mut world_pt_locked = false;
// Hard axis lock (#312): the held Shift lock projects
// the committed point — osnap hits included — onto the
// locked ray, matching the preview exactly.
if let (Some(dir), Some(base)) = (self.axis_lock_dir, self.last_point) {
pt = axis_lock_apply(pt, base, dir);
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
world_pt_locked = true;
}
// OTRACK alignment wins over ortho/polar; otherwise apply
// ortho/polar relative to the last point.
let otrack = if !world_pt_locked && snap_hit.is_none() {
let step = if self.polar_mode && !is_window_corner {
Some(self.polar_increment_deg)
} else {
None
};
let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
self.snapper.otrack_snap(
raw,
view_rot,
eye,
bounds,
step,
self.last_point,
self.ortho_mode && !is_window_corner,
ucs_x,
ucs_y,
)
} else {
None
};
if let Some(h) = otrack {
pt = h.aligned;
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
} else if !world_pt_locked
&& !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,
);
}
}
}
// A click while dynamic-input fields hold typed values
// commits the CONSTRAINED point — the same resolution
// the preview shows and Enter would commit (#356).
if self.tabs[i].active_cmd.is_some()
&& self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some())
{
self.tabs[i].last_cursor_world = pt;
if let Some(r) = self.dyn_resolve_point() {
pt = r;
}
}
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 click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires2,
world_pt,
view_rot2,
eye2,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
);
let include_fills = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.entity_pick_includes_fills())
.unwrap_or(false);
let candidate_handles = if include_fills {
self.tabs[i]
.scene
.interaction_candidate_handles(&click_candidates)
} else {
None
};
let hit = scene::pick::hit_test::click_hit(
p,
&click_candidates,
view_rot2,
eye2,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
if !include_fills {
return None;
}
scene::pick::hit_test::click_hit_hatch(
p,
&self.tabs[i]
.scene
.visible_hatches_for_click(candidate_handles.as_ref()),
view_rot2,
eye2,
bounds,
candidate_handles.as_ref(),
)
})
.or_else(|| {
include_fills.then(|| {
scene::pick::hit_test::click_hit_insert_hatch(
p,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot2,
eye2,
bounds,
candidate_handles.as_ref(),
)
})?
})
.or_else(|| {
include_fills.then(|| {
self.tabs[i].scene.solid_click_hit(
p,
view_rot2,
eye2,
bounds,
candidate_handles.as_ref(),
)
})?
});
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 shift = self.shift_down;
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
// Shift-swap state for TRIM/EXTEND (#336).
c.set_shift(shift);
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 if !self.command_point_allowed(i, world_pt) {
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);
// The one-shot snap override is spent by this pick —
// restore the running osnap configuration (#337).
self.snapper.clear_override();
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, poly_drag) = {
let sel = self.tabs[i].scene.selection.borrow();
(
sel.left_down,
sel.left_dragging,
sel.box_anchor,
sel.box_crossing,
sel.vp_size,
sel.poly_active,
)
};
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 !poly_drag {
// PICKDRAG 1 (#226): the press-drag spanned a
// rectangle through the box machinery — complete
// it here on release.
if let Some(a) = box_anchor {
let crossing = box_crossing;
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let world_aabb =
[a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)]
.map(|point| self.cursor_model_point(i, &edit_cam, point, bounds))
.into_iter()
.fold(
[
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
],
|mut aabb, world| {
aabb[0] = aabb[0].min(world.x);
aabb[1] = aabb[1].min(world.y);
aabb[2] = aabb[2].max(world.x);
aabb[3] = aabb[3].max(world.y);
aabb
},
);
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
all_wires,
world_aabb,
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
view_rot,
eye,
bounds,
);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&area_candidates);
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a,
p,
crossing,
&area_candidates,
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(candidate_handles.as_ref()),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
a,
p,
crossing,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.mesh_box_hit(
a,
p,
crossing,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.block_mesh_box_hit(
a,
p,
crossing,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
// 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.
// PICKADD 0 (#226): a plain box REPLACES.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
if !self.pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
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 world_aabb = poly_pts.iter().fold(
[
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
],
|mut aabb, &point| {
let world = self.cursor_model_point(i, &edit_cam, point, bounds);
aabb[0] = aabb[0].min(world.x);
aabb[1] = aabb[1].min(world.y);
aabb[2] = aabb[2].max(world.x);
aabb[3] = aabb[3].max(world.y);
aabb
},
);
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
all_wires,
world_aabb,
poly_pts.iter().fold(
[
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
],
|mut rect, point| {
rect[0] = rect[0].min(point.x);
rect[1] = rect[1].min(point.y);
rect[2] = rect[2].max(point.x);
rect[3] = rect[3].max(point.y);
rect
},
),
view_rot,
eye,
bounds,
);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&area_candidates);
let mut handles: Vec<Handle> = scene::pick::hit_test::poly_hit(
&poly_pts,
crossing,
&area_candidates,
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(candidate_handles.as_ref()),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(scene::pick::hit_test::poly_hit_insert_hatch(
&poly_pts,
crossing,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.mesh_poly_hit(
&poly_pts,
crossing,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.block_mesh_poly_hit(
&poly_pts,
crossing,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
// 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 {
// PICKADD 0 (#226): a plain marquee REPLACES
// the selection (empty results still leave it
// alone, matching the box rule).
if !self.pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
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);
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires,
click_world,
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&click_candidates);
// 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,
&click_candidates,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.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,
&click_candidates,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.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(candidate_handles.as_ref()),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
)
})
.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().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
)
})
.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,
candidate_handles.as_ref(),
)
});
// 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.
// PICKADD 0 (#226): a plain click
// REPLACES the selection instead and
// Shift+click toggles membership.
if self.shift_down {
if !self.pick_add
&& !self.tabs[i].scene.selected.contains(&handle)
{
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
} else {
self.tabs[i].scene.deselect_entity(handle);
}
} else {
self.tabs[i].scene.select_entity(handle, !self.pick_add);
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.
// PICKADD 0 (#226): OS convention — the
// empty click also drops the selection.
if !self.pick_add && !self.shift_down {
self.tabs[i].scene.deselect_all();
self.refresh_properties();
}
// 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 world_aabb = [a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)]
.map(|point| self.cursor_model_point(i, &edit_cam, point, bounds))
.into_iter()
.fold(
[
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
],
|mut aabb, world| {
aabb[0] = aabb[0].min(world.x);
aabb[1] = aabb[1].min(world.y);
aabb[2] = aabb[2].max(world.x);
aabb[3] = aabb[3].max(world.y);
aabb
},
);
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
all_wires,
world_aabb,
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
view_rot,
eye,
bounds,
);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&area_candidates);
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a,
p,
crossing,
&area_candidates,
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(candidate_handles.as_ref()),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
a,
p,
crossing,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.mesh_box_hit(
a,
p,
crossing,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.block_mesh_box_hit(
a,
p,
crossing,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
// 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.
// PICKADD 0 (#226): a plain box REPLACES instead.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
if !self.pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
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();
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires,
click_world,
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&click_candidates);
// 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,
&click_candidates,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
self.tabs[i].scene.solid_click_hit(
p,
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
)
});
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 opens the block editor (BEDIT) — a
// space tab scoped to the block's own geometry, so
// edits reflect in every instance. (#136, #192, #261)
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 && self.tabs[i].active_block_edit.is_none() {
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!(
"BEDIT_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();
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires,
click_world,
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
);
scene::pick::hit_test::click_hit(
p,
&click_candidates,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.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.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Zoom, now);
self.command_line.push_output("Zoom Extents");
}
Task::none()
}
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
let nav_started = Instant::now();
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);
}
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Zoom, nav_started);
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();
};
let dwell_ms = crate::app::HOVER_DWELL_MS;
if Instant::now()
.duration_since(dwell.last_move_at)
.as_millis()
< dwell_ms
{
return Task::none();
}
let perf = crate::perf::enabled();
let hover_started = Instant::now();
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);
if let Some(task) =
self.prepare_interaction_index_task(i, Arc::clone(&all_wires), bounds.height)
{
// Keep the dwell armed. While the index is pending later timer
// ticks return immediately; once installed, the next tick performs
// the exact pick at the latest cursor position.
return task;
}
let hover_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let candidate_started = Instant::now();
let hover_candidates = self.tabs[i].scene.interaction_hover_candidates_near(
all_wires,
hover_world,
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
);
let candidate_ms = candidate_started.elapsed().as_secs_f64() * 1000.0;
let candidate_count = hover_candidates.len();
let handles_started = Instant::now();
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&hover_candidates);
let handles_ms = handles_started.elapsed().as_secs_f64() * 1000.0;
// Mirror the click-selection pick order so the rollover
// highlights every selectable object: wire → hatch →
// block-internal hatch → shaded 3D solid body.
let wire_started = Instant::now();
let mut hovered = scene::pick::hit_test::click_hit(
p,
&hover_candidates,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
)
.and_then(Scene::handle_from_wire_name);
let wire_ms = wire_started.elapsed().as_secs_f64() * 1000.0;
let mut hatch_ms = 0.0;
let mut insert_ms = 0.0;
let mut solid_ms = 0.0;
if hovered.is_none() {
let started = Instant::now();
hovered = scene::pick::hit_test::click_hit_hatch(
p,
&self.tabs[i]
.scene
.visible_hatches_for_click(candidate_handles.as_ref()),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
);
hatch_ms = started.elapsed().as_secs_f64() * 1000.0;
}
if hovered.is_none() {
let started = Instant::now();
hovered = scene::pick::hit_test::click_hit_insert_hatch(
p,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
);
insert_ms = started.elapsed().as_secs_f64() * 1000.0;
}
if hovered.is_none() {
let started = Instant::now();
hovered = self.tabs[i]
.scene
.solid_hover_hit(p, view_rot, eye, bounds, candidate_handles.as_ref());
solid_ms = started.elapsed().as_secs_f64() * 1000.0;
}
self.tabs[i].scene.set_hover_highlight(hovered);
self.hover_dwell = None;
let hover_ms = hover_started.elapsed().as_secs_f64() * 1000.0;
if perf && hover_ms >= 5.0 {
crate::perf_record!(
"[perf] hover-detail query={candidate_ms:.1} handles={handles_ms:.1} wire={wire_ms:.1} hatch={hatch_ms:.1} insert={insert_ms:.1} solid={solid_ms:.1} candidates={candidate_count}",
);
crate::perf_record!(
"[perf] hover-dwell {:>7.1}ms wires={} hit={}",
hover_ms,
self.tabs[i].scene.last_tess_wires.get(),
hovered.is_some(),
);
}
Task::none()
}
pub(in crate::app) fn prepare_interaction_index_task(
&mut self,
i: usize,
wires: Arc<Vec<crate::scene::WireModel>>,
screen_height_px: f32,
) -> Option<Task<Message>> {
let (epoch, source) = self.tabs[i]
.scene
.interaction_index_build_key(&wires, screen_height_px)?;
let tab_id = self.tabs[i].id;
let key = (tab_id, epoch, source);
if let Some(active) = self.active_interaction_index {
if active != key {
if let Some(position) = self
.queued_interaction_indices
.iter()
.position(|(queued_tab, ..)| *queued_tab == tab_id)
{
self.queued_interaction_indices.remove(position);
}
self.queued_interaction_indices.push_back((
tab_id,
epoch,
source,
wires,
screen_height_px,
));
}
return Some(Task::none());
}
self.tabs[i]
.scene
.mark_interaction_index_pending(epoch, source);
self.active_interaction_index = Some(key);
let weak = Arc::downgrade(&wires);
Some(Task::perform(
async move {
let started = iced::time::Instant::now();
let index = Arc::new(
crate::scene::pick::interaction_index::InteractionIndex::build(&wires),
);
index.prepare_screen();
Message::InteractionIndexReady {
tab_id,
epoch,
source,
wires: weak,
index,
build_ms: started.elapsed().as_secs_f64() * 1000.0,
}
},
|message| message,
))
}
pub(crate) fn on_layout_switch(&mut self, name: String) -> Task<Message> {
self.on_layout_switch_inner(name, false)
}
pub(crate) fn on_block_edit_switch(&mut self, name: String) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Task::none();
}
let Some(target_index) = self.tabs[i]
.block_edits
.iter()
.position(|session| session.block_name == name)
else {
self.command_line
.push_error(&format!("BEDIT: block tab \"{name}\" is not open."));
return Task::none();
};
if self.tabs[i].active_block_edit == Some(target_index) {
return Task::none();
}
let cancel_task = self.cancel_active_command_for_space_change();
let current_camera = self.tabs[i].scene.camera.borrow().clone();
let current_ucs = self.tabs[i].active_ucs.clone();
if let Some(active_index) = self.tabs[i].active_block_edit {
if let Some(session) = self.tabs[i].block_edits.get_mut(active_index) {
session.editor_camera = current_camera;
session.editor_ucs = current_ucs;
}
} else {
self.tabs[i].scene.sync_camera_to_document();
}
let (br_handle, editor_camera) = {
let session = &self.tabs[i].block_edits[target_index];
(session.br_handle, session.editor_camera.clone())
};
self.layout_rename_state = None;
self.tabs[i].scene.active_viewport = None;
self.tabs[i].scene.set_current_layout("Model".to_string());
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].active_block_edit = Some(target_index);
*self.tabs[i].scene.camera.borrow_mut() = editor_camera;
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.tabs[i].scene.deselect_all();
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
self.tabs[i].refresh_active_ucs();
self.tabs[i].scene.bump_geometry_no_blocks();
self.refresh_properties();
self.adopt_view_display(i);
self.sync_dyn_fields();
cancel_task
}
/// MVIEW's "Insert view > New" flow deliberately visits Model space to
/// define a view and then returns to its paper layout. This is the only
/// layout transition allowed to preserve an active command.
pub(crate) fn on_layout_switch_preserving_command(
&mut self,
name: String,
) -> Task<Message> {
self.on_layout_switch_inner(name, true)
}
fn on_layout_switch_inner(
&mut self,
name: String,
preserve_active_command: bool,
) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
self.command_line
.push_info("Open or create a drawing to switch layouts.");
return Task::none();
}
let perf = crate::perf::enabled();
let perf_total = Instant::now();
let leaving_block_edit = self.tabs[i].active_block_edit.is_some();
let perf_from = self.tabs[i]
.active_block_edit_session()
.map(|session| session.block_name.clone())
.unwrap_or_else(|| self.tabs[i].scene.current_layout.clone());
let context_changed = leaving_block_edit
|| self.tabs[i].scene.current_layout != name
|| self.tabs[i].scene.active_viewport.is_some();
let preserve_active_command = preserve_active_command
&& self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() == "MVIEW");
if context_changed {
if preserve_active_command {
// MVIEW keeps its command-owned step data, but all host-owned
// cursor/snap/dynamic-input state belongs to the old space.
self.reset_space_interaction_state();
}
}
let cancel_task = if context_changed && !preserve_active_command {
self.cancel_active_command_for_space_change()
} else {
Task::none()
};
if let Some(active_index) = self.tabs[i].active_block_edit.take() {
let camera = self.tabs[i].scene.camera.borrow().clone();
let editor_ucs = self.tabs[i].active_ucs.clone();
let (return_layout, return_camera) = {
let session = &mut self.tabs[i].block_edits[active_index];
session.editor_camera = camera;
session.editor_ucs = editor_ucs;
(session.return_layout.clone(), session.return_camera.clone())
};
let return_layout = if self.tabs[i].scene.layout_names().contains(&return_layout) {
return_layout
} else {
"Model".to_string()
};
self.tabs[i].scene.block_edit_block = None;
self.tabs[i].scene.set_current_layout(return_layout);
*self.tabs[i].scene.camera.borrow_mut() = return_camera;
self.tabs[i].scene.camera_generation += 1;
self.tabs[i].scene.bump_geometry_no_blocks();
}
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).
let perf_phase = Instant::now();
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
let sync_ms = perf_phase.elapsed().as_secs_f64() * 1000.0;
// Cancel any pending rename and active viewport when switching.
self.layout_rename_state = None;
self.tabs[i].scene.active_viewport = None;
let perf_phase = Instant::now();
self.tabs[i].scene.set_current_layout(name.clone());
self.tabs[i].scene.deselect_all();
let switch_ms = perf_phase.elapsed().as_secs_f64() * 1000.0;
// UCS follows the pane: model header UCS in the Model tab, none
// in plain paper space (a viewport's UCS is adopted on entry).
let perf_phase = Instant::now();
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;
// `deselect_all` invalidates the scene highlight, but grips and the
// Properties panel are separate caches owned by the app.
self.refresh_properties();
// 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);
let restore_ms = perf_phase.elapsed().as_secs_f64() * 1000.0;
// 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 perf_phase = Instant::now();
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);
let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0;
if perf {
crate::perf_record!(
"[perf] layout-switch from={} to={} total={:.2}ms sync={:.2}ms switch={:.2}ms restore={:.2}ms layers={:.2}ms epoch={}",
perf_from,
name,
perf_total.elapsed().as_secs_f64() * 1000.0,
sync_ms,
switch_ms,
restore_ms,
layers_ms,
self.tabs[i].scene.geometry_epoch,
);
}
if context_changed {
self.sync_dyn_fields();
}
cancel_task
}
pub(super) fn on_layout_create(&mut self) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
self.command_line
.push_info("Open or create a drawing to add a layout.");
return Task::none();
}
let cancel_task = self.cancel_active_command_for_space_change();
// 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);
l.paper_width = 297.0;
l.paper_height = 210.0;
l.plot_paper_units = 1;
l.plot_scale_numerator = 1.0;
l.plot_scale_denominator = 1.0;
l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into();
break;
}
}
}
// 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);
let switch_task = self.on_layout_switch(new_name.clone());
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;
return Task::batch([cancel_task, switch_task]);
}
Err(e) => self
.command_line
.push_error(&format!("Failed to create layout: {e}")),
}
cancel_task
}
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;
// BEDIT block tab: renaming it renames the BLOCK itself —
// its record, marker and every INSERT reference. (#261)
let is_block_tab = self.tabs[i]
.active_block_edit_session()
.is_some_and(|session| session.block_name == orig);
if is_block_tab {
if self.tabs[i]
.scene
.document
.block_records
.get(&new_name)
.is_some()
{
self.command_line
.push_error(&format!("\"{}\" name already in use", new_name));
} else {
self.push_undo_snapshot(i, "BLOCK RENAME");
if self.tabs[i].scene.rename_block(&orig, &new_name) {
if let Some(session) =
self.tabs[i].active_block_edit_session_mut()
{
session.block_name = new_name.clone();
}
for session in &mut self.tabs[i].block_edits {
if session.return_block.as_deref() == Some(orig.as_str()) {
session.return_block = Some(new_name.clone());
}
}
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Block \"{orig}\"\"{new_name}\""));
} else {
self.command_line
.push_error(&format!("Could not rename block \"{orig}\""));
}
}
return Task::none();
}
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.set_current_layout(new_name.clone());
}
self.tabs[i].dirty = true;
self.command_line
.push_output(&format!("Layout \"{orig}\"\"{new_name}\""));
}
}
}
Task::none()
}
}