From 4db763ef27a7739520b5151eea1c01a071e6a8dd Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 24 Jul 2026 20:49:05 +0300 Subject: [PATCH] perf: unify spatial interaction queries Dense drawings previously rescanned full geometry for snapping, tracking, and selection.\n\nRefs #433 --- src/app/command_driver.rs | 120 +- src/app/update/viewport.rs | 5453 ++++++++++++++------------- src/scene/mod.rs | 691 +++- src/scene/pick/hit_test.rs | 313 +- src/scene/pick/interaction_index.rs | 433 +++ src/scene/pick/mod.rs | 6 +- src/scene/pick/wire_grid.rs | 178 - src/snap.rs | 837 ++-- 8 files changed, 4606 insertions(+), 3425 deletions(-) create mode 100644 src/scene/pick/interaction_index.rs delete mode 100644 src/scene/pick/wire_grid.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 589d838b0..cb7ed97ec 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -214,7 +214,10 @@ impl OpenCADStudio { // UCS (relative offsets are rotated by the UCS axes), so a multi- // token `LINE 0,0 10,10` under a rotated UCS lands correctly. let ucs = self.tabs[i].active_ucs.clone(); - let wcs = match (matches!(kind, super::helpers::CoordKind::Relative), self.last_point) { + let wcs = match ( + matches!(kind, super::helpers::CoordKind::Relative), + self.last_point, + ) { (true, Some(base)) => { base + match &ucs { Some(u) => super::helpers::ucs_rotate_vec(coord, u), @@ -257,8 +260,7 @@ impl OpenCADStudio { { // Remember the working set for the "Previous" selection keyword // (#426) before dropping it. - self.tabs[i].prev_selection = - self.tabs[i].scene.selected.iter().copied().collect(); + self.tabs[i].prev_selection = self.tabs[i].scene.selected.iter().copied().collect(); self.tabs[i].scene.deselect_all(); self.refresh_properties(); } @@ -549,8 +551,7 @@ impl OpenCADStudio { // Same gate as COPY (dimension-free), sized by the total number // of copies the array will add. let delta_safe = self.delta_copy_safe(i, &handles); - let pending = - self.begin_undo(i, label.clone(), handles.len() * count, delta_safe); + let pending = self.begin_undo(i, label.clone(), handles.len() * count, delta_safe); for t in &transforms { self.tabs[i].scene.copy_entities(&handles, t); } @@ -569,8 +570,7 @@ impl OpenCADStudio { } CmdResult::ReplaceMany(replacements, additions) => { let label = self.history_label_from_active_cmd(i, "FILLET"); - let was_catchment = self - .tabs[i] + let was_catchment = self.tabs[i] .active_cmd .as_ref() .is_some_and(|c| c.name() == "SS_CATCHMENT"); @@ -1161,7 +1161,9 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].scene.zoom_to_window(p1.as_vec3(), p2.as_vec3()); + self.tabs[i] + .scene + .zoom_to_window(p1.as_vec3(), p2.as_vec3()); self.command_line.push_output("Zoom Window"); } CmdResult::Measurement(msg) => { @@ -1335,10 +1337,8 @@ impl OpenCADStudio { .and_then(convert_to_polyline); match converted { Some(pl) => { - return self.apply_cmd_result(CmdResult::ReplaceEntity( - handle, - vec![pl], - )); + return self + .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![pl])); } None => self .command_line @@ -1457,9 +1457,8 @@ impl OpenCADStudio { let x1 = p1.x.max(p2.x); let y1 = p1.y.max(p2.y); self.plot_window = Some((x0, y0, x1, y1)); - self.command_line.push_output(&format!( - "Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}" - )); + self.command_line + .push_output(&format!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}")); // Pick window closed the plot dialog so the viewport could // receive the two clicks — bring the dialog back with the // window now active. @@ -1524,23 +1523,14 @@ impl OpenCADStudio { let mut handles: Vec = Vec::new(); { let scene = &self.tabs[i].scene; - let mut seen: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); - for w in scene.entity_wires().iter() { - let Some(h) = crate::scene::Scene::handle_from_wire_name(&w.name) else { - continue; - }; - if !seen.insert(h) || scene.is_layer_locked(h) { - continue; - } - let [ax, ay, bx, by] = w.aabb; - if ax as f64 <= win_max.x - && bx as f64 >= win_min.x - && ay as f64 <= win_max.y - && by as f64 >= win_min.y - { - handles.push(h); - } - } + handles.extend( + scene + .interaction_handles_in_world_aabb([ + win_min.x, win_min.y, win_max.x, win_max.y, + ]) + .into_iter() + .filter(|&h| !scene.is_layer_locked(h)), + ); } if handles.is_empty() { self.command_line @@ -1797,7 +1787,10 @@ impl OpenCADStudio { let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it let _ = color; // color is captured inside mesh_fn if let Some(mesh) = mesh_fn(name) { - self.tabs[i].scene.meshes.insert(handle, crate::scene::MeshLodSet::from_single(mesh)); + self.tabs[i] + .scene + .meshes + .insert(handle, crate::scene::MeshLodSet::from_single(mesh)); } self.tabs[i].dirty = true; self.command_line.push_output("Solid created."); @@ -1838,15 +1831,18 @@ impl OpenCADStudio { verts_low, normals, indices, - } => Some((crate::scene::model::mesh_model::MeshModel { - name: String::new(), - verts, - verts_low, - normals, - indices, - color, - selected: false, - }, solid)), + } => Some(( + crate::scene::model::mesh_model::MeshModel { + name: String::new(), + verts, + verts_low, + normals, + indices, + color, + selected: false, + }, + solid, + )), _ => None, } } @@ -1858,7 +1854,10 @@ impl OpenCADStudio { let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); - self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); + self.tabs[i] + .scene + .meshes + .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); // Keep the truck B-rep so the save path can export exact // ACIS geometry (else the solid is dropped by other CAD apps). self.tabs[i].scene.solid_models.insert(new_handle, solid); @@ -1937,7 +1936,10 @@ impl OpenCADStudio { let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); - self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); + self.tabs[i] + .scene + .meshes + .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.command_line .push_output(&format!("REVOLVE: solid created ({:.0}°).", angle_deg)); @@ -2102,7 +2104,10 @@ impl OpenCADStudio { let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); - self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); + self.tabs[i] + .scene + .meshes + .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.command_line.push_output("SWEEP: solid created."); } else { @@ -2180,14 +2185,18 @@ impl OpenCADStudio { }), _ => None, } - })(); + })( + ); if let Some(mut mesh) = result { self.push_undo_snapshot(i, "LOFT"); let new_entity = empty_solid3d(); let new_handle = self.tabs[i].scene.add_entity(new_entity); mesh.name = format!("{}", new_handle.value()); - self.tabs[i].scene.meshes.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); + self.tabs[i] + .scene + .meshes + .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "LOFT: solid created from {} profiles.", @@ -2275,9 +2284,11 @@ impl OpenCADStudio { return self.begin_text_edit(handle); } CmdResult::SuspendForTextEdit { handle } => { - let is_editable = crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document); + let is_editable = + crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document); if !is_editable { - self.command_line.push_error("TEXTEDIT: selected entity is not text."); + self.command_line + .push_error("TEXTEDIT: selected entity is not text."); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); @@ -2304,7 +2315,8 @@ impl OpenCADStudio { CmdResult::SetTexteditMode(val) => { self.texteditmode = val; let display_val = if val { 1 } else { 0 }; - self.command_line.push_output(&format!("TEXTEDITMODE set to {display_val}")); + self.command_line + .push_output(&format!("TEXTEDITMODE set to {display_val}")); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -2430,7 +2442,13 @@ impl OpenCADStudio { } let blocks = self.clipboard_deps.blocks.clone(); for def in blocks { - if self.tabs[i].scene.document.block_records.get(&def.name).is_some() { + if self.tabs[i] + .scene + .document + .block_records + .get(&def.name) + .is_some() + { continue; } self.tabs[i] diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7f5be604c..6a1619463 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5,13 +5,12 @@ 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, + ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -69,7 +68,6 @@ fn over_ucs_icon(p: Point, h: &crate::ui::overlay::UcsIconHit) -> bool { 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 @@ -120,7 +118,9 @@ impl OpenCADStudio { 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); + 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 @@ -179,8 +179,7 @@ impl OpenCADStudio { ) -> 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] + let plane = self.tabs[i] .active_ucs .as_ref() .filter(|_| self.tabs[i].editing_model_space()) @@ -217,11 +216,17 @@ impl OpenCADStudio { i: usize, edit_cam: &Option, bounds: iced::Rectangle, - ) -> (glam::Mat4, glam::DVec3, std::sync::Arc>) { + ) -> ( + glam::Mat4, + glam::DVec3, + std::sync::Arc>, + ) { 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), + 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) @@ -236,7 +241,6 @@ impl OpenCADStudio { } } - pub(in crate::app) fn update_grip_hover(&mut self, i: usize, p: iced::Point) { const HOVER_OPEN_MS: u128 = 600; const POPUP_DISMISS_PX: f32 = 80.0; @@ -356,1082 +360,1110 @@ impl OpenCADStudio { } } -pub(super) fn on_tick(&mut self, t: Instant) -> Task { - let i = self.active_tab; - self.tabs[i].scene.update(t - self.start); + pub(super) fn on_tick(&mut self, t: Instant) -> Task { + let i = self.active_tab; + self.tabs[i].scene.update(t - self.start); - // If the camera moved since we last synced, write it back to - // the document and mark the file dirty. - let gen = self.tabs[i].scene.camera_generation; - if gen != self.tabs[i].last_synced_camera_gen { - self.tabs[i].last_synced_camera_gen = gen; - if self.tabs[i].scene.sync_camera_to_document() { - self.tabs[i].dirty = true; - } - } + // If the camera moved since we last synced, write it back to + // the document and mark the file dirty. + let gen = self.tabs[i].scene.camera_generation; + if gen != self.tabs[i].last_synced_camera_gen { + self.tabs[i].last_synced_camera_gen = gen; + if self.tabs[i].scene.sync_camera_to_document() { + self.tabs[i].dirty = true; + } + } - // Surface any plugin-guard panic messages that piled up since - // the last tick (the host singleton isn't reachable from inside - // the plugin hooks, so they queue and we flush here). (#145) - #[cfg(not(target_arch = "wasm32"))] - for msg in crate::plugin::drain_errors() { - self.command_line.push_error(&msg); - } + // 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() + Task::none() } - pub(super) fn on_set_render_mode(&mut self, mode: acadrust::entities::ViewportRenderMode) -> Task { - use acadrust::entities::ViewportRenderMode as M; - let i = self.active_tab; - let label = match mode { - M::Wireframe2D => "Wireframe 2D", - M::Wireframe3D => "Wireframe 3D", - M::HiddenLine => "Hidden Line", - M::FlatShaded => "Flat Shaded", - M::GouraudShaded => "Gouraud Shaded", - M::FlatShadedWithEdges => "Flat Shaded + Edges", - M::GouraudShadedWithEdges => "Gouraud Shaded + Edges", - }; - // In a paper layout with an active (double-clicked) - // viewport, the picker drives that viewport entity's own - // render mode; the model-layout tab style is untouched. - if self.tabs[i].scene.set_active_viewport_render_mode(mode) { - self.tabs[i].scene.bump_geometry(); - self.command_line - .push_output(&format!("Viewport visual style: {label}")); - return Task::none(); - } - self.tabs[i].render_mode = mode; - // Write the style onto the active Model tile alone so it - // sticks when that tile loses focus and the other tiles keep - // their own styles. - self.tabs[i].scene.set_active_model_tile_render_mode(mode); - // Keep the legacy `wireframe` bool synced — both wireframe - // modes set it, everything else clears it. - let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D); - self.tabs[i].wireframe = wf; - self.ribbon.set_wireframe(wf); - self.tabs[i].visual_style = label.into(); - // Re-upload face3d fills on the next frame — the render - // pipeline keys its upload cache off `geometry_epoch`. - self.tabs[i].scene.bump_geometry(); - self.command_line - .push_output(&format!("Visual style: {label}")); - Task::none() + pub(super) fn on_set_render_mode( + &mut self, + mode: acadrust::entities::ViewportRenderMode, + ) -> Task { + use acadrust::entities::ViewportRenderMode as M; + let i = self.active_tab; + let label = match mode { + M::Wireframe2D => "Wireframe 2D", + M::Wireframe3D => "Wireframe 3D", + M::HiddenLine => "Hidden Line", + M::FlatShaded => "Flat Shaded", + M::GouraudShaded => "Gouraud Shaded", + M::FlatShadedWithEdges => "Flat Shaded + Edges", + M::GouraudShadedWithEdges => "Gouraud Shaded + Edges", + }; + // In a paper layout with an active (double-clicked) + // viewport, the picker drives that viewport entity's own + // render mode; the model-layout tab style is untouched. + if self.tabs[i].scene.set_active_viewport_render_mode(mode) { + self.tabs[i].scene.bump_geometry(); + self.command_line + .push_output(&format!("Viewport visual style: {label}")); + return Task::none(); + } + self.tabs[i].render_mode = mode; + // Write the style onto the active Model tile alone so it + // sticks when that tile loses focus and the other tiles keep + // their own styles. + self.tabs[i].scene.set_active_model_tile_render_mode(mode); + // Keep the legacy `wireframe` bool synced — both wireframe + // modes set it, everything else clears it. + let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D); + self.tabs[i].wireframe = wf; + self.ribbon.set_wireframe(wf); + self.tabs[i].visual_style = label.into(); + // Re-upload face3d fills on the next frame — the render + // pipeline keys its upload cache off `geometry_epoch`. + self.tabs[i].scene.bump_geometry(); + self.command_line + .push_output(&format!("Visual style: {label}")); + Task::none() } pub(super) fn on_cursor_moved(&mut self, p: Point) -> Task { - // `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); + // `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() + // 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 { - // A ribbon dropdown is open over the viewport. Its backdrop - // cannot swallow cursor motion — in iced 0.14 mouse_area/opaque - // capture only button presses, never CursorMoved — so the move - // leaks through the stack to the pane mouse_area beneath and - // would track the crosshair over the dropdown's empty areas. - // Drop the move here instead. (#227) - if self.ribbon.open_dropdown.is_some() { - return Task::none(); - } - let i = self.active_tab; + // A ribbon dropdown is open over the viewport. Its backdrop + // cannot swallow cursor motion — in iced 0.14 mouse_area/opaque + // capture only button presses, never CursorMoved — so the move + // leaks through the stack to the pane mouse_area beneath and + // would track the crosshair over the dropdown's empty areas. + // Drop the move here instead. (#227) + if self.ribbon.open_dropdown.is_some() { + return Task::none(); + } + let i = self.active_tab; - // UCS icon grip drag: map the cursor onto the UCS plane and - // slide the origin / rotate the axis. Short-circuits pan & snap. - if let Some(kind) = self.ucs_grip_drag { - self.drag_ucs_grip(i, kind, p); - self.tabs[i].scene.selection.borrow_mut().last_move_pos = Some(p); - return Task::none(); - } + // 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, - )); + // 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); + // 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); + // 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); + 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() { + 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); - if let Some(a) = sel.box_anchor { - sel.box_crossing = p.x < a.x; - } + 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.selection.borrow_mut().middle_last_pos = - Some(p); - return Task::none(); - } else if self.tabs[i].scene.current_layout == "Model" { - if sel.orbit_pivot.is_none() { - // Selection centre when something is selected, - // otherwise the point under the cursor. (#229) - sel.orbit_pivot = self.tabs[i] - .scene - .orbit_pivot() - .or(Some(self.tabs[i].last_cursor_world)); - } - let pivot = sel.orbit_pivot; - drop(sel); - self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy, pivot); - self.tabs[i].scene.camera_generation += 1; - self.tabs[i].scene.selection.borrow_mut().middle_last_pos = - Some(p); - return Task::none(); - } - // Paper sheet is top-locked: Shift+MMB falls through - // to the plain pan below. - } - // Pan scale uses the active tile's size (ortho size - // is relative to viewport height), so a tiled pane - // pans at the correct rate. - let bounds = self.tabs[i] - .scene - .active_model_tile_bounds(vp_size.0, vp_size.1); - // Drop `sel` before calling mutable scene methods. + 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); - 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.orbit_active_viewport(dx, dy); + self.tabs[i].scene.camera_generation += 1; + self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); + return Task::none(); + } else if self.tabs[i].scene.current_layout == "Model" { + if sel.orbit_pivot.is_none() { + // Selection centre when something is selected, + // otherwise the point under the cursor. (#229) + sel.orbit_pivot = self.tabs[i] + .scene + .orbit_pivot() + .or(Some(self.tabs[i].last_cursor_world)); } + let pivot = sel.orbit_pivot; + drop(sel); + self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy, pivot); + self.tabs[i].scene.camera_generation += 1; self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); return Task::none(); } - sel.middle_last_pos = Some(p); + // Paper sheet is top-locked: Shift+MMB falls through + // to the plain pan below. } - - let dragging = sel.left_down || sel.right_down || sel.middle_down; - let vp_size = sel.vp_size; + // 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); - - // The active pane already follows the cursor (each pane's own - // mouse_area focuses it via `PaneMove` → `focus_model_pane`), so - // the camera + tile bounds used for picking below are already the - // pane the cursor is in. - - // Tile-relative picking: shadow `p` with the cursor mapped - // into the active Model tile and `vp_size` with the tile's - // size, so every pick / snap / view_proj below operates in - // the active pane. `p_full` keeps the canvas-space cursor - // for screen overlays (cursor marker, snap glyph). - let p_full = p; - // Inside a floating viewport (MSPACE) the active "pane" is the - // viewport's own screen rectangle and its own camera — the very - // camera the GPU draws the content with. Routing picking / snap - // / projection through it makes in-viewport editing behave like - // the main model view (model coords, no paper round-trip) and - // track the viewport's pan / zoom / twist exactly. - let edit_frame = self.tabs[i].scene.viewport_edit_frame(vp_size); - let tile_b = match &edit_frame { - Some((_, full)) => *full, - None => self.tabs[i] - .scene - .active_model_tile_bounds(vp_size.0, vp_size.1), - }; - let edit_cam = edit_frame.map(|(cam, _)| cam); - let p = iced::Point { - x: p_full.x - tile_b.x, - y: p_full.y - tile_b.y, - }; - let vp_size = (tile_b.width, tile_b.height); - - // ── Grip drag ───────────────────────────────────────────── - if let Some(grip) = self.tabs[i].active_grip.clone() { - let (vw, vh) = vp_size; - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vw, - height: vh, - }; - // In a viewport, pick the cursor's model point with the - // viewport camera directly; otherwise project on the paper - // sheet and map to model. Either way `raw` is model space. - let raw = match &edit_cam { - Some(cam) => cam.pick_on_target_plane(p, bounds), - None => { - let paper = self - .tabs[i] - .scene - .camera - .borrow() - .pick_on_target_plane(p, bounds); - self.tabs[i].scene.paper_to_model(paper) - } - }; - let (view_rot, eye) = match &edit_cam { - Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), - None => { - let cam = self.tabs[i].scene.camera.borrow(); - (cam.view_proj_rte(bounds), cam.eye()) - } - }; - - // First move of this drag: hide the edited entity from the - // base tessellation (one re-tess) so subsequent moves only - // refresh a cheap overlay preview instead of re-tessellating - // the whole model on every move. - if self.grip_preview_handle != Some(grip.handle) { - if let Some(prev) = self.grip_preview_handle.take() { - self.tabs[i].scene.hidden.remove(&prev); - } - // Back up the original geometry so Esc can cancel the drag. - self.grip_original = - self.tabs[i].scene.document.get_entity(grip.handle).cloned(); - self.tabs[i].scene.hidden.insert(grip.handle); - // Grip drag never changes a block definition — keep the - // block cache so the hide doesn't re-tessellate blocks. - self.tabs[i].scene.bump_geometry_no_blocks(); - self.grip_preview_handle = Some(grip.handle); - // Snapshot the entity's glyph quads once so each move can - // slide the already-shaped text rather than re-shaping it - // (issue #316). The fast slide path only runs for a rigid - // whole-entity move of pure text: no wire geometry (so a - // dimension re-tessellates) and a Square insertion grip (so - // an MTEXT width handle, a Triangle, still re-tessellates so - // the re-wrap is exact). - let snap = self.tabs[i].scene.wire_models_for(&[grip.handle]); - self.grip_text_verts = - snap.iter().flat_map(|w| w.text_verts.iter().copied()).collect(); - let square_grip = self.tabs[i] - .selected_grips - .iter() - .find(|g| g.id == grip.grip_id) - .map(|g| g.shape == crate::scene::model::object::GripShape::Square) - .unwrap_or(false); - self.grip_text_slide = !self.grip_text_verts.is_empty() - && snap.iter().all(|w| w.points.is_empty()) - && square_grip; - } - - // The edited entity is hidden, so it's already absent from - // `hit_test_wires` — snap against the set directly, no clone - // and no self-snap. - let all_wires = if let (Some(_), Some(h)) = - (&edit_cam, self.tabs[i].scene.active_viewport) - { - self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height) - } else { - self.tabs[i].scene.hit_test_wires_near(raw, view_rot, bounds) - }; - // Grip drag has no single rubber-band origin for a perp foot. - self.snapper.from_point = None; - let (go, gr) = self.tabs[i].ucs_grid_basis(); - // `raw` is already model space (viewport camera or paper→model), - // and the wires are model space, so the snap result is model. - let snap_hit = self - .snapper - .snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr); - let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw); - self.tabs[i].snap_result = snap_hit; - if let Some(s) = self.tabs[i].snap_result.as_mut() { - s.screen.x += tile_b.x; - s.screen.y += tile_b.y; - } - - // 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 apply = if grip.is_translate { - GripApply::Translate(snapped - grip.last_world) - } else { - GripApply::Absolute(snapped) - }; + 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 - .apply_grip(grip.handle, grip.grip_id, apply); - self.tabs[i].dirty = true; - self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped; - // Overlay the moved entity (hidden from the base). Pure text - // moved as a whole slides its drag-start glyphs — no re-shaping - // and no wire re-tess. Anything else (wire geometry, or a point - // grip that reshapes text) is re-tessellated for an exact - // preview; either way the glyphs ride the preview-text buffer - // so dragged text never vanishes mid-drag (issue #316). - if self.grip_text_slide { - let d = snapped - grip.origin_world; - let slid = crate::scene::pipeline::text_gpu::translate_verts( - &self.grip_text_verts, - [d.x, d.y, d.z], - ); - self.tabs[i].scene.set_preview_text(slid); - self.tabs[i].scene.set_preview_wires(Vec::new()); - } else { - // Wire geometry, or a reshape grip: re-tessellate. The - // preview WireModels carry the entity's glyphs (gathered - // for the preview-text buffer in the render path), so a - // dimension / MTEXT-width drag keeps its text visible too. - let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]); - self.tabs[i].scene.set_preview_wires(preview); - } - self.refresh_selected_grips(); - self.refresh_properties(); - return Task::none(); + .camera + .borrow_mut() + .pan_screen(dx, dy, bounds.height); + self.tabs[i].scene.camera_generation += 1; + // Keep an in-progress box selection pinned to the + // drawing as the view pans under it (#234). + self.reproject_box_anchor(i, vp_size.0, vp_size.1); } + self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p); + return Task::none(); + } + sel.middle_last_pos = Some(p); + } - // 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; + let dragging = sel.left_down || sel.right_down || sel.middle_down; + let vp_size = sel.vp_size; + drop(sel); + + // The active pane already follows the cursor (each pane's own + // mouse_area focuses it via `PaneMove` → `focus_model_pane`), so + // the camera + tile bounds used for picking below are already the + // pane the cursor is in. + + // Tile-relative picking: shadow `p` with the cursor mapped + // into the active Model tile and `vp_size` with the tile's + // size, so every pick / snap / view_proj below operates in + // the active pane. `p_full` keeps the canvas-space cursor + // for screen overlays (cursor marker, snap glyph). + let p_full = p; + // Inside a floating viewport (MSPACE) the active "pane" is the + // viewport's own screen rectangle and its own camera — the very + // camera the GPU draws the content with. Routing picking / snap + // / projection through it makes in-viewport editing behave like + // the main model view (model coords, no paper round-trip) and + // track the viewport's pan / zoom / twist exactly. + let edit_frame = self.tabs[i].scene.viewport_edit_frame(vp_size); + let tile_b = match &edit_frame { + Some((_, full)) => *full, + None => self.tabs[i] + .scene + .active_model_tile_bounds(vp_size.0, vp_size.1), + }; + let edit_cam = edit_frame.map(|(cam, _)| cam); + let p = iced::Point { + x: p_full.x - tile_b.x, + y: p_full.y - tile_b.y, + }; + let vp_size = (tile_b.width, tile_b.height); + + // ── Grip drag ───────────────────────────────────────────── + if let Some(grip) = self.tabs[i].active_grip.clone() { + let (vw, vh) = vp_size; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vw, + height: vh, + }; + // In a viewport, pick the cursor's model point with the + // viewport camera directly; otherwise project on the paper + // sheet and map to model. Either way `raw` is model space. + let raw = match &edit_cam { + Some(cam) => cam.pick_on_target_plane(p, bounds), + None => { + let paper = self.tabs[i] + .scene + .camera + .borrow() + .pick_on_target_plane(p, bounds); + self.tabs[i].scene.paper_to_model(paper) } + }; + let (view_rot, eye) = match &edit_cam { + Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + } + }; - // Rollover highlight: when idle (no active command, no - // drag), defer the pick until the cursor stops. The full - // pick (wires + hatches + block hatches + shaded meshes) is - // O(N) per frame and stalls the cursor on large drawings, - // so each move clears the current highlight and resets the - // dwell timer — `HoverDwellTick` runs the hit-test only - // once the cursor has been still for `HOVER_DWELL_MS`. - if !dragging && self.tabs[i].active_cmd.is_none() { - self.tabs[i].scene.set_hover_highlight(None); - self.hover_dwell = Some(crate::app::HoverDwell { - last_move_at: Instant::now(), - point: p, - tile_size: vp_size, - tab: i, - }); + // First move of this drag: hide the edited entity from the + // base tessellation (one re-tess) so subsequent moves only + // refresh a cheap overlay preview instead of re-tessellating + // the whole model on every move. + if self.grip_preview_handle != Some(grip.handle) { + if let Some(prev) = self.grip_preview_handle.take() { + self.tabs[i].scene.hidden.remove(&prev); + } + // Back up the original geometry so Esc can cancel the drag. + self.grip_original = self.tabs[i].scene.document.get_entity(grip.handle).cloned(); + self.tabs[i].scene.hidden.insert(grip.handle); + // Grip drag never changes a block definition — keep the + // block cache so the hide doesn't re-tessellate blocks. + self.tabs[i].scene.bump_geometry_no_blocks(); + self.grip_preview_handle = Some(grip.handle); + // Snapshot the entity's glyph quads once so each move can + // slide the already-shaped text rather than re-shaping it + // (issue #316). The fast slide path only runs for a rigid + // whole-entity move of pure text: no wire geometry (so a + // dimension re-tessellates) and a Square insertion grip (so + // an MTEXT width handle, a Triangle, still re-tessellates so + // the re-wrap is exact). + let snap = self.tabs[i].scene.wire_models_for(&[grip.handle]); + self.grip_text_verts = snap + .iter() + .flat_map(|w| w.text_verts.iter().copied()) + .collect(); + let square_grip = self.tabs[i] + .selected_grips + .iter() + .find(|g| g.id == grip.grip_id) + .map(|g| g.shape == crate::scene::model::object::GripShape::Square) + .unwrap_or(false); + self.grip_text_slide = !self.grip_text_verts.is_empty() + && snap.iter().all(|w| w.points.is_empty()) + && square_grip; + } + + // The edited entity is hidden, so it's already absent from + // `hit_test_wires` — snap against the set directly, no clone + // and no self-snap. + let all_wires = + if let (Some(_), Some(h)) = (&edit_cam, self.tabs[i].scene.active_viewport) { + self.tabs[i] + .scene + .model_wires_for_viewport_arc(h, bounds.height) } else { - // Suppress the rollover during a command or a drag. - self.tabs[i].scene.set_hover_highlight(None); - self.hover_dwell = None; + self.tabs[i].scene.hit_test_wires() + }; + let snap_candidates = self.tabs[i].scene.interaction_candidates_near( + all_wires, + raw, + view_rot, + 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, + ); + } + } - if self.tabs[i].active_cmd.is_some() { - let (vw, vh) = vp_size; - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vw, - height: vh, - }; - // Inside a floating viewport the cursor, camera and wires are - // all model-space (the viewport's own camera draws the - // content), so snap / hit-test / preview run exactly like the - // main model view — no paper projection, tracks pan/zoom/twist. - let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let (view_rot, eye) = match &edit_cam { - Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), - None => { - let cam = self.tabs[i].scene.camera.borrow(); - (cam.view_proj_rte(bounds), cam.eye()) - } - }; - // Sync grid-snap spacing to the adaptive spacing of the visible grid. - self.snapper.grid_spacing = - crate::ui::overlay::compute_grid_step(view_rot, bounds); - // Cursor and wires are model-space; the snap result is model. - let snap_cursor = cursor_world; + let apply = if grip.is_translate { + GripApply::Translate(snapped - grip.last_world) + } else { + GripApply::Absolute(snapped) + }; + self.tabs[i] + .scene + .apply_grip(grip.handle, grip.grip_id, apply); + self.tabs[i].dirty = true; + self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped; + // Overlay the moved entity (hidden from the base). Pure text + // moved as a whole slides its drag-start glyphs — no re-shaping + // and no wire re-tess. Anything else (wire geometry, or a point + // grip that reshapes text) is re-tessellated for an exact + // preview; either way the glyphs ride the preview-text buffer + // so dragged text never vanishes mid-drag (issue #316). + if self.grip_text_slide { + let d = snapped - grip.origin_world; + let slid = crate::scene::pipeline::text_gpu::translate_verts( + &self.grip_text_verts, + [d.x, d.y, d.z], + ); + self.tabs[i].scene.set_preview_text(slid); + self.tabs[i].scene.set_preview_wires(Vec::new()); + } else { + // Wire geometry, or a reshape grip: re-tessellate. The + // preview WireModels carry the entity's glyphs (gathered + // for the preview-text buffer in the render path), so a + // dimension / MTEXT-width drag keeps its text visible too. + let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]); + self.tabs[i].scene.set_preview_wires(preview); + } + self.refresh_selected_grips(); + self.refresh_properties(); + return Task::none(); + } - 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) + // Keep the coordinate readout live on every move, even with no + // active command. When a command is running the snap path below + // overwrites this with the snapped point. + { + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vp_size.0, + height: vp_size.1, + }; + let world = self.cursor_model_point(i, &edit_cam, p, bounds); + self.tabs[i].last_cursor_world = world; + } + + // Rollover highlight: when idle (no active command, no + // drag), defer the pick until the cursor stops. The full + // pick (wires + hatches + block hatches + shaded meshes) is + // O(N) per frame and stalls the cursor on large drawings, + // so each move clears the current highlight and resets the + // dwell timer — `HoverDwellTick` runs the hit-test only + // once the cursor has been still for `HOVER_DWELL_MS`. + if !dragging && self.tabs[i].active_cmd.is_none() { + self.tabs[i].scene.set_hover_highlight(None); + self.hover_dwell = Some(crate::app::HoverDwell { + last_move_at: Instant::now(), + point: p, + tile_size: vp_size, + tab: i, + }); + } else { + // Suppress the rollover during a command or a drag. + self.tabs[i].scene.set_hover_highlight(None); + self.hover_dwell = None; + } + + if self.tabs[i].active_cmd.is_some() { + let (vw, vh) = vp_size; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vw, + height: vh, + }; + // Inside a floating viewport the cursor, camera and wires are + // all model-space (the viewport's own camera draws the + // content), so snap / hit-test / preview run exactly like the + // main model view — no paper projection, tracks pan/zoom/twist. + let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds); + let (view_rot, eye) = match &edit_cam { + Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + } + }; + // Sync grid-snap spacing to the adaptive spacing of the visible grid. + self.snapper.grid_spacing = crate::ui::overlay::compute_grid_step(view_rot, bounds); + // Cursor and wires are model-space; the snap result is model. + let snap_cursor = cursor_world; + + let all_wires = + if let (Some(_), Some(h)) = (&edit_cam, self.tabs[i].scene.active_viewport) { + self.tabs[i] + .scene + .model_wires_for_viewport_arc(h, bounds.height) + } else { + self.tabs[i].scene.hit_test_wires() + }; + let snap_candidates = self.tabs[i].scene.interaction_candidates_near( + all_wires, + snap_cursor, + view_rot, + 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 { - self.tabs[i] - .scene - .hit_test_wires_near(snap_cursor, view_rot, bounds) - }; - let needs_entity = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_entity_pick()) - .unwrap_or(false); - let needs_structure = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_structure_point_pick()) - .unwrap_or(false); - let is_gathering = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.is_selection_gathering()) - .unwrap_or(false); - // A selection-window corner (STRETCH crossing window) is a - // free point — Ortho/Polar must not pin it to an axis through - // the first corner, or the rectangle collapses to a line - // (#291). - let is_window_corner = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.window_corner_pick()) - .unwrap_or(false); - let needs_tan = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_tangent_pick()) - .unwrap_or(false); - self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure { None - } else if needs_tan { - self.snapper.snap_tangent_only( - snap_cursor.as_vec3(), - p, - &all_wires[..], - view_rot, - eye, - bounds, - ) - } else { - let (go, gr) = self.tabs[i].ucs_grid_basis(); - // The snapper is a screen-space (f32) engine; the f64 - // base only matters for typed-input precision, so hand it - // the downcast point here. - self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); - self.snapper - .snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr) }; + 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)); - // 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, - &all_wires[..], - view_rot, - eye, - bounds, - Instant::now(), - ); - // Parallel snap: acquire the reference line under the - // cursor (independent of OTRACK). (#277) - self.snapper.update_parallel( - cursor_world.as_vec3(), - &all_wires[..], - view_rot, - eye, - bounds, - Instant::now(), - ); - if self.tabs[i].snap_result.is_none() { - // A window corner is a free point, so neither the - // Polar increment nor the Ortho lock may pull the - // OTRACK alignment onto an axis (#291). - let step = if self.polar_mode && !is_window_corner { - Some(self.polar_increment_deg) - } else { - None - }; - let (_, ucs_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); - } + // 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() - }); + 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 { - 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, - ); - } + 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 hover_handle = - scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, self.tabs[i].scene.document.header.lineweight_display) - .and_then(|s| Scene::handle_from_wire_name(s)) - .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; + }; + // 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; - self.sync_dyn_fields(); - Task::none() + // Point-picked selection window (STRETCH): draw a filled + // crossing marquee from the first corner to the cursor so it + // reads like a normal box selection, not a bare outline (#291). + let window_first = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|c| c.window_first_corner()); + self.tabs[i].scene.selection.borrow_mut().preview_box = if is_window_corner { + window_first.map(|c1| (proj(c1), p_full, true)) + } else { + None + }; + + // Entity-pick previews (TRIM/EXTEND/FILLET…) compare the + // cursor against WCS document entities and return WCS wires. + // `effective` is offset-relative, so build a WCS copy for + // the click and shift the returned wires back to the + // offset-relative frame the renderer expects (model space + // only; paper-space entities use sheet coordinates). + let wo_local = if self.tabs[i].scene.current_layout == "Model" { + [0.0_f64; 3] + } else { + [0.0; 3] + }; + let wo_f = glam::DVec3::new(wo_local[0], wo_local[1], wo_local[2]); + let effective_wcs = effective + wo_f; + + // Orange object snap (plugin commands implement resolve_object_pick). + if needs_structure { + use crate::snap::{SnapResult, SnapType}; + let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| { + c.resolve_object_pick( + &self.tabs[i].scene, + effective.x as f64, + effective.y as f64, + ) + }); + if let Some(pick) = pick { + let world = glam::DVec3::new(pick.x, pick.y, effective.z); + let ndc = view_rot.project_point3((world - eye).as_vec3()); + let screen = iced::Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ); + self.tabs[i].snap_result = Some(SnapResult { + world, + screen, + snap_type: SnapType::ObjectPick, + tangent_obj: None, + extension_base: None, + extension_base2: None, + }); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_acquisition_hint(Some(pick.label)); + } + } else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_acquisition_hint(None); + } + } + + // Snap glyph is positioned in canvas space; shift the + // tile-local snap screen point back to the full canvas. + if let Some(s) = self.tabs[i].snap_result.as_mut() { + s.screen.x += tile_b.x; + s.screen.y += tile_b.y; + } + + // Give the command the current UCS + Ctrl state before it + // builds its rubber-band preview (and, by persistence, its + // commit). + self.push_ucs_to_cmd(i); + self.push_ctrl_to_cmd(i); + let mut previews = if needs_structure { + let mut p = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective)) + .unwrap_or_default(); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + p.extend(cmd.on_preview_wires(effective)); + } + p + } else if needs_entity { + let hover_handle = scene::pick::hit_test::click_hit( + p, + &snap_candidates, + view_rot, + eye, + bounds, + self.tabs[i].scene.document.header.lineweight_display, + ) + .and_then(|s| Scene::handle_from_wire_name(s)) + .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(); + Task::none() } pub(super) fn on_viewport_exit(&mut self) -> Task { - 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() + 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 { + fn ucs_icon_hit_info( + &self, + i: usize, + vw: f32, + vh: f32, + ) -> Option { if !self.show_ucs_icon || self.tabs[i].active_cmd.is_some() { return None; } @@ -1503,20 +1535,28 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { (cam.view_proj_rte(bounds), cam.eye()) } }; - let all_wires = if let (Some(_), Some(h)) = - (&edit_cam, self.tabs[i].scene.active_viewport) + 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) + self.tabs[i] + .scene + .model_wires_for_viewport_arc(h, bounds.height) } else { - self.tabs[i].scene.hit_test_wires_near(raw, view_rot, bounds) + self.tabs[i].scene.hit_test_wires() }; + let snap_candidates = self.tabs[i].scene.interaction_candidates_near( + all_wires, + raw, + view_rot, + bounds, + self.snapper.osnap_radius_px, + ); self.snapper.grid_spacing = crate::ui::overlay::compute_grid_step(view_rot, bounds); // No rubber-band origin (perp/extension feet don't apply to a free drag). self.snapper.from_point = None; let (go, gr) = self.tabs[i].ucs_grid_basis(); let snap_hit = self .snapper - .snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr); + .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() { @@ -1598,10 +1638,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { Task::none() } - pub(super) fn on_pane_clicked( - &mut self, - pane: iced::widget::pane_grid::Pane, - ) -> Task { + pub(super) fn on_pane_clicked(&mut self, pane: iced::widget::pane_grid::Pane) -> Task { let i = self.active_tab; if let Some(&idx) = self.tabs[i].scene.model_panes.get(pane) { self.focus_model_pane(idx); @@ -1624,1308 +1661,1269 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } pub(super) fn on_viewport_left_press(&mut self) -> Task { - 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() { + let i = self.active_tab; + // A left-click during a command resets the right-click cycle, so + // the next right-click acts as Enter again rather than opening + // the context menu. + self.tabs[i] + .scene + .selection + .borrow_mut() + .right_click_entered = false; + // A click in the viewport dismisses any open ribbon dropdown + // (e.g. the annotation style combo), which has no backdrop of + // its own to catch outside clicks. + self.ribbon.close_dropdown(); + // Likewise dismiss the Properties color dropdowns — a viewport + // press starts a box selection, so without this they'd only + // close on the second click (issue #104). + self.tabs[i].properties.color_picker_open = false; + self.tabs[i].properties.color_palette_open = false; + // Click anywhere outside the popup dismisses it. The + // menu's own buttons live above this mouse_area, so a + // press that reaches here means the cursor is not on + // any menu item. + if self.grip_popup.take().is_some() { + self.grip_hover = None; + return Task::none(); + } + // Outside-click dismiss for the visibility-state dropdown + // (its buttons sit above this mouse_area). + if self.visibility_popup.take().is_some() { + return Task::none(); + } + // Same dismiss-on-outside-click for the right-click + // context menu: its panel is opaque, so a press that + // reaches here is outside the menu. + { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + if sel.context_menu.take().is_some() { + return Task::none(); + } + } + let (p, vp_size) = { + let sel = self.tabs[i].scene.selection.borrow(); + let p = match sel.last_move_pos { + Some(p) => p, + None => return Task::none(), + }; + (p, sel.vp_size) + }; + let (vw, vh) = vp_size; + + // PAN mode: a left press begins a pan drag. Reuse the middle- + // button pan path (the move handler pans whenever `middle_down`), + // so no selection/pick logic runs while panning. + if self.tabs[i].pan_mode { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.middle_down = true; + sel.middle_last_pos = Some(p); + return Task::none(); + } + + if vw > 1.0 && vh > 1.0 { + let rot = self.tabs[i].scene.active_view_rotation_mat(); + // Map the cursor into whichever area owns the cube (active + // viewport rect in paper, active tile in model) so the + // consume-check lines up with the gizmo — same framing as + // ViewportClick's snap hit-test. + let (cx, cy, w, h) = match self.tabs[i] + .scene + .active_viewport + .and_then(|hndl| self.tabs[i].scene.viewport_screen_rect(hndl, (vw, vh))) + { + Some(rect) => (p.x - rect.x, p.y - rect.y, rect.width, rect.height), + None => { + let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh); + (p.x - tb.x, p.y - tb.y, tb.width, tb.height) + } + }; + if scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX).is_some() { + return Task::none(); + } + } + + // UCS icon: click to select (grips appear), then drag a grip to + // move/rotate. A click off the icon clears the selection. Works + // in the corner too (parked icon), not just at the origin. + if self.show_ucs_icon && self.tabs[i].active_cmd.is_none() { + if let Some(hit) = self.ucs_icon_hit_info(i, vw, vh) { + // Already selected → a press on a grip starts the drag. + if self.ucs_icon_selected { + if let Some(kind) = ucs_grip_under(p, &hit) { + self.ucs_grip_drag = Some(kind); + return Task::none(); + } + } + // Press on the icon body selects it (and shows grips). + if over_ucs_icon(p, &hit) { + self.ucs_icon_selected = true; + self.ucs_icon_hover = true; + return Task::none(); + } + } + // Press elsewhere drops the selection, then falls through to + // the normal pick / box-select so the click still lands. + self.ucs_icon_selected = false; + } + + // Divider resize is handled natively by the input pane_grid + // (`on_resize`); the active pane already follows the cursor via + // `focus_model_pane`. So a press here goes straight to picking. + + // From here the click targets the active tile: map the + // cursor into it and use the tile's size for picking, so + // grip / selection hit-tests land in the right pane. + let p_full = p; + // Inside a floating viewport the pane is the viewport's own rect + // + camera (matches the GPU); otherwise the active model tile. + let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); + let tile_b = match &edit_frame { + Some((_, full)) => *full, + None => self.tabs[i].scene.active_model_tile_bounds(vw, vh), + }; + let edit_cam = edit_frame.map(|(cam, _)| cam); + let p = iced::Point { + x: p_full.x - tile_b.x, + y: p_full.y - tile_b.y, + }; + let (vw, vh) = (tile_b.width, tile_b.height); + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vw, + height: vh, + }; + + if self.tabs[i].active_cmd.is_none() + && self.tabs[i].active_grip.is_none() + && !self.tabs[i].selected_grips.is_empty() + { + if let Some(handle) = self.tabs[i].selected_handle { + let is_paper = self.tabs[i].scene.current_layout != "Model"; + // In-viewport grips are model-space; project them with the + // viewport camera so they hit-test where the GPU draws + // them. Paper-space entities use the 2-D paper transform; + // the model tab uses the model camera. + let grip_hit = if let Some(cam) = &edit_cam { + find_hit_grip_rte( + p, + &self.tabs[i].selected_grips, + cam.view_proj_rte(bounds), + cam.eye(), + bounds, + ) + } else if is_paper { + let cam = self.tabs[i].scene.camera.borrow(); + let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; + let half_h = cam.ortho_size(); + let half_w = half_h * aspect; + let tx = cam.target.x as f32; + let ty = cam.target.y as f32; + drop(cam); + find_hit_grip_paper( + p, + &self.tabs[i].selected_grips, + tx, + ty, + half_w, + half_h, + bounds, + ) + } else { + let cam = self.tabs[i].scene.camera.borrow(); + find_hit_grip(p, &self.tabs[i].selected_grips, &cam, bounds) + }; + if let Some((grip_id, is_translate, world)) = grip_hit { + // The visibility (lookup) grip opens a state + // dropdown instead of starting a stretch drag. + if grip_id == crate::app::visibility::VIS_GRIP_ID { + self.open_visibility_popup(p); + self.grip_hover = None; + self.grip_popup = None; + return Task::none(); + } + self.tabs[i].active_grip = Some(GripEdit { + handle, + grip_id, + is_translate, + origin_world: world, + last_world: world, + }); self.grip_hover = None; + self.grip_popup = None; return Task::none(); } - // Outside-click dismiss for the visibility-state dropdown - // (its buttons sit above this mouse_area). - if self.visibility_popup.take().is_some() { - return Task::none(); - } - // Same dismiss-on-outside-click for the right-click - // context menu: its panel is opaque, so a press that - // reaches here is outside the menu. - { - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - if sel.context_menu.take().is_some() { - return Task::none(); - } - } - let (p, vp_size) = { - let sel = self.tabs[i].scene.selection.borrow(); - let p = match sel.last_move_pos { - Some(p) => p, - None => return Task::none(), - }; - (p, sel.vp_size) - }; - let (vw, vh) = vp_size; + } + } - // PAN mode: a left press begins a pan drag. Reuse the middle- - // button pan path (the move handler pans whenever `middle_down`), - // so no selection/pick logic runs while panning. - if self.tabs[i].pan_mode { - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.middle_down = true; - sel.middle_last_pos = Some(p); - return Task::none(); - } - - if vw > 1.0 && vh > 1.0 { - let rot = self.tabs[i].scene.active_view_rotation_mat(); - // Map the cursor into whichever area owns the cube (active - // viewport rect in paper, active tile in model) so the - // consume-check lines up with the gizmo — same framing as - // ViewportClick's snap hit-test. - let (cx, cy, w, h) = match self.tabs[i] - .scene - .active_viewport - .and_then(|hndl| self.tabs[i].scene.viewport_screen_rect(hndl, (vw, vh))) - { - Some(rect) => (p.x - rect.x, p.y - rect.y, rect.width, rect.height), - None => { - let tb = self.tabs[i].scene.active_model_tile_bounds(vw, vh); - (p.x - tb.x, p.y - tb.y, tb.width, tb.height) - } - }; - if scene::hit_test(cx, cy, w, h, rot, VIEWCUBE_PX).is_some() { - return Task::none(); - } - } - - // UCS icon: click to select (grips appear), then drag a grip to - // move/rotate. A click off the icon clears the selection. Works - // in the corner too (parked icon), not just at the origin. - if self.show_ucs_icon && self.tabs[i].active_cmd.is_none() { - if let Some(hit) = self.ucs_icon_hit_info(i, vw, vh) { - // Already selected → a press on a grip starts the drag. - if self.ucs_icon_selected { - if let Some(kind) = ucs_grip_under(p, &hit) { - self.ucs_grip_drag = Some(kind); - return Task::none(); - } - } - // Press on the icon body selects it (and shows grips). - if over_ucs_icon(p, &hit) { - self.ucs_icon_selected = true; - self.ucs_icon_hover = true; - return Task::none(); - } - } - // Press elsewhere drops the selection, then falls through to - // the normal pick / box-select so the click still lands. - self.ucs_icon_selected = false; - } - - // Divider resize is handled natively by the input pane_grid - // (`on_resize`); the active pane already follows the cursor via - // `focus_model_pane`. So a press here goes straight to picking. - - // From here the click targets the active tile: map the - // cursor into it and use the tile's size for picking, so - // grip / selection hit-tests land in the right pane. - let p_full = p; - // Inside a floating viewport the pane is the viewport's own rect - // + camera (matches the GPU); otherwise the active model tile. - let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); - let tile_b = match &edit_frame { - Some((_, full)) => *full, - None => self.tabs[i].scene.active_model_tile_bounds(vw, vh), - }; - let edit_cam = edit_frame.map(|(cam, _)| cam); - let p = iced::Point { - x: p_full.x - tile_b.x, - y: p_full.y - tile_b.y, - }; - let (vw, vh) = (tile_b.width, tile_b.height); - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vw, - height: vh, - }; - - if self.tabs[i].active_cmd.is_none() - && self.tabs[i].active_grip.is_none() - && !self.tabs[i].selected_grips.is_empty() - { - if let Some(handle) = self.tabs[i].selected_handle { - let is_paper = self.tabs[i].scene.current_layout != "Model"; - // In-viewport grips are model-space; project them with the - // viewport camera so they hit-test where the GPU draws - // them. Paper-space entities use the 2-D paper transform; - // the model tab uses the model camera. - let grip_hit = if let Some(cam) = &edit_cam { - find_hit_grip_rte( - p, - &self.tabs[i].selected_grips, - cam.view_proj_rte(bounds), - cam.eye(), - bounds, - ) - } else if is_paper { - let cam = self.tabs[i].scene.camera.borrow(); - let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; - let half_h = cam.ortho_size(); - let half_w = half_h * aspect; - let tx = cam.target.x as f32; - let ty = cam.target.y as f32; - drop(cam); - find_hit_grip_paper( - p, - &self.tabs[i].selected_grips, - tx, - ty, - half_w, - half_h, - bounds, - ) - } else { - let cam = self.tabs[i].scene.camera.borrow(); - find_hit_grip(p, &self.tabs[i].selected_grips, &cam, bounds) - }; - if let Some((grip_id, is_translate, world)) = grip_hit { - // The visibility (lookup) grip opens a state - // dropdown instead of starting a stretch drag. - if grip_id == crate::app::visibility::VIS_GRIP_ID { - self.open_visibility_popup(p); - self.grip_hover = None; - self.grip_popup = None; - return Task::none(); - } - self.tabs[i].active_grip = Some(GripEdit { - handle, - grip_id, - is_translate, - origin_world: world, - last_world: world, - }); - self.grip_hover = None; - self.grip_popup = None; - return Task::none(); - } - } - } - - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.left_down = true; - // Stored in full-canvas space (like ViewportMove's cursor and - // the overlay box / lasso drawing); release maps it into the - // active tile. Tile-local here would double-offset the anchor. - sel.left_press_pos = Some(p_full); - sel.left_press_time = Some(Instant::now()); - sel.left_dragging = false; - Task::none() + 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 { - let i = self.active_tab; + 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(); - } + // PAN mode: end the pan drag but stay in pan mode for the next + // drag (exit is Esc / another command). Mirror of the press. + if self.tabs[i].pan_mode { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.middle_down = false; + sel.middle_last_pos = None; + return Task::none(); + } - // Commit a UCS icon grip drag: persist the new UCS so it - // round-trips, and clear the lingering press state. - if self.ucs_grip_drag.take().is_some() { - self.tabs[i].persist_active_ucs(); - self.tabs[i].snap_result = None; - self.snapper.from_point = None; - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.left_down = false; - sel.left_press_pos = None; - sel.left_dragging = false; - return Task::none(); - } + // Commit a UCS icon grip drag: persist the new UCS so it + // round-trips, and clear the lingering press state. + if self.ucs_grip_drag.take().is_some() { + self.tabs[i].persist_active_ucs(); + self.tabs[i].snap_result = None; + self.snapper.from_point = None; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.left_down = false; + sel.left_press_pos = None; + sel.left_dragging = false; + return Task::none(); + } - let (p, is_click, is_down) = { - let sel = self.tabs[i].scene.selection.borrow(); - let p = match sel.last_move_pos { - Some(p) => p, - None => return Task::none(), - }; - (p, !sel.left_dragging, sel.left_down) - }; + let (p, is_click, is_down) = { + let sel = self.tabs[i].scene.selection.borrow(); + let p = match sel.last_move_pos { + Some(p) => p, + None => return Task::none(), + }; + (p, !sel.left_dragging, sel.left_down) + }; - // Grip editing: click-move-click (plus legacy press-drag). - // The grip engages on press (active_grip set). This release - // commits only if the grip has actually moved, or if it was a - // press-drag. A bare engaging click (no movement yet) keeps the - // grip hot so the user can move the cursor and click again to - // place it. Escape cancels (handled elsewhere). - if let Some(grip) = self.tabs[i].active_grip.clone() { - // Reset mouse state so the lingering press from the engaging - // click doesn't read as an in-progress drag on later moves. - { - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.left_down = false; - sel.left_press_pos = None; - sel.left_press_time = None; - sel.left_dragging = false; - } - let moved = grip.last_world != grip.origin_world; - if is_click && !moved { - // Engaging click — stay hot, wait for the placement click. - return Task::none(); - } - self.tabs[i].active_grip = None; - // Commit the grip drag: keep the doc's dragged geometry, - // un-hide the edited entity and re-tessellate the base - // once, dropping the overlay preview. - if let Some(h) = self.grip_preview_handle.take() { - // Undo entry for the drag (#332): the pre-drag backup - // swaps in, the PRE state is snapshotted, then the - // dragged result swaps back. Without this a grip edit - // (e.g. reshaping a polyline vertex) was un-undoable — - // the drag mutated the document without any history. - if let Some(orig) = self.grip_original.take() { - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) { - let dragged = std::mem::replace(e, orig); - let snap = self.capture_history_snapshot(i, "GRIP"); - if let Some(e2) = - self.tabs[i].scene.document.get_entity_mut(h) - { - *e2 = dragged; - } - self.tabs[i].history.undo_stack.push(snap); - self.tabs[i].history.redo_stack.clear(); - self.tabs[i].dirty = true; - } + // Grip editing: click-move-click (plus legacy press-drag). + // The grip engages on press (active_grip set). This release + // commits only if the grip has actually moved, or if it was a + // press-drag. A bare engaging click (no movement yet) keeps the + // grip hot so the user can move the cursor and click again to + // place it. Escape cancels (handled elsewhere). + if let Some(grip) = self.tabs[i].active_grip.clone() { + // Reset mouse state so the lingering press from the engaging + // click doesn't read as an in-progress drag on later moves. + { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.left_down = false; + sel.left_press_pos = None; + sel.left_press_time = None; + sel.left_dragging = false; + } + let moved = grip.last_world != grip.origin_world; + if is_click && !moved { + // Engaging click — stay hot, wait for the placement click. + return Task::none(); + } + self.tabs[i].active_grip = None; + // Commit the grip drag: keep the doc's dragged geometry, + // un-hide the edited entity and re-tessellate the base + // once, dropping the overlay preview. + if let Some(h) = self.grip_preview_handle.take() { + // Undo entry for the drag (#332): the pre-drag backup + // swaps in, the PRE state is snapshotted, then the + // dragged result swaps back. Without this a grip edit + // (e.g. reshaping a polyline vertex) was un-undoable — + // the drag mutated the document without any history. + if let Some(orig) = self.grip_original.take() { + if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) { + let dragged = std::mem::replace(e, orig); + let snap = self.capture_history_snapshot(i, "GRIP"); + if let Some(e2) = self.tabs[i].scene.document.get_entity_mut(h) { + *e2 = dragged; } - self.grip_text_verts = Vec::new(); - self.grip_text_slide = false; - self.tabs[i].scene.hidden.remove(&h); - self.tabs[i].scene.clear_preview_wire(); - // Only the dragged entity changed — re-tessellate just it. - self.tabs[i].scene.mark_entity_dirty(h); - self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i].history.undo_stack.push(snap); + self.tabs[i].history.redo_stack.clear(); + self.tabs[i].dirty = true; } - // Placement confirmed — keep the just-added leader. - self.grip_add_provisional = None; - self.tabs[i].snap_result = None; - self.refresh_properties(); - return Task::none(); } + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; + self.tabs[i].scene.hidden.remove(&h); + self.tabs[i].scene.clear_preview_wire(); + // Only the dragged entity changed — re-tessellate just it. + self.tabs[i].scene.mark_entity_dirty(h); + self.tabs[i].scene.bump_geometry_no_blocks(); + } + // Placement confirmed — keep the just-added leader. + self.grip_add_provisional = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + return Task::none(); + } - // Map the release point into the active Model tile so the - // click's pick / on_point / selection use the active pane's - // camera + bounds. `p_full` keeps the canvas point for the - // box/poly selection rectangle (drawn in canvas space). - let p_full = p; - // Inside a floating viewport the pane is the viewport's own rect - // and camera (see the MoveCursor path); pick / snap then run in - // model space exactly where the GPU draws the content. - let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size; - let edit_frame = self.tabs[i].scene.viewport_edit_frame(canvas_sz); - let (tile_vw, tile_vh, tile_off) = match &edit_frame { - Some((_, full)) => (full.width, full.height, iced::Point::new(full.x, full.y)), + // 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 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 c = self.tabs[i].scene.camera.borrow(); + (c.view_proj_rte(bounds), c.eye()) } }; - 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_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_taken = self.tabs[i].snap_result.take(); - let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj); - - let world_pt = { - // Cursor → model point (viewport camera inside a viewport, - // else paper sheet → model). Model space throughout. - let raw = self.cursor_model_point(i, &edit_cam, p, bounds); - let (view_rot, eye) = match &edit_cam { - Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), - None => { - let c = self.tabs[i].scene.camera.borrow(); - (c.view_proj_rte(bounds), c.eye()) - } - }; - let snap_cursor = raw; - let all_wires = if let (Some(_), Some(h)) = - (&edit_cam, self.tabs[i].scene.active_viewport) - { - self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height) - } else { - self.tabs[i] - .scene - .hit_test_wires_near(snap_cursor, view_rot, bounds) - }; - let needs_tan = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_tangent_pick()) - .unwrap_or(false); - let needs_entity_click = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_entity_pick()) - .unwrap_or(false); - let snap_hit = if needs_entity_click { - None - } else if needs_tan { - self.snapper - .snap_tangent_only(snap_cursor.as_vec3(), p, &all_wires[..], view_rot, eye, bounds) - } else { - let (go, gr) = self.tabs[i].ucs_grid_basis(); - self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); - self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr) - }; - // Snap runs in model space; the result is already model. - let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); - // When no UCS is active clamp to world XY; with a UCS the point is - // already constrained to that plane by the ray–plane intersection. - if self.tabs[i].active_ucs.is_none() { - pt.z = 0.0; - } - 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, + let snap_candidates = self.tabs[i].scene.interaction_candidates_near( + all_wires, + snap_cursor, + view_rot, + 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 ray–plane 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, - 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, - ); - } - } + 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 - }; + } + } + // 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]) - }; + // `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] + 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.needs_structure_point_pick()) + .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_candidates_near( + all_wires2, + world_pt, + view_rot2, + bounds, + scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + ); + 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)); + 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) { - 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)); + 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 = 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_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 + .push_error("HATCHEDIT: not a hatch entity."); + self.tabs[i].active_cmd = None; } - } else if self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_entity_pick()) - .unwrap_or(false) + } + // DIMTEDIT / MLEADERADD / MLEADERREMOVE: inject cloned entity via trait. { - let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds); - let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds, self.tabs[i].scene.document.header.lineweight_display) - .and_then(|s| Scene::handle_from_wire_name(s)); - if let Some(handle) = hit { - // Some commands (e.g. SS_CATCHMENT) need the entity - // body before `on_entity_pick` can advance. - let inject_first = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.inject_before_entity_pick()) - .unwrap_or(false); - if inject_first { - if let Some(entity) = - self.tabs[i].scene.document.get_entity(handle).cloned() - { - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - cmd.inject_picked_entity(entity); - } - } - } - - let 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 = - Box::new(HatcheditCommand::with_handle( - handle, - model.name.clone(), - model.scale, - model.angle_offset, - )); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(cmd); - } else { - self.command_line - .push_error("HATCHEDIT: not a hatch entity."); - self.tabs[i].active_cmd = None; - } - } - // DIMTEDIT / MLEADERADD / MLEADERREMOVE: inject cloned entity via trait. - { - let needs_inject = self.tabs[i] - .active_cmd - .as_ref() - .map(|c| { - matches!( - c.name(), - "DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE" - ) - }) - .unwrap_or(false); - if needs_inject { - if let Some(entity) = - self.tabs[i].scene.document.get_entity(handle).cloned() - { - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - cmd.inject_picked_entity(entity); - let prompt = cmd.prompt(); - self.command_line.push_info(&prompt); - } - } - } - } - result - } else { - self.command_line.push_info("Nothing found at that point."); - None - } - } else if self.tabs[i] - .active_cmd - .as_ref() - .map(|c| c.needs_tangent_pick()) - .unwrap_or(false) - { - if let Some(obj) = tangent_obj_at_click { - self.tabs[i] - .active_cmd - .as_mut() - .map(|c| c.on_tangent_point(obj, pick_wcs)) - } else { - self.command_line.push_info("Select a tangent object."); - None - } - } else { - // A scalar typed into the dynamic-input box but not - // yet confirmed with Enter is applied before the - // point pick — e.g. an OFFSET distance typed and then - // clicked takes effect rather than being discarded. - let wants_text = self.tabs[i] + let needs_inject = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| { + matches!(c.name(), "DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE") + }) .unwrap_or(false); - if wants_text { - if let Some(text) = self.tabs[i] - .dyn_fields - .iter() - .find_map(|f| f.buffer.clone()) + if needs_inject { + if let Some(entity) = + self.tabs[i].scene.document.get_entity(handle).cloned() { - 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); + 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); } - 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(); + result + } else { + self.command_line.push_info("Nothing found at that point."); + None } + } else if self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_tangent_pick()) + .unwrap_or(false) + { + if let Some(obj) = tangent_obj_at_click { + self.tabs[i] + .active_cmd + .as_mut() + .map(|c| c.on_tangent_point(obj, pick_wcs)) + } else { + self.command_line.push_info("Select a tangent object."); + None + } + } else { + // A scalar typed into the dynamic-input box but not + // yet confirmed with Enter is applied before the + // point pick — e.g. an OFFSET distance typed and then + // clicked takes effect rather than being discarded. + let wants_text = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.wants_text_input()) + .unwrap_or(false); + if wants_text { + if let Some(text) = self.tabs[i] + .dyn_fields + .iter() + .find_map(|f| f.buffer.clone()) + { + let text = crate::app::expr_eval::eval_to_string(text.trim()); + if let Some(c) = self.tabs[i].active_cmd.as_mut() { + c.on_text_input(&text); + } + for f in self.tabs[i].dyn_fields.iter_mut() { + f.buffer = None; + } + self.tabs[i].dyn_active = 0; + } + } + self.last_point = Some(world_pt); + // 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)) + } + }; - 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, - ) - }; + 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 mut selection_just_completed = false; + 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, + ) + }; - // 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, - }); + let mut selection_just_completed = false; - if is_down2 { - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vp_size.0, - height: vp_size.1, - }; + // 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_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 mut handles: Vec = scene::pick::hit_test::box_hit( - a, - p, - crossing, - &all_wires[..], - view_rot, - eye, - bounds, - ) + 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() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .collect(); - handles.extend(scene::pick::hit_test::box_hit_hatch( - a, - p, - crossing, - &self.tabs[i].scene.visible_hatches_for_click(), - view_rot, - eye, - bounds, - )); - handles.extend( - self.tabs[i].scene.mesh_box_hit(a, p, crossing, view_rot, eye, bounds), + .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 + }, ); - handles.extend(self.tabs[i].scene.block_mesh_box_hit( - a, p, crossing, view_rot, eye, bounds, - )); - // Objects on a locked layer aren't selectable. - handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h)); - // Box/lasso accumulates like individual picks - // (issue #83): a plain box adds to the current - // selection, Shift+box removes the boxed - // entities. Esc / empty-space click still clears. - // PICKADD 0 (#226): a plain box REPLACES. + let area_candidates = self.tabs[i] + .scene + .interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); + let candidate_handles = self.tabs[i] + .scene + .interaction_candidate_handles(&area_candidates); + let mut handles: Vec = 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(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 = 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, view_rot); + let candidate_handles = self.tabs[i] + .scene + .interaction_candidate_handles(&area_candidates); + let mut handles: Vec = 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(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_candidates_near( + all_wires, + click_world, + view_rot, + 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 = 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()[..], + 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 { - for h in &handles { - self.tabs[i].scene.deselect_entity(*h); + 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 { - 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.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 { - let (poly_pts, crossing) = { - let sel = self.tabs[i].scene.selection.borrow(); - // Map lasso points into the active tile. - let pts: Vec = sel - .poly_points - .iter() - .map(|pp| iced::Point { - x: pp.x - tile_off.x, - y: pp.y - tile_off.y, - }) - .collect(); - (pts, sel.poly_crossing) - }; - self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing; - let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - let mut handles: Vec = scene::pick::hit_test::poly_hit( - &poly_pts, - crossing, - &all_wires[..], - view_rot, - eye, - bounds, - ) - .into_iter() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .collect(); - handles.extend(scene::pick::hit_test::poly_hit_hatch( - &poly_pts, - crossing, - &self.tabs[i].scene.visible_hatches_for_click(), - view_rot, - eye, - bounds, - )); - handles.extend( - self.tabs[i].scene.mesh_poly_hit(&poly_pts, crossing, view_rot, eye, bounds), - ); - handles.extend(self.tabs[i].scene.block_mesh_poly_hit( - &poly_pts, crossing, view_rot, eye, bounds, - )); - // Selection filter: keep only allowed types. - handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); - // Objects on a locked layer aren't selectable. - handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h)); - // Accumulate like the box path (issue #83): plain - // lasso adds, Shift+lasso removes. An empty lasso - // leaves the current selection untouched so a stray - // drag never discards hard-won picks. - if self.shift_down { - for h in &handles { - self.tabs[i].scene.deselect_entity(*h); - } - } else { - // 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); + // 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(); } - self.refresh_properties(); - selection_just_completed = true; - } - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.poly_active = false; - sel.poly_points.clear(); - sel.poly_crossing = false; - sel.box_anchor = None; - sel.box_anchor_world = None; - sel.box_current = None; - } else { - if box_anchor.is_none() { - let (view_rot, eye, all_wires) = - self.pick_view(i, &edit_cam, bounds); - - // Selection cycling: where two or more objects - // overlap, open a list box to pick which one; a - // single object falls through to the normal click. - // Gated behind the toggle, so default picking is - // unchanged when off. - let mut handled_by_cycling = false; - if self.selection_cycling { - let cands: Vec = scene::pick::hit_test::click_hits_all( - p, - &all_wires[..], - 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, &all_wires[..], 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(), - view_rot, - eye, - bounds, - ) - }) - .or_else(|| { - // Block-internal hatch: resolve to the - // parent Insert (AutoCAD behaviour). - scene::pick::hit_test::click_hit_insert_hatch( - p, - &self.tabs[i].scene.insert_hatches_for_click()[..], - view_rot, - eye, - bounds, - ) - }) - .or_else(|| { - // 3D solids: click anywhere on the shaded - // body — top-level solids and block-internal - // ones together, front-most wins (a block in - // front of a solid resolves to the block). - self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds) - }); - // Selection filter: drop a pick whose type is excluded. - let hit = - hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h)); - if let Some(handle) = hit { - if let Some(layer) = - self.tabs[i].scene.locked_layer_name(handle) - { - // Locked layer: visible + snappable but - // not selectable. Report and do nothing - // else — in particular do NOT set - // `selection_just_completed`, or a - // gather command (MOVE's "select - // objects") would wrongly finish. - self.command_line.push_info(&format!( - "Object is on locked layer \"{layer}\" — unlock the layer to select or edit it." - )); - } else { - // Individual picks accumulate (issue #47): - // each plain click adds to the selection, - // Shift+click removes the picked entity. - // 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 mut handles: Vec = scene::pick::hit_test::box_hit( - a, - p, - crossing, - &all_wires[..], - view_rot, - eye, - bounds, - ) - .into_iter() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .collect(); - handles.extend(scene::pick::hit_test::box_hit_hatch( - a, - p, - crossing, - &self.tabs[i].scene.visible_hatches_for_click(), - view_rot, - eye, - bounds, - )); - handles.extend( - self.tabs[i].scene.mesh_box_hit(a, p, crossing, view_rot, eye, bounds), - ); - handles.extend(self.tabs[i].scene.block_mesh_box_hit( - a, p, crossing, view_rot, eye, bounds, - )); - // Selection filter: keep only allowed types. - handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); - // Objects on a locked layer aren't selectable. - handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h)); - // Accumulate (issue #83): a plain box adds to the - // current selection, Shift+box removes the boxed - // entities. An empty box leaves the selection alone - // so an accidental empty drag never discards it. - // 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(); + // 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(); - sel.box_last = Some((a, p)); - sel.box_last_crossing = crossing; - sel.box_anchor = None; - sel.box_anchor_world = None; - sel.box_current = None; + // 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; - 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 = self.tabs[i] - .scene - .selected_entities() + } 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() - .map(|(h, _)| h) - .collect(); - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - let result = cmd.on_selection_complete(handles); - return self.apply_cmd_result(result); - } - } - - // ── Double-click in Model Space: DDEDIT for Text/MText ──── - if is_click - && is_down - && self.tabs[i].active_cmd.is_none() - && self.tabs[i].scene.current_layout == "Model" - { - let now = Instant::now(); - let is_double_model = self - .last_vp_click_time - .map(|t| { - let dt = now.duration_since(t).as_millis(); - let last = self.last_vp_click_pos.unwrap_or(p); - let d = (p.x - last.x).hypot(p.y - last.y); - dt < 400 && d < 8.0 - }) - .unwrap_or(false); - - self.last_vp_click_time = Some(now); - self.last_vp_click_pos = Some(p); - - if is_double_model { - let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: vw, - height: vh, - }; - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye()) }; - let all_wires = self.tabs[i].scene.hit_test_wires(); - // Resolve the double-clicked object — its wire, or (for a - // block/solid with no wire under the cursor) its shaded - // body, which maps to the parent INSERT. - let hit = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, 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)); - 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 { - 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() - && self.tabs[i].block_edit.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 = { - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye()) }; - let all_wires = self.tabs[i].scene.hit_test_wires(); - scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, 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] + .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, view_rot); + let candidate_handles = self.tabs[i] + .scene + .interaction_candidate_handles(&area_candidates); + let mut handles: Vec = 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 - .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); + .visible_hatches_for_click(candidate_handles.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); } - } - - Task::none() - } - - pub(super) fn on_viewport_middle_press(&mut self) -> Task { - 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) - }; - { + self.refresh_properties(); 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); + 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; } - if is_double { - self.tabs[i].scene.fit_all(); - self.command_line.push_output("Zoom Extents"); - } - Task::none() - } + } - pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task { - 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 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 = 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, @@ -2933,48 +2931,287 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { 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); + 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_candidates_near( + all_wires, + click_world, + view_rot, + 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 { + 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() + && self.tabs[i].block_edit.is_none() + { + return Task::done(Message::Command(format!( + "BEDIT_BEGIN:{}", + handle.value() + ))); } - drop(cam); - self.tabs[i].scene.camera_generation += 1; - // Keep an in-progress box selection pinned to the drawing - // as the view zooms under it (#234). - self.reproject_box_anchor(i, vw, vh); } - Task::none() + } + } + + // ── 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 = { + 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_candidates_near( + all_wires, + click_world, + view_rot, + 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 { + let i = self.active_tab; + self.ribbon.close_dropdown(); + let now = Instant::now(); + let is_double = { + let sel = self.tabs[i].scene.selection.borrow(); + sel.middle_last_press_time + .map(|t| now.duration_since(t).as_millis() < 300) + .unwrap_or(false) + }; + { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + let Some(p) = sel.last_move_pos else { + return Task::none(); + }; + sel.middle_down = true; + sel.middle_last_pos = Some(p); + sel.middle_last_press_time = Some(now); + } + if is_double { + self.tabs[i].scene.fit_all(); + self.command_line.push_output("Zoom Extents"); + } + Task::none() + } + + pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task { + let s = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y * 0.01, + }; + let i = self.active_tab; + let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vw, + height: vh, + }; + if self.tabs[i].scene.active_viewport.is_some() { + // In MSPACE: zoom the active viewport's model-space view, + // keeping the model point under the cursor stationary. + let cursor_paper = cursor.map(|cp| { + let pt = self.tabs[i] + .scene + .camera + .borrow() + .pick_on_target_plane(cp, bounds); + glam::Vec2::new(pt.x as f32, pt.y as f32) + }); + self.tabs[i].scene.zoom_active_viewport(s, cursor_paper); + // Bump so the GPU re-uploads the viewport's re-culled wire + // set after zooming inside it. + self.tabs[i].scene.camera_generation += 1; + } else { + // Model space: zoom about the cursor within the active + // tile so the point under it stays put in that pane. + let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh); + let mut cam = self.tabs[i].scene.camera.borrow_mut(); + if let Some(cursor) = cursor { + let local = iced::Point { + x: cursor.x - tile_b.x, + y: cursor.y - tile_b.y, + }; + let tb = iced::Rectangle { + x: 0.0, + y: 0.0, + width: tile_b.width, + height: tile_b.height, + }; + cam.zoom_about_point(local, tb, s); + } else { + cam.zoom(s); + } + drop(cam); + self.tabs[i].scene.camera_generation += 1; + // Keep an in-progress box selection pinned to the drawing + // as the view zooms under it (#234). + self.reproject_box_anchor(i, vw, vh); + } + Task::none() } /// Re-pin an active box-selection anchor to its stored world point after the @@ -2991,327 +3228,363 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { width: tile_b.width, height: tile_b.height, }; - if let Some(sp) = self.tabs[i].scene.camera.borrow().project(world, tile_local) { + 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 { - 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() + 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 { - // 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) + // The cube is oriented in the active UCS, so snap in the UCS frame. + let r_ucs = self.tabs[self.active_tab].scene.viewcube_ucs_mat(); + self.snap_view_region(region, r_ucs) } /// Compass cardinals are world-fixed, so they snap in the world frame /// (no UCS composition). pub(super) fn on_view_cube_snap_world(&mut self, region: CubeRegion) -> Task { - self.snap_view_region(region, glam::Mat4::IDENTITY) + self.snap_view_region(region, glam::Mat4::IDENTITY) } fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task { - let i = self.active_tab; - let mut region = region; - // "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()); + 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() + // 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 { - let Some(dwell) = self.hover_dwell.clone() else { - return Task::none(); - }; - if Instant::now() - .duration_since(dwell.last_move_at) - .as_millis() - < crate::app::HOVER_DWELL_MS - { - return Task::none(); - } - let i = dwell.tab; - // Re-check the gate — drag / command may have started - // between the move that armed the dwell and this tick. - if i >= self.tabs.len() || self.tabs[i].active_cmd.is_some() { - self.hover_dwell = None; - return Task::none(); - } - let bounds = iced::Rectangle { - x: 0.0, - y: 0.0, - width: dwell.tile_size.0, - height: dwell.tile_size.1, - }; - let p = dwell.point; - // Inside a viewport, hover-pick through the viewport camera + - // model wires so the rollover highlights the entity under the - // cursor (dwell.point / dwell.tile_size are already pane-local). - let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size; - let edit_cam = self - .tabs[i] + let Some(dwell) = self.hover_dwell.clone() else { + return Task::none(); + }; + if Instant::now() + .duration_since(dwell.last_move_at) + .as_millis() + < crate::app::HOVER_DWELL_MS + { + return Task::none(); + } + let i = dwell.tab; + // Re-check the gate — drag / command may have started + // between the move that armed the dwell and this tick. + if i >= self.tabs.len() || self.tabs[i].active_cmd.is_some() { + self.hover_dwell = None; + return Task::none(); + } + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: dwell.tile_size.0, + height: dwell.tile_size.1, + }; + let p = dwell.point; + // Inside a viewport, hover-pick through the viewport camera + + // model wires so the rollover highlights the entity under the + // cursor (dwell.point / dwell.tile_size are already pane-local). + let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size; + let edit_cam = self.tabs[i] + .scene + .viewport_edit_frame(canvas_sz) + .map(|(cam, _)| cam); + let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); + let hover_world = self.cursor_model_point(i, &edit_cam, p, bounds); + let hover_candidates = self.tabs[i].scene.interaction_candidates_near( + all_wires, + hover_world, + view_rot, + bounds, + scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + ); + let candidate_handles = self.tabs[i] + .scene + .interaction_candidate_handles(&hover_candidates); + // Mirror the click-selection pick order so the rollover + // highlights every selectable object: wire → hatch → + // block-internal hatch → shaded 3D solid body. + let hovered = scene::pick::hit_test::click_hit( + p, + &hover_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 - .viewport_edit_frame(canvas_sz) - .map(|(cam, _)| cam); - let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - // Mirror the click-selection pick order so the rollover - // highlights every selectable object: wire → hatch → - // block-internal hatch → shaded 3D solid body. - let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds, 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(), - view_rot, - eye, - bounds, - ) - }) - .or_else(|| { - scene::pick::hit_test::click_hit_insert_hatch( - p, - &self.tabs[i].scene.insert_hatches_for_click()[..], - view_rot, - eye, - bounds, - ) - }) - .or_else(|| self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds)); - self.tabs[i].scene.set_hover_highlight(hovered); - self.hover_dwell = None; - Task::none() + .visible_hatches_for_click(candidate_handles.as_ref()), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + ) + }) + .or_else(|| { + scene::pick::hit_test::click_hit_insert_hatch( + p, + &self.tabs[i].scene.insert_hatches_for_click()[..], + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + ) + }) + .or_else(|| { + self.tabs[i] + .scene + .solid_click_hit(p, view_rot, eye, bounds, candidate_handles.as_ref()) + }); + self.tabs[i].scene.set_hover_highlight(hovered); + self.hover_dwell = None; + Task::none() } pub(super) fn on_layout_switch(&mut self, name: String) -> Task { - let i = self.active_tab; - // A BEDIT block editor locks the active space; finish it with - // Save Block or Discard before switching spaces. (#261) - if self.tabs[i].block_edit.is_some() { - self.command_line.push_info( - "Finish the block editor (Save Block or Discard) before switching spaces.", - ); - return Task::none(); - } - let going_to_paper = name != "Model"; - // Persist the camera of the layout we're leaving BEFORE switching - // so returning to it restores where the user left off (the - // periodic sync only fires on a tick, which may not have run - // since the last pan/zoom). - self.tabs[i].scene.sync_camera_to_document(); - self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; - // Cancel any pending rename/context-menu and active viewport when switching. - self.layout_rename_state = None; - self.layout_context_menu = None; - self.tabs[i].scene.active_viewport = None; - self.tabs[i].scene.set_current_layout(name); - self.tabs[i].scene.deselect_all(); - // UCS follows the pane: model header UCS in the Model tab, none - // in plain paper space (a viewport's UCS is adopted on entry). - self.tabs[i].refresh_active_ucs(); - self.tabs[i].scene.restore_saved_camera(); - self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; - // Grid/snap are per-view: load the layout we just entered (its - // sheet viewport in paper space, the model tile in model space) - // so model and each layout keep independent grid state. - self.adopt_view_display(i); - // Paper-space tools live in the right-edge side toolbar now, so - // entering/leaving a layout no longer hijacks the ribbon tab. - let _ = going_to_paper; - // Refresh VP freeze columns for the new layout. - let doc_layers = self.tabs[i].scene.document.layers.clone(); - let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); - Task::none() + let i = self.active_tab; + // A BEDIT block editor locks the active space; finish it with + // Save Block or Discard before switching spaces. (#261) + if self.tabs[i].block_edit.is_some() { + self.command_line.push_info( + "Finish the block editor (Save Block or Discard) before switching spaces.", + ); + return Task::none(); + } + let going_to_paper = name != "Model"; + // Persist the camera of the layout we're leaving BEFORE switching + // so returning to it restores where the user left off (the + // periodic sync only fires on a tick, which may not have run + // since the last pan/zoom). + self.tabs[i].scene.sync_camera_to_document(); + self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; + // Cancel any pending rename/context-menu and active viewport when switching. + self.layout_rename_state = None; + self.layout_context_menu = None; + self.tabs[i].scene.active_viewport = None; + self.tabs[i].scene.set_current_layout(name); + self.tabs[i].scene.deselect_all(); + // UCS follows the pane: model header UCS in the Model tab, none + // in plain paper space (a viewport's UCS is adopted on entry). + self.tabs[i].refresh_active_ucs(); + self.tabs[i].scene.restore_saved_camera(); + self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; + // Grid/snap are per-view: load the layout we just entered (its + // sheet viewport in paper space, the model tile in model space) + // so model and each layout keep independent grid state. + self.adopt_view_display(i); + // Paper-space tools live in the right-edge side toolbar now, so + // entering/leaving a layout no longer hijacks the ribbon tab. + let _ = going_to_paper; + // Refresh VP freeze columns for the new layout. + let doc_layers = self.tabs[i].scene.document.layers.clone(); + let vp_info = self.tabs[i].scene.viewport_list(); + self.tabs[i] + .layers + .sync_with_viewports(&doc_layers, vp_info); + Task::none() } pub(super) fn on_layout_create(&mut self) -> Task { - let i = self.active_tab; - // Find a unique name (e.g. Layout2, Layout3, ...). - let existing = self.tabs[i].scene.layout_names(); - let mut idx = existing.len(); - let new_name = loop { - let candidate = format!("Layout{}", idx); - if !existing.contains(&candidate) { - break candidate; - } - idx += 1; - }; - self.push_undo_snapshot(i, "LAYOUT"); - match self.tabs[i].scene.document.add_layout(&new_name) { - Ok(_) => { - // Override the acadrust default limits (12×9 imperial) with A4 landscape. - for obj in self.tabs[i].scene.document.objects.values_mut() { - if let acadrust::objects::ObjectType::Layout(l) = obj { - if l.name == new_name { - l.min_limits = (0.0, 0.0); - l.max_limits = (297.0, 210.0); - l.min_extents = (0.0, 0.0, 0.0); - l.max_extents = (297.0, 210.0, 0.0); - break; - } - } + let i = self.active_tab; + // Find a unique name (e.g. Layout2, Layout3, ...). + let existing = self.tabs[i].scene.layout_names(); + let mut idx = existing.len(); + let new_name = loop { + let candidate = format!("Layout{}", idx); + if !existing.contains(&candidate) { + break candidate; + } + idx += 1; + }; + self.push_undo_snapshot(i, "LAYOUT"); + match self.tabs[i].scene.document.add_layout(&new_name) { + Ok(_) => { + // Override the acadrust default limits (12×9 imperial) with A4 landscape. + for obj in self.tabs[i].scene.document.objects.values_mut() { + if let acadrust::objects::ObjectType::Layout(l) = obj { + if l.name == new_name { + l.min_limits = (0.0, 0.0); + l.max_limits = (297.0, 210.0); + l.min_extents = (0.0, 0.0, 0.0); + l.max_extents = (297.0, 210.0, 0.0); + break; } - self.tabs[i].scene.set_current_layout(new_name.clone()); - // Safety net — `add_layout` already creates the overall - // sheet viewport; this covers any path that doesn't. - self.tabs[i].scene.ensure_sheet_viewport(&new_name); - self.tabs[i].scene.deselect_all(); - self.tabs[i].scene.fit_all(); - self.command_line.push_output(&format!( - "Layout \"{new_name}\" created — use MVIEW to add a viewport" - )); - self.tabs[i].dirty = true; } - Err(e) => self - .command_line - .push_error(&format!("Failed to create layout: {e}")), } - Task::none() + self.tabs[i].scene.set_current_layout(new_name.clone()); + // Safety net — `add_layout` already creates the overall + // sheet viewport; this covers any path that doesn't. + self.tabs[i].scene.ensure_sheet_viewport(&new_name); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.fit_all(); + self.command_line.push_output(&format!( + "Layout \"{new_name}\" created — use MVIEW to add a viewport" + )); + self.tabs[i].dirty = true; + } + Err(e) => self + .command_line + .push_error(&format!("Failed to create layout: {e}")), + } + Task::none() } pub(super) fn on_layout_rename_commit(&mut self) -> Task { - 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] - .block_edit - .as_ref() - .map(|be| be.block_name == orig) - .unwrap_or(false); - 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(be) = self.tabs[i].block_edit.as_mut() { - be.block_name = 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()); + 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] + .block_edit + .as_ref() + .map(|be| be.block_name == orig) + .unwrap_or(false); + 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(be) = self.tabs[i].block_edit.as_mut() { + be.block_name = new_name.clone(); } self.tabs[i].dirty = true; self.command_line - .push_output(&format!("Layout \"{orig}\" → \"{new_name}\"")); + .push_output(&format!("Block \"{orig}\" → \"{new_name}\"")); + } else { + self.command_line + .push_error(&format!("Could not rename block \"{orig}\"")); } } + return Task::none(); } - 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() } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b69a3f844..33af13b2c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -16,10 +16,9 @@ pub mod view; // Topic submodules split out of this root (each contributes `impl Scene` // blocks and/or free functions). Pure text-move from the original mod.rs. +mod camera_ops; mod entity; mod group_layer; -mod scene_markers; -mod camera_ops; mod layout; mod modify; mod mspace; @@ -27,6 +26,7 @@ mod page_setup; mod paper; mod preview; mod project; +mod scene_markers; mod selection; // Parallel tessellation free functions live in `convert::tess` (alongside the @@ -45,19 +45,40 @@ pub(super) struct EntityIndex { pub unbounded_handles: Vec, } -use view::camera::Camera; -pub use view::camera::Projection; +fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> { + let mut aabb = [ + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ]; + for [x, y] in hatch.boundary.iter().copied() { + if !x.is_finite() || !y.is_finite() { + continue; + } + let x = hatch.world_origin[0] + x as f64; + let y = hatch.world_origin[1] + y as f64; + aabb[0] = aabb[0].min(x); + aabb[1] = aabb[1].min(y); + aabb[2] = aabb[2].max(x); + aabb[3] = aabb[3].max(y); + } + aabb.iter().all(|value| value.is_finite()).then_some(aabb) +} + pub use model::hatch_model::HatchModel; pub use model::image_model::ImageModel; pub use model::mesh_model::MeshLodSet; pub use model::object::{GripApply, GripDef}; +pub use model::wire_model::WireModel; +pub use pick::selection_state::SelectionState; pub use pipeline::uniforms::Uniforms; pub use pipeline::viewcube::{ hit_test, hit_test_cardinal, hover_id, CubeRegion, NudgeDir, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, VIEWCUBE_REGION_PX, }; -pub use pick::selection_state::SelectionState; -pub use model::wire_model::WireModel; +use view::camera::Camera; +pub use view::camera::Projection; use crate::command::EntityTransform; use acadrust::entities::{Block, BlockEnd, Insert as DxfInsert}; @@ -74,8 +95,8 @@ use truck_modeling::{ }; use iced::time::Duration; -use std::cell::RefCell; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use std::cell::RefCell; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -311,9 +332,10 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) => { image_handles.push(h) } - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => { - mesh_handles.push(h) - } + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) => mesh_handles.push(h), _ => {} } if let Some(c) = offset_centroid(e, model_block, &prep) { @@ -348,12 +370,8 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { let images: HashMap = image_handles .par_iter() .filter_map(|&handle| match doc.get_entity(handle)? { - EntityType::RasterImage(img) => { - ImageModel::from_raster_image(img).map(|m| (handle, m)) - } - EntityType::Ole2Frame(ole) => { - ImageModel::from_ole2frame(ole).map(|m| (handle, m)) - } + EntityType::RasterImage(img) => ImageModel::from_raster_image(img).map(|m| (handle, m)), + EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { ImageModel::from_underlay(u, def).map(|m| (handle, m)) @@ -440,10 +458,7 @@ struct OffsetPrep { } fn offset_prep(doc: &acadrust::CadDocument, model_block: Handle) -> OffsetPrep { - let model_br = doc - .block_records - .iter() - .find(|br| br.handle == model_block); + let model_br = doc.block_records.iter().find(|br| br.handle == model_block); let mspace_set: Option> = model_br .filter(|br| !br.entity_handles.is_empty()) .map(|br| br.entity_handles.iter().copied().collect()); @@ -460,18 +475,18 @@ fn offset_prep(doc: &acadrust::CadDocument, model_block: Handle) -> OffsetPrep { } else { rustc_hash::FxHashSet::default() }; - OffsetPrep { mspace_set, any_enumerated, owned_by_other_block } + OffsetPrep { + mspace_set, + any_enumerated, + owned_by_other_block, + } } /// Per-entity centroid for the world-offset scan, or `None` if the entity is /// not MSPACE geometry / has no usable bbox. Single-outlier-robust because /// the caller takes the median of these per-entity centroids rather than a /// global min/max midpoint. -fn offset_centroid( - e: &EntityType, - model_block: Handle, - prep: &OffsetPrep, -) -> Option<[f64; 3]> { +fn offset_centroid(e: &EntityType, model_block: Handle, prep: &OffsetPrep) -> Option<[f64; 3]> { let c = e.common(); let h = c.handle; let include = if let Some(ref set) = prep.mspace_set { @@ -683,11 +698,23 @@ fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet { let hy = ay as f32; let hz = az as f32; *v = [hx, hy, hz]; - *vl = [(ax - hx as f64) as f32, (ay - hy as f64) as f32, (az - hz as f64) as f32]; - if hx < min_x { min_x = hx; } - if hy < min_y { min_y = hy; } - if hx > max_x { max_x = hx; } - if hy > max_y { max_y = hy; } + *vl = [ + (ax - hx as f64) as f32, + (ay - hy as f64) as f32, + (az - hz as f64) as f32, + ]; + if hx < min_x { + min_x = hx; + } + if hy < min_y { + min_y = hy; + } + if hx > max_x { + max_x = hx; + } + if hy > max_y { + max_y = hy; + } } } // Re-split the feature edges the same way so they track the mesh at scale. @@ -702,7 +729,11 @@ fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet { let az = v[2] as f64 + vl[2] as f64; let (hx, hy, hz) = (ax as f32, ay as f32, az as f32); *v = [hx, hy, hz]; - *vl = [(ax - hx as f64) as f32, (ay - hy as f64) as f32, (az - hz as f64) as f32]; + *vl = [ + (ax - hx as f64) as f32, + (ay - hy as f64) as f32, + (az - hz as f64) as f32, + ]; } } if min_x.is_finite() { @@ -752,11 +783,23 @@ fn transform_block_mesh_lod_set( let hy = ay as f32; let hz = az as f32; *v = [hx, hy, hz]; - *vl = [(ax - hx as f64) as f32, (ay - hy as f64) as f32, (az - hz as f64) as f32]; - if hx < min_x { min_x = hx; } - if hy < min_y { min_y = hy; } - if hx > max_x { max_x = hx; } - if hy > max_y { max_y = hy; } + *vl = [ + (ax - hx as f64) as f32, + (ay - hy as f64) as f32, + (az - hz as f64) as f32, + ]; + if hx < min_x { + min_x = hx; + } + if hy < min_y { + min_y = hy; + } + if hx > max_x { + max_x = hx; + } + if hy > max_y { + max_y = hy; + } } for n in &mut lod.normals { let d = xform.apply_rotation(Vector3::new(n[0] as f64, n[1] as f64, n[2] as f64)); @@ -782,7 +825,11 @@ fn transform_block_mesh_lod_set( )); let (hx, hy, hz) = (w.x as f32, w.y as f32, w.z as f32); *v = [hx, hy, hz]; - *vl = [(w.x - hx as f64) as f32, (w.y - hy as f64) as f32, (w.z - hz as f64) as f32]; + *vl = [ + (w.x - hx as f64) as f32, + (w.y - hy as f64) as f32, + (w.z - hz as f64) as f32, + ]; } } if min_x.is_finite() { @@ -808,9 +855,8 @@ pub struct Scene { /// Small map (not a single slot): a paper frame renders several wire /// sources (sheet + content viewports), each with its own stable content /// id — one slot would thrash between them every frame. - sdf_text_cache: RefCell< - HashMap>>, - >, + sdf_text_cache: + RefCell>>>, /// Per wire-source `(geometry_epoch, gathered text)` of the most recent /// SDF-text build. When a new content id misses the cache but the journal /// shows no text-bearing entity changed since that epoch, the same glyphs @@ -820,7 +866,13 @@ pub struct Scene { /// sets in one frame, and a single slot handed the sheet's glyphs to the /// content viewports, hiding every model-space text entity (#403). last_sdf_text: RefCell< - HashMap>)>, + HashMap< + u64, + ( + u64, + std::sync::Arc>, + ), + >, >, /// pane_grid layout tree for the Model tab — the source of truth for the /// tile split layout, resize and focus. `model_tiles` (the renderer's @@ -884,11 +936,25 @@ pub struct Scene { /// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation. /// The middle `u64` is the set's [`WIRE_CONTENT_GEN`] content id. wire_cache: RefCell>)>>, - /// Spatial grid over the model hit-test wire set, keyed on `geometry_epoch` - /// so it is rebuilt only when geometry changes (not per cursor move). Lets - /// snap / hover query a cursor-local wire subset instead of scanning the - /// whole (block-exploded, multi-million-wire) set every move. - wire_grid_cache: RefCell)>>, + /// Shared wire + segment broad phase for snapping, hover, click/cycling and + /// area selection. The source pointer is part of the key because a camera + /// cull can replace the wire Arc without changing `geometry_epoch`. + interaction_index_cache: RefCell< + Option<( + u64, + usize, + std::sync::Weak>, + Arc, + )>, + >, + /// Auxiliary broad phase for objects that have no wire representation: + /// hatch-only and solid-only block instances. + interaction_handle_index_cache: RefCell< + Option<( + u64, + Arc, + )>, + >, /// Index built from every SortEntitiesTable in the document. /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. @@ -1076,8 +1142,7 @@ pub struct Scene { /// incremental patch while its `Arc` is uniquely held, so pinning it in /// this cache would silently force a full rebuild on every edit (#358). #[allow(clippy::type_complexity)] - split_cache: - RefCell>, Option>>)>>, + split_cache: RefCell>, Option>>)>>, /// Cached `selected ∪ hover` handle set for the GPU xray overlay, keyed by /// `selection_generation`. Rebuilt only when the selection changes so /// `build_primitive` doesn't clone the set every frame. @@ -1166,7 +1231,8 @@ impl Scene { block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, wire_cache: RefCell::new(None), - wire_grid_cache: RefCell::new(None), + interaction_index_cache: RefCell::new(None), + interaction_handle_index_cache: RefCell::new(None), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), hatch_cache: RefCell::new(None), @@ -1345,7 +1411,11 @@ impl Scene { /// journal never has a gap (a gap would let a consumer serve stale geometry). fn push_geometry_delta(&self, epoch: u64, changes: Vec<(Handle, ChangeKind)>, full: bool) { let mut ring = self.geometry_deltas.borrow_mut(); - ring.push_back(GeometryDelta { epoch, changes, full }); + ring.push_back(GeometryDelta { + epoch, + changes, + full, + }); while ring.len() > GEOMETRY_JOURNAL_CAP { if let Some(ev) = ring.pop_front() { self.geometry_journal_floor.set(ev.epoch); @@ -1711,9 +1781,9 @@ impl Scene { /// (hatched) frame actually renders after the cursor stops, even when no /// input event would otherwise trigger a redraw. Read-only (no side effect). pub fn is_settling(&self) -> bool { - self.nav_changed_at - .get() - .map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS + 130) + self.nav_changed_at.get().map_or(false, |t| { + t.elapsed().as_millis() < Self::NAV_SETTLE_MS + 130 + }) } /// Re-evaluate every cached mesh's color through `render_style` so a @@ -1850,16 +1920,20 @@ impl Scene { } fn current_layout_sheet_viewport_handle(&self) -> Handle { - self.document.objects.values().find_map(|obj| { - let ObjectType::Layout(layout) = obj else { - return None; - }; - if layout.name == self.current_layout { - Some(layout.viewport) - } else { - None - } - }).unwrap_or(Handle::NULL) + self.document + .objects + .values() + .find_map(|obj| { + let ObjectType::Layout(layout) = obj else { + return None; + }; + if layout.name == self.current_layout { + Some(layout.viewport) + } else { + None + } + }) + .unwrap_or(Handle::NULL) } /// Guarantee that a paper layout has its full-screen overall (`id == 1`) @@ -1948,9 +2022,9 @@ impl Scene { ); vp.view_center = acadrust::types::Vector3::ZERO; vp.view_height = ph * 1.1; - if let Ok(handle) = - self.document - .add_entity_to_layout(EntityType::Viewport(vp), layout_name) + if let Ok(handle) = self + .document + .add_entity_to_layout(EntityType::Viewport(vp), layout_name) { if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) { l.viewport = handle; @@ -2117,21 +2191,20 @@ impl Scene { return None; } let ((x0, y0), (x1, y1)) = self.paper_limits()?; - let (left, bottom, right, top, rot) = - self.document.objects.values().find_map(|obj| { - if let ObjectType::Layout(l) = obj { - if l.name == self.current_layout { - return Some(( - l.plot_margin_left, - l.plot_margin_bottom, - l.plot_margin_right, - l.plot_margin_top, - l.plot_rotation, - )); - } + let (left, bottom, right, top, rot) = self.document.objects.values().find_map(|obj| { + if let ObjectType::Layout(l) = obj { + if l.name == self.current_layout { + return Some(( + l.plot_margin_left, + l.plot_margin_bottom, + l.plot_margin_right, + l.plot_margin_top, + l.plot_rotation, + )); } - None - })?; + } + None + })?; // `paper_limits()` already swaps the sheet for a 90°/270° rotation, so the // margins must rotate to the same edges: a margin on a physical side moves // to the displayed side that side rotates onto. @@ -2204,7 +2277,9 @@ impl Scene { return Some(ps); } self.document.objects.values().find_map(|o| { - let ObjectType::Layout(l) = o else { return None }; + let ObjectType::Layout(l) = o else { + return None; + }; if l.name.as_str() != name { return None; } @@ -2413,7 +2488,9 @@ impl Scene { // units, else derive drawing units from the factor (paper = 1). let (paper, drawing) = label .split_once(':') - .and_then(|(p, d)| Some((p.trim().parse::().ok()?, d.trim().parse::().ok()?))) + .and_then(|(p, d)| { + Some((p.trim().parse::().ok()?, d.trim().parse::().ok()?)) + }) .filter(|&(p, d)| p > 0.0 && d > 0.0) .unwrap_or((1.0, 1.0 / factor)); self.add_scale(label, paper, drawing); @@ -2546,9 +2623,7 @@ impl Scene { d.handle = h; d.owner = root_h; self.document.objects.insert(h, ObjectType::Dictionary(d)); - if let Some(ObjectType::Dictionary(root)) = - self.document.objects.get_mut(&root_h) - { + if let Some(ObjectType::Dictionary(root)) = self.document.objects.get_mut(&root_h) { root.add_entry("ACAD_SCALELIST", h); } h @@ -3018,7 +3093,9 @@ impl Scene { for c in bg { mix(c.to_bits() as u64); } - mix(anno_scale_override.map(|a| a.to_bits() as u64).unwrap_or(u64::MAX)); + mix(anno_scale_override + .map(|a| a.to_bits() as u64) + .unwrap_or(u64::MAX)); match frozen_layers { Some(f) => { // Order-independent fold of the frozen-layer set. @@ -3063,7 +3140,8 @@ impl Scene { self.append_scene_markers(&mut wires, bg); } self.apply_refedit_fade(&mut wires, bg); - self.last_tess_ms.set(t_tess.elapsed().as_secs_f32() * 1000.0); + self.last_tess_ms + .set(t_tess.elapsed().as_secs_f32() * 1000.0); self.last_tess_wires.set(wires.len()); let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); @@ -3322,8 +3400,7 @@ impl Scene { let arc = Arc::new(wires); let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed); self.last_model_wire_gen.set(gen); - *self.paper_sheet_cache.borrow_mut() = - Some((self.geometry_epoch, gen, Arc::clone(&arc))); + *self.paper_sheet_cache.borrow_mut() = Some((self.geometry_epoch, gen, Arc::clone(&arc))); arc } @@ -3434,7 +3511,10 @@ impl Scene { // draw-order biasing so 3D occlusion is never flattened. if matches!( e, - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) ) { continue; } @@ -3589,7 +3669,10 @@ impl Scene { .iter() .filter_map(|(handle, model)| { if frozen.is_some() { - let layer = self.document.get_entity(*handle).map(|e| e.common().layer.clone()); + let layer = self + .document + .get_entity(*handle) + .map(|e| e.common().layer.clone()); if let Some(layer) = layer { if self.layer_frozen_in(&layer, frozen) { return None; @@ -3687,11 +3770,13 @@ impl Scene { // block so a block placed at an INSERT scale renders at the right size // (#123) — model space normally, the edited block in a BEDIT editor so // model-space solids don't leak into it (#261). - all.extend(self.instanced_block_meshes( - self.block_edit_block - .unwrap_or_else(|| self.model_space_block_handle()), - frozen, - )); + all.extend( + self.instanced_block_meshes( + self.block_edit_block + .unwrap_or_else(|| self.model_space_block_handle()), + frozen, + ), + ); all } @@ -3734,7 +3819,10 @@ impl Scene { /// Hatch / 2-D-solid fills for a content viewport, with its frozen layers /// removed. Cached per frozen-set signature (viewports sharing a frozen set /// share the build). No frozen layers → the shared unfiltered set. - pub(super) fn hatch_models_for_viewport(&self, frozen: &HashSet) -> Arc> { + pub(super) fn hatch_models_for_viewport( + &self, + frozen: &HashSet, + ) -> Arc> { if frozen.is_empty() { return self.hatch_models_arc(); } @@ -3753,7 +3841,10 @@ impl Scene { } /// Wipeout fills for a content viewport, with its frozen layers removed. - pub(super) fn wipeout_models_for_viewport(&self, frozen: &HashSet) -> Arc> { + pub(super) fn wipeout_models_for_viewport( + &self, + frozen: &HashSet, + ) -> Arc> { if frozen.is_empty() { return self.wipeout_models_arc(); } @@ -3819,8 +3910,16 @@ impl Scene { /// The name of the locked layer `handle` sits on, if any (for messages). pub fn locked_layer_name(&self, handle: Handle) -> Option { - let name = self.document.get_entity(handle).map(|e| e.common().layer.clone())?; - let locked = self.document.layers.get(&name).map(|l| l.is_locked()).unwrap_or(false); + let name = self + .document + .get_entity(handle) + .map(|e| e.common().layer.clone())?; + let locked = self + .document + .layers + .get(&name) + .map(|l| l.is_locked()) + .unwrap_or(false); locked.then_some(name) } @@ -3973,8 +4072,7 @@ impl Scene { ) -> ([f32; 4], [f32; 4]) { use acadrust::types::Color; let bg = self.current_bg(); - let on_l0 = - crate::scene::view::render::is_effective_layer_zero(&ins.common.layer); + let on_l0 = crate::scene::view::render::is_effective_layer_zero(&ins.common.layer); let child_ins_color = if ins.common.color == Color::ByBlock { parent_ins_color } else if on_l0 && ins.common.color == Color::ByLayer { @@ -4038,7 +4136,10 @@ impl Scene { /// `self.hatches` at block-local coords for the block-defn position, /// which doesn't project correctly through the offset-rel view_proj /// and was causing the wrong hatch to be selected on click). - pub fn visible_hatches_for_click(&self) -> HashMap { + pub fn visible_hatches_for_click( + &self, + candidate_handles: Option<&HashSet>, + ) -> HashMap { let layout_block = self.current_layout_block_handle(); let model_block = self.model_space_block_handle(); let layer_hidden = |layer: &str| { @@ -4051,6 +4152,9 @@ impl Scene { self.hatches .iter() .filter_map(|(&h, m)| { + if candidate_handles.is_some_and(|handles| !handles.contains(&h)) { + return None; + } let c = self.document.get_entity(h)?.common(); if c.invisible || layer_hidden(&c.layer) { return None; @@ -4093,19 +4197,23 @@ impl Scene { .block_records .get(block_name) .map(|br| { - br.entity_handles.iter().any(|&h| match self.document.get_entity(h) { - Some(EntityType::Hatch(_)) => true, - Some(EntityType::Insert(ins)) => self.block_has_hatch(&ins.block_name, memo), - // A dimension bakes its geometry into a per-instance `*D` - // block; a custom filled arrowhead lives there as a nested - // Insert(hatch). Recurse so the fill explosion knows to - // descend the dimension (see `explode_including_dims`). - Some(EntityType::Dimension(dim)) => { - !dim.base().block_name.trim().is_empty() - && self.block_has_hatch(&dim.base().block_name, memo) - } - _ => false, - }) + br.entity_handles + .iter() + .any(|&h| match self.document.get_entity(h) { + Some(EntityType::Hatch(_)) => true, + Some(EntityType::Insert(ins)) => { + self.block_has_hatch(&ins.block_name, memo) + } + // A dimension bakes its geometry into a per-instance `*D` + // block; a custom filled arrowhead lives there as a nested + // Insert(hatch). Recurse so the fill explosion knows to + // descend the dimension (see `explode_including_dims`). + Some(EntityType::Dimension(dim)) => { + !dim.base().block_name.trim().is_empty() + && self.block_has_hatch(&dim.base().block_name, memo) + } + _ => false, + }) }) .unwrap_or(false); memo.insert(block_name.to_string(), result); @@ -4131,25 +4239,27 @@ impl Scene { .block_records .get(block_name) .map(|br| { - br.entity_handles.iter().any(|&h| match self.document.get_entity(h) { - Some(EntityType::LwPolyline(p)) => { - p.constant_width > 1e-9 - || p.vertices - .iter() - .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9) - } - Some(EntityType::Polyline2D(p)) => { - p.start_width > 1e-9 - || p.end_width > 1e-9 - || p.vertices - .iter() - .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9) - } - Some(EntityType::Insert(ins)) => { - self.block_has_wide_poly(&ins.block_name, memo) - } - _ => false, - }) + br.entity_handles + .iter() + .any(|&h| match self.document.get_entity(h) { + Some(EntityType::LwPolyline(p)) => { + p.constant_width > 1e-9 + || p.vertices + .iter() + .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9) + } + Some(EntityType::Polyline2D(p)) => { + p.start_width > 1e-9 + || p.end_width > 1e-9 + || p.vertices + .iter() + .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9) + } + Some(EntityType::Insert(ins)) => { + self.block_has_wide_poly(&ins.block_name, memo) + } + _ => false, + }) }) .unwrap_or(false); memo.insert(block_name.to_string(), result); @@ -4177,7 +4287,8 @@ impl Scene { // Exploding an INSERT to find block-internal hatches is expensive, so // skip blocks that contain no hatch at all (the common case for solid- // only blocks). The hatch-presence test is memoised across inserts. - let mut hatch_memo: std::collections::HashMap = std::collections::HashMap::new(); + let mut hatch_memo: std::collections::HashMap = + std::collections::HashMap::new(); for entity in self.document.entities() { let EntityType::Insert(ins) = entity else { continue; @@ -4226,9 +4337,8 @@ impl Scene { /// only — paper-space entities are NOT interactive. pub fn hit_test_wires(&self) -> Arc> { if self.current_layout == "Model" { - // entity_wires_arc is culled to the current view and keyed on the - // camera, so it re-culls when the view changes — picking must reach - // entities that scroll into view after a pan/zoom. + // Model uses the resident, camera-independent full wire set, so the + // interaction index survives pan/zoom and reaches every entity. return self.entity_wires_arc(); } let layout_block = self.current_layout_block_handle(); @@ -4240,62 +4350,180 @@ impl Scene { } } - /// Minimum wire count before the spatial grid is worth building; below it a - /// full linear scan is already fast and the grid's build/query overhead - /// would just add latency. - const WIRE_GRID_MIN: usize = 40_000; + /// Below this size a full scan is cheaper than building both wire and + /// segment grids. + const INTERACTION_INDEX_MIN: usize = 4_000; - /// Spatial grid over `wires`, cached on `geometry_epoch`. `wires` must be - /// the same `geometry_epoch`-keyed set the indices will index into. - fn wire_hit_grid( + fn interaction_index( &self, wires: &Arc>, - ) -> Arc { + ) -> Arc { + let source = Arc::as_ptr(wires) as usize; { - let cache = self.wire_grid_cache.borrow(); - if let Some((epoch, ref arc)) = *cache { - if epoch == self.geometry_epoch { - return Arc::clone(arc); + let cache = self.interaction_index_cache.borrow(); + if let Some((epoch, ptr, weak, index)) = cache.as_ref() { + if *epoch == self.geometry_epoch + && *ptr == source + && weak + .upgrade() + .is_some_and(|cached| Arc::ptr_eq(&cached, wires)) + { + return Arc::clone(index); } } } - let grid = Arc::new(crate::scene::pick::wire_grid::WireGrid::build(wires)); - *self.wire_grid_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&grid))); - grid + let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(wires)); + *self.interaction_index_cache.borrow_mut() = Some(( + self.geometry_epoch, + source, + Arc::downgrade(wires), + Arc::clone(&index), + )); + index } - /// Hit-test wires near the cursor. For large model sets in a flat top-down - /// view this queries a cached spatial grid so snap / hover touch only the - /// cursor neighbourhood instead of the whole (block-exploded) wire set; - /// small sets, paper space and tilted views fall back to the full set - /// (where the grid's flat-XY assumption or build/query overhead would not - /// pay off). Drop-in for `hit_test_wires()` at per-move snap / hover sites. - pub fn hit_test_wires_near( + fn interaction_source_is_resident( &self, + wires: &Arc>, + screen_height_px: f32, + ) -> bool { + if self.current_layout == "Model" { + return true; + } + self.active_viewport.is_some_and(|viewport| { + let resident = self.model_wires_for_viewport_arc(viewport, screen_height_px); + Arc::ptr_eq(&resident, wires) + }) + } + + fn interaction_handle_index( + &self, + ) -> Arc { + { + let cache = self.interaction_handle_index_cache.borrow(); + if let Some((epoch, index)) = cache.as_ref() { + if *epoch == self.geometry_epoch { + return Arc::clone(index); + } + } + } + let mut entries: Vec<(u64, [f64; 4])> = Vec::new(); + for (handle, hatch) in self.insert_hatches_for_click().iter() { + if let Some(aabb) = hatch_interaction_aabb(hatch) { + entries.push((handle.value(), aabb)); + } + } + for set in self.meshes_arc().iter() { + let Some(mesh) = set.lods.first() else { + continue; + }; + let Ok(handle) = mesh.name.parse::() else { + continue; + }; + let [min_x, min_y, max_x, max_y] = set.world_aabb; + entries.push(( + handle, + [min_x as f64, min_y as f64, max_x as f64, max_y as f64], + )); + } + let index = + Arc::new(crate::scene::pick::interaction_index::InteractionHandleIndex::build(entries)); + *self.interaction_handle_index_cache.borrow_mut() = + Some((self.geometry_epoch, Arc::clone(&index))); + index + } + + /// Cursor-local interaction candidates. `radius_px` is supplied by the + /// consumer (OSNAP aperture or click tolerance), avoiding the old fixed + /// 64-pixel neighbourhood. Returned wires stay borrowed from `wires`; + /// no heavyweight `WireModel` clone occurs. + pub fn interaction_candidates_near( + &self, + wires: Arc>, cursor: glam::DVec3, view_rot: glam::Mat4, bounds: iced::Rectangle, - ) -> Arc> { - let full = self.hit_test_wires(); - if self.current_layout != "Model" || full.len() < Self::WIRE_GRID_MIN { - return full; + radius_px: f32, + ) -> crate::scene::pick::interaction_index::InteractionCandidates { + if !self.interaction_source_is_resident(&wires, bounds.height) + || wires.len() < Self::INTERACTION_INDEX_MIN + { + return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - // The grid indexes world XY; only a flat top-down view maps world x/y - // straight to screen, so restrict to it (matches the hit-test cull). - let flat = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9; - if !flat { - return full; + let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 + && view_rot.z_axis.y.abs() < 1e-9 + && (view_rot.w_axis.w - 1.0).abs() < 1e-6; + if !flat_ortho { + return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - // World radius covering the snap aperture + click threshold + margin. - // `view_rot.col(0).x * width/2` = pixels per world unit (ortho). - let s = view_rot.col(0).x.abs() * bounds.width * 0.5; + let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) + + (view_rot.x_axis.y * bounds.height * 0.5).powi(2)) + .sqrt(); + let world_y_px = ((view_rot.y_axis.x * bounds.width * 0.5).powi(2) + + (view_rot.y_axis.y * bounds.height * 0.5).powi(2)) + .sqrt(); + let s = world_x_px.min(world_y_px); if s <= 1e-6 { - return full; + return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - let radius = 64.0 / s as f64; - let grid = self.wire_hit_grid(&full); - let idxs = grid.query(cursor.x, cursor.y, radius); - Arc::new(idxs.iter().map(|&i| full[i as usize].clone()).collect()) + let radius = radius_px.max(0.0) as f64 / s as f64; + let query = [ + cursor.x - radius, + cursor.y - radius, + cursor.x + radius, + cursor.y + radius, + ]; + self.interaction_index(&wires).query(wires, query) + } + + /// World-XY rectangular broad phase for box/lasso/fence and command + /// windows. Tilted and paper views use the same candidate type with an + /// all-wire fallback so exact screen-space rules stay unchanged. + pub fn interaction_candidates_in_aabb( + &self, + wires: Arc>, + aabb: [f64; 4], + view_rot: glam::Mat4, + ) -> crate::scene::pick::interaction_index::InteractionCandidates { + let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 + && view_rot.z_axis.y.abs() < 1e-9 + && (view_rot.w_axis.w - 1.0).abs() < 1e-6; + if !self.interaction_source_is_resident(&wires, 0.0) + || wires.len() < Self::INTERACTION_INDEX_MIN + || !flat_ortho + { + return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); + } + self.interaction_index(&wires).query(wires, aabb) + } + + pub fn interaction_candidate_handles( + &self, + candidates: &crate::scene::pick::interaction_index::InteractionCandidates, + ) -> Option> { + let aabb = candidates.query_aabb()?; + let mut handles: HashSet = candidates + .iter() + .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) + .collect(); + let index = self.entity_index(); + handles.extend(index.tree.query_rect(aabb)); + handles.extend( + self.interaction_handle_index() + .query(aabb) + .into_iter() + .map(Handle::new), + ); + Some(handles) + } + + pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet { + let wires = self.hit_test_wires(); + let candidates = self.interaction_index(&wires).query(wires, aabb); + candidates + .iter() + .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) + .collect() } /// Pick a meshed 3D solid by clicking on its shaded body (face), not just @@ -4339,10 +4567,12 @@ impl Scene { view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, ) -> Vec { let iter = self .meshes .iter() + .filter(|(h, _)| candidate_handles.is_none_or(|handles| handles.contains(h))) .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); pick::hit_test::mesh_box_hit(a, b, crossing, iter, view_rot, eye, bounds) } @@ -4355,10 +4585,12 @@ impl Scene { view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, ) -> Vec { let iter = self .meshes .iter() + .filter(|(h, _)| candidate_handles.is_none_or(|handles| handles.contains(h))) .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); pick::hit_test::mesh_poly_hit(poly, crossing, iter, view_rot, eye, bounds) } @@ -4374,6 +4606,7 @@ impl Scene { view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, ) -> Option { // Reuse the renderer's expanded mesh set (top-level solids + per-INSERT // block instances), cached per geometry epoch — so hover no longer @@ -4396,6 +4629,9 @@ impl Scene { return None; } let handle = Handle::new(m.name.parse::().ok()?); + if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { + return None; + } Some((handle, m)) }); pick::hit_test::mesh_click_hit(cursor, candidates, view_rot, eye, bounds) @@ -4413,6 +4649,7 @@ impl Scene { view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); @@ -4424,7 +4661,9 @@ impl Scene { continue; } let EntityType::Insert(ins) = e else { continue }; - if !self.mesh_entity_visible(ins.common.handle) { + if candidate_handles.is_some_and(|handles| !handles.contains(&ins.common.handle)) + || !self.mesh_entity_visible(ins.common.handle) + { continue; } let mut sets = Vec::new(); @@ -4458,6 +4697,7 @@ impl Scene { view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); @@ -4469,7 +4709,9 @@ impl Scene { continue; } let EntityType::Insert(ins) = e else { continue }; - if !self.mesh_entity_visible(ins.common.handle) { + if candidate_handles.is_some_and(|handles| !handles.contains(&ins.common.handle)) + || !self.mesh_entity_visible(ins.common.handle) + { continue; } let mut sets = Vec::new(); @@ -4533,7 +4775,10 @@ impl Scene { return false; } let layer = self.document.layers.get(&c.layer); - if layer.map(|l| l.flags.off || l.flags.frozen).unwrap_or(false) { + if layer + .map(|l| l.flags.off || l.flags.frozen) + .unwrap_or(false) + { return false; } if let Some(frozen) = frozen_layers { @@ -4733,8 +4978,7 @@ impl Scene { static EN: std::sync::OnceLock = std::sync::OnceLock::new(); *EN.get_or_init(|| std::env::var("OCS_NO_RESIDENT_MEMO").is_err()) } - let resident = - base_ok && view_aabb.is_none() && wpp.is_none() && resident_memo_enabled(); + let resident = base_ok && view_aabb.is_none() && wpp.is_none() && resident_memo_enabled(); let memo_active = base_ok && (view_aabb.is_some() || resident); let mut wires: Vec = if memo_active { // Guard hash of everything tessellate_entity output depends on @@ -4785,14 +5029,24 @@ impl Scene { } } // Materialize hits + tessellate misses, both in parallel. - let hit_wires: Vec = - hit_arcs.par_iter().flat_map_iter(|a| a.iter().cloned()).collect(); + let hit_wires: Vec = hit_arcs + .par_iter() + .flat_map_iter(|a| a.iter().cloned()) + .collect(); let miss_pairs: Vec<(Handle, Arc>)> = misses .par_iter() .map(|e| { let e: &EntityType = e; let w = tessellate_entity( - doc, sel, avp, bg, anno, e, Some(blk_ref), view_aabb, wpp, + doc, + sel, + avp, + bg, + anno, + e, + Some(blk_ref), + view_aabb, + wpp, paper, ); (e.common().handle, Arc::new(w)) @@ -4812,7 +5066,15 @@ impl Scene { .into_par_iter() .flat_map(|e| { tessellate_entity( - doc, sel, avp, bg, anno, e, Some(blk_ref), view_aabb, wpp, + doc, + sel, + avp, + bg, + anno, + e, + Some(blk_ref), + view_aabb, + wpp, paper, ) }) @@ -4960,9 +5222,7 @@ impl Scene { ChangeKind::Added | ChangeKind::Modified => { match self.document.get_entity(*h) { None => ops.push(IdxOp::Remove(*h)), - Some(e) if is_unindexable_entity(e) => { - ops.push(IdxOp::Remove(*h)) - } + Some(e) if is_unindexable_entity(e) => ops.push(IdxOp::Remove(*h)), Some(e) => match entity_world_aabb_f64(e) { Some(ab) => ops.push(IdxOp::SetBounded(*h, ab)), None => ops.push(IdxOp::SetUnbounded(*h)), @@ -5044,9 +5304,7 @@ impl Scene { unbounded_handles: unbounded, }, )); - std::cell::Ref::map(self.entity_index_cache.borrow(), |c| { - &c.as_ref().unwrap().1 - }) + std::cell::Ref::map(self.entity_index_cache.borrow(), |c| &c.as_ref().unwrap().1) } /// Full tessellation pipeline for one entity. @@ -5336,7 +5594,10 @@ mod journal_tests { s.bump_entities(&[(h(20), ChangeKind::Added)]); s.bump_entities(&[(h(20), ChangeKind::Removed)]); let m = as_map(s.replay_since(e0)).unwrap(); - assert!(!m.contains_key(&h(20)), "add+remove in one window must cancel"); + assert!( + !m.contains_key(&h(20)), + "add+remove in one window must cancel" + ); } #[test] @@ -5394,8 +5655,11 @@ mod journal_tests { // quadtree + always-surfaced unbounded). fn indexed(s: &Scene) -> HashSet { let idx = s.entity_index(); - let mut set: HashSet = - idx.tree.query_rect([-1e9, -1e9, 1e9, 1e9]).into_iter().collect(); + let mut set: HashSet = idx + .tree + .query_rect([-1e9, -1e9, 1e9, 1e9]) + .into_iter() + .collect(); set.extend(idx.unbounded_handles.iter().copied()); set } @@ -5472,7 +5736,11 @@ mod journal_tests { e.common_mut().handle = h3; s.update_entity(e); } - assert_eq!(resident_names(&s), from_scratch(&s), "resident after modify"); + assert_eq!( + resident_names(&s), + from_scratch(&s), + "resident after modify" + ); s.erase_entities(&[h2]); assert_eq!(resident_names(&s), from_scratch(&s), "resident after erase"); @@ -5585,7 +5853,10 @@ mod delta_undo_tests { scene.begin_undo_recording(); scene.erase_entities(&[h2]); let rec = scene.take_undo_recording().unwrap(); - assert!(!rec.is_poisoned(), "erasing an ungrouped entity must not poison"); + assert!( + !rec.is_poisoned(), + "erasing an ungrouped entity must not poison" + ); let delta = build_delta(&scene, rec); assert!(scene.document.get_entity(h2).is_none(), "h2 must be erased"); @@ -5596,7 +5867,11 @@ mod delta_undo_tests { assert_eq!(scene.document.get_entity(h2).cloned().unwrap(), orig2); assert_eq!(scene.document.get_entity(h1).cloned().unwrap(), orig1); assert_eq!(scene.document.entities().count(), count); - assert_eq!(ms_occurrences(&scene, h2), 1, "h2 must appear once, not duplicated"); + assert_eq!( + ms_occurrences(&scene, h2), + 1, + "h2 must appear once, not duplicated" + ); // Redo erases h2 again. (remove_entity leaves the handle dangling in // the block record — harmless, a lookup miss is skipped on render/save @@ -5611,7 +5886,10 @@ mod delta_undo_tests { scene.begin_undo_recording(); let h = scene.add_entity(line(0.0, 0.0, 4.0, 4.0)); let rec = scene.take_undo_recording().unwrap(); - assert!(!rec.is_poisoned(), "a plain add on an existing layer must not poison"); + assert!( + !rec.is_poisoned(), + "a plain add on an existing layer must not poison" + ); let added = scene.document.get_entity(h).cloned().unwrap(); let delta = build_delta(&scene, rec); @@ -5633,8 +5911,10 @@ mod delta_undo_tests { let src = scene.add_entity(line(0.0, 0.0, 1.0, 0.0)); scene.begin_undo_recording(); - let new_handles = - scene.copy_entities(&[src], &EntityTransform::Translate(DVec3::new(10.0, 0.0, 0.0))); + let new_handles = scene.copy_entities( + &[src], + &EntityTransform::Translate(DVec3::new(10.0, 0.0, 0.0)), + ); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned(), "copying a plain entity must not poison"); assert_eq!(new_handles.len(), 1); @@ -5649,7 +5929,10 @@ mod delta_undo_tests { // Redo restores the copy with its handle, once. scene.apply_entity_delta(&delta, false); - assert_eq!(scene.document.get_entity(copy_h).cloned().unwrap(), copy_img); + assert_eq!( + scene.document.get_entity(copy_h).cloned().unwrap(), + copy_img + ); assert_eq!(ms_occurrences(&scene, copy_h), 1); } } diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index c42407f3b..39d10d457 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -4,7 +4,7 @@ //! to 2-D pixel coordinates, then compared against the cursor or selection box. //! This matches the visual result the user sees. -use rustc_hash::FxHashMap as HashMap; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use acadrust::Handle; use glam::Mat4; @@ -13,6 +13,7 @@ use iced::{Point, Rectangle}; use crate::scene::model::hatch_model::HatchModel; use crate::scene::model::mesh_model::MeshModel; use crate::scene::model::wire_model::WireModel; +use crate::scene::pick::interaction_index::WireSource; /// Pixel radius used for single-click wire detection. pub const CLICK_THRESHOLD_PX: f32 = 8.0; @@ -167,9 +168,9 @@ fn text_quad_hit_area( /// within that wire's [`pick_tolerance_px`] of `cursor`. /// /// Returns `None` when no wire is close enough. -pub fn click_hit<'a>( +pub fn click_hit<'a, W: WireSource + ?Sized>( cursor: Point, - wires: &'a [WireModel], + wires: &'a W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -193,32 +194,69 @@ pub fn click_hit<'a>( // points (the dominant per-move cost on 100 k-wire drawings). let z_flat = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9; - // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. - for wire in wires { - let tol = pick_tolerance_px(wire, lw_display); - // Cheap AABB pre-reject (flat view only; never for the unbounded - // sentinel used by previews / greeked text). - if z_flat - && wire.aabb != WireModel::UNBOUNDED_AABB - && aabb_rejects(wire.aabb, cursor, tol, view_rot, eye, bounds) - { - continue; - } - let mut prev: Option = None; - for (i, &[px, py, pz]) in wire.points.iter().enumerate() { - if px.is_nan() { - prev = None; + if let Some(segments) = wires.segments() { + // The interaction index already narrowed long/batched wires to segments + // touching the cursor aperture. Hover and click therefore project only + // those local edges, not every point of every candidate wire. + for segment in segments { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + let start = segment.start as usize; + if start + 1 >= wire.points.len() { continue; } - let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds); - if let Some(p0) = prev { - let d = dist_point_to_segment(cursor, p0, cur); - if d < tol && d < best_dist { - best_dist = d; - best = Some(&wire.name); + let p0 = world_to_screen( + wp64(wire.points[start], &wire.points_low, start), + view_rot, + eye, + bounds, + ); + let p1 = world_to_screen( + wp64(wire.points[start + 1], &wire.points_low, start + 1), + view_rot, + eye, + bounds, + ); + let d = dist_point_to_segment(cursor, p0, p1); + if d < pick_tolerance_px(wire, lw_display) && d < best_dist { + best_dist = d; + best = Some(&wire.name); + } + } + } else { + // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. + for wire in wires.iter() { + let tol = pick_tolerance_px(wire, lw_display); + // Cheap AABB pre-reject (flat view only; never for the unbounded + // sentinel used by previews / greeked text). + if z_flat + && wire.aabb != WireModel::UNBOUNDED_AABB + && aabb_rejects(wire.aabb, cursor, tol, view_rot, eye, bounds) + { + continue; + } + let mut prev: Option = None; + for (i, &[px, py, pz]) in wire.points.iter().enumerate() { + if px.is_nan() { + prev = None; + continue; } + let cur = world_to_screen( + wp64([px, py, pz], &wire.points_low, i), + view_rot, + eye, + bounds, + ); + if let Some(p0) = prev { + let d = dist_point_to_segment(cursor, p0, cur); + if d < tol && d < best_dist { + best_dist = d; + best = Some(&wire.name); + } + } + prev = Some(cur); } - prev = Some(cur); } } @@ -232,7 +270,7 @@ pub fn click_hit<'a>( // 3D solid is. Same projected-triangle containment as `mesh_click_hit`, // front-most wins. let mut best_fill: Option<(f32, &str)> = None; - for wire in wires { + for wire in wires.iter() { if wire.fill_tris.is_empty() { continue; } @@ -263,7 +301,7 @@ pub fn click_hit<'a>( // surface — where the two overlap, the nearer thing to the eye is decided // by depth, but a wire that has a real fill should win on it first. let mut best_wall: Option<(f32, &str)> = None; - for wire in wires { + for wire in wires.iter() { if wire.pick_tris.is_empty() { continue; } @@ -301,7 +339,7 @@ pub fn click_hit<'a>( // priority — real edges and fills above always win. let mut best_area = f32::MAX; let mut best_box: Option<&str> = None; - for wire in wires { + for wire in wires.iter() { if wire.text_verts.is_empty() { continue; } @@ -318,9 +356,9 @@ pub fn click_hit<'a>( /// Like `click_hit` but returns every wire within the click threshold, /// nearest first. Used by selection cycling to step through overlapping /// objects under the cursor. -pub fn click_hits_all<'a>( +pub fn click_hits_all<'a, W: WireSource + ?Sized>( cursor: Point, - wires: &'a [WireModel], + wires: &'a W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -330,28 +368,70 @@ pub fn click_hits_all<'a>( return Vec::new(); } let mut hits: Vec<(f32, &str)> = Vec::new(); - for wire in wires { - let tol = pick_tolerance_px(wire, lw_display); - let mut prev: Option = None; - let mut best_for_wire = tol; - let mut hit = false; - for (i, &[px, py, pz]) in wire.points.iter().enumerate() { - if px.is_nan() { - prev = None; + if let Some(segments) = wires.segments() { + let mut best_by_wire: HashMap = HashMap::default(); + for segment in segments { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + let start = segment.start as usize; + if start + 1 >= wire.points.len() { continue; } - let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds); - if let Some(p0) = prev { - let d = dist_point_to_segment(cursor, p0, cur); - if d < best_for_wire { - best_for_wire = d; - hit = true; - } + let p0 = world_to_screen( + wp64(wire.points[start], &wire.points_low, start), + view_rot, + eye, + bounds, + ); + let p1 = world_to_screen( + wp64(wire.points[start + 1], &wire.points_low, start + 1), + view_rot, + eye, + bounds, + ); + let d = dist_point_to_segment(cursor, p0, p1); + if d < pick_tolerance_px(wire, lw_display) { + best_by_wire + .entry(segment.wire) + .and_modify(|best| *best = best.min(d)) + .or_insert(d); } - prev = Some(cur); } - if hit { - hits.push((best_for_wire, &wire.name)); + hits.extend(best_by_wire.into_iter().filter_map(|(wire_idx, distance)| { + wires + .source_wire(wire_idx) + .map(|wire| (distance, wire.name.as_str())) + })); + } else { + for wire in wires.iter() { + let tol = pick_tolerance_px(wire, lw_display); + let mut prev: Option = None; + let mut best_for_wire = tol; + let mut hit = false; + for (i, &[px, py, pz]) in wire.points.iter().enumerate() { + if px.is_nan() { + prev = None; + continue; + } + let cur = world_to_screen( + wp64([px, py, pz], &wire.points_low, i), + view_rot, + eye, + bounds, + ); + if let Some(p0) = prev { + let d = dist_point_to_segment(cursor, p0, cur); + if d < best_for_wire { + best_for_wire = d; + hit = true; + } + } + prev = Some(cur); + } + if hit { + hits.push((best_for_wire, &wire.name)); + } } } // Thickness walls join the cycle so an extruded entity picked on its wall @@ -360,7 +440,7 @@ pub fn click_hits_all<'a>( // // A wall's own edges live on the same wire, so skip any wire the loop above // already caught: cycling must not offer one entity twice. - for wire in wires { + for wire in wires.iter() { if wire.pick_tris.is_empty() || hits.iter().any(|&(_, n)| n == wire.name) { continue; } @@ -380,7 +460,7 @@ pub fn click_hits_all<'a>( // SDF text: use the same exact glyph-quad test as `click_hit`; a batched // text wire's union AABB may cover empty space between distant runs (#438). // Ranked after real geometry (distance = the click threshold). - for wire in wires { + for wire in wires.iter() { if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { continue; } @@ -418,12 +498,21 @@ pub fn aabb_under_cursor( if !min_x.is_finite() || !min_z.is_finite() { return true; // degenerate bound — don't cull } - let (mut sx0, mut sy0, mut sx1, mut sy1) = - (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + let (mut sx0, mut sy0, mut sx1, mut sy1) = ( + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ); for &z in &[min_z, max_z] { - for &(x, y) in &[(min_x, min_y), (max_x, min_y), (min_x, max_y), (max_x, max_y)] { - let rel = glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z) - .as_vec3(); + for &(x, y) in &[ + (min_x, min_y), + (max_x, min_y), + (min_x, max_y), + (max_x, max_y), + ] { + let rel = + glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z).as_vec3(); let clip = view_proj * rel.extend(1.0); if clip.w <= 1e-6 { return true; // corner at/behind the camera — can't bound; keep it @@ -493,7 +582,12 @@ fn mesh_vert(hi: [f32; 3], low: &[[f32; 3]], i: usize) -> glam::DVec3 { } /// Project a mesh's vertices to screen space. -fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec { +fn project_mesh_verts( + mesh: &MeshModel, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Vec { mesh.verts .iter() .enumerate() @@ -607,11 +701,11 @@ pub fn mesh_poly_hit<'a>( /// ANY projected point inside the box, OR any wire segment crosses the box /// boundary (so large entities like viewport frames are caught even when /// no corner falls inside the selection rectangle). -pub fn box_hit<'a>( +pub fn box_hit<'a, W: WireSource + ?Sized>( corner_a: Point, corner_b: Point, crossing: bool, - wires: &'a [WireModel], + wires: &'a W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -719,10 +813,10 @@ pub fn box_hit<'a>( /// - **Window mode** (`crossing = false`): ALL projected points inside polygon. /// - **Crossing mode** (`crossing = true`): ANY point inside OR any wire /// segment crosses a polygon edge. -pub fn poly_hit<'a>( +pub fn poly_hit<'a, W: WireSource + ?Sized>( poly: &[Point], crossing: bool, - wires: &'a [WireModel], + wires: &'a W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -813,7 +907,12 @@ pub fn poly_hit<'a>( // ── Helpers ─────────────────────────────────────────────────────────────── -fn world_to_screen(world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point { +fn world_to_screen( + world: glam::DVec3, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Point { let ndc = view_rot.project_point3((world - eye).as_vec3()); Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, @@ -845,9 +944,7 @@ fn wp64(hi: [f32; 3], low: &[[f32; 3]], i: usize) -> glam::DVec3 { fn point_in_polygon(p: Point, poly: &[Point]) -> bool { // Ray-cast crossing test for a single edge a→b. fn cross(p: Point, a: Point, b: Point, inside: &mut bool) { - if (a.y > p.y) != (b.y > p.y) - && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x - { + if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { *inside = !*inside; } } @@ -861,13 +958,14 @@ fn point_in_polygon(p: Point, poly: &[Point]) -> bool { // sub-path that is a real ring (≥3 verts); closing a 2-point explicit edge // would add a degenerate back-edge that cancels its own crossing. let mut count = 0usize; - let close = |prev: Option, path_start: Option, count: usize, inside: &mut bool| { - if count >= 3 { - if let (Some(pv), Some(sv)) = (prev, path_start) { - cross(p, pv, sv, inside); + let close = + |prev: Option, path_start: Option, count: usize, inside: &mut bool| { + if count >= 3 { + if let (Some(pv), Some(sv)) = (prev, path_start) { + cross(p, pv, sv, inside); + } } - } - }; + }; for &pt in poly { if !pt.x.is_finite() || !pt.y.is_finite() { close(prev, path_start, count, &mut inside); @@ -928,8 +1026,12 @@ pub fn click_hit_hatch( view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, + candidate_handles: Option<&HashSet>, ) -> Option { for (&handle, hatch) in hatches { + if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { + continue; + } if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) { return Some(handle); } @@ -948,8 +1050,12 @@ pub fn click_hit_insert_hatch( view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, + candidate_handles: Option<&HashSet>, ) -> Option { for (handle, hatch) in insert_hatches { + if candidate_handles.is_some_and(|handles| !handles.contains(handle)) { + continue; + } if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) { return Some(*handle); } @@ -978,7 +1084,12 @@ fn hatch_contains_screen_point( .iter() .map(|&[x, y]| { if x.is_finite() && y.is_finite() { - world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds) + world_to_screen( + glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), + view_rot, + eye, + bounds, + ) } else { // Preserve path separators for the NaN-aware // point_in_polygon ray-cast. @@ -998,6 +1109,7 @@ pub fn box_hit_hatch( view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, + candidate_handles: Option<&HashSet>, ) -> Vec { let min_x = corner_a.x.min(corner_b.x); let max_x = corner_a.x.max(corner_b.x); @@ -1013,6 +1125,9 @@ pub fn box_hit_hatch( hatches .iter() .filter_map(|(&handle, hatch)| { + if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { + return None; + } if hatch.boundary.is_empty() { return None; } @@ -1020,7 +1135,14 @@ pub fn box_hit_hatch( let screen: Vec = hatch .boundary .iter() - .map(|&[x, y]| world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds)) + .map(|&[x, y]| { + world_to_screen( + glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), + view_rot, + eye, + bounds, + ) + }) .collect(); let hit = if crossing { screen.iter().any(|&sp| inside(sp)) @@ -1044,6 +1166,7 @@ pub fn poly_hit_hatch( view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, + candidate_handles: Option<&HashSet>, ) -> Vec { if poly.len() < 3 { return vec![]; @@ -1052,6 +1175,9 @@ pub fn poly_hit_hatch( hatches .iter() .filter_map(|(&handle, hatch)| { + if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { + return None; + } if hatch.boundary.is_empty() { return None; } @@ -1059,7 +1185,14 @@ pub fn poly_hit_hatch( let screen: Vec = hatch .boundary .iter() - .map(|&[x, y]| world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds)) + .map(|&[x, y]| { + world_to_screen( + glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), + view_rot, + eye, + bounds, + ) + }) .collect(); let hit = if crossing { screen.iter().any(|&sp| point_in_polygon(sp, poly)) @@ -1113,16 +1246,38 @@ mod aabb_reject_tests { #[test] fn aabb_reject_keeps_near_wire_drops_far() { let vp = Mat4::IDENTITY; - let bounds = Rectangle { x: 0.0, y: 0.0, width: 200.0, height: 200.0 }; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; let cursor = Point::new(100.0, 100.0); // world origin - let near = wire("5", vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]], [-0.02, 0.0, 0.02, 0.0]); - let far = wire("9", vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], [0.9, 0.9, 0.95, 0.9]); + let near = wire( + "5", + vec![[-0.02, 0.0, 0.0], [0.02, 0.0, 0.0]], + [-0.02, 0.0, 0.02, 0.0], + ); + let far = wire( + "9", + vec![[0.9, 0.9, 0.0], [0.95, 0.9, 0.0]], + [0.9, 0.9, 0.95, 0.9], + ); let eye = glam::DVec3::ZERO; - assert_eq!(click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true), Some("5")); - assert_eq!(click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true), None); + assert_eq!( + click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true), + Some("5") + ); + assert_eq!( + click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true), + None + ); // The far wire must be rejected without hiding the near one. - assert_eq!(click_hit(cursor, &[far, near], vp, eye, bounds, true), Some("5")); + assert_eq!( + click_hit(cursor, &[far, near], vp, eye, bounds, true), + Some("5") + ); } } diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs new file mode 100644 index 000000000..6957199d6 --- /dev/null +++ b/src/scene/pick/interaction_index.rs @@ -0,0 +1,433 @@ +//! Shared spatial broad phase for cursor and area interactions. +//! +//! Exact snap and selection rules remain in their consumers. This index only +//! narrows a large, camera-independent Model wire set to wire and segment +//! candidates whose world-XY bounds overlap the interaction aperture. + +use crate::scene::model::wire_model::WireModel; +use std::sync::Arc; + +const TARGET_ENTRIES_PER_CELL: usize = 8; +const MAX_SPAN_CELLS: u64 = 64; +const MAX_AXIS_CELLS: u32 = 8_192; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SegmentRef { + pub wire: u32, + pub start: u32, +} + +#[derive(Clone, Copy)] +struct Entry { + aabb: [f64; 4], + value: T, +} + +struct SpatialGrid { + min: [f64; 2], + cell: f64, + cols: u32, + rows: u32, + entries: Vec>, + cells: Vec>, + oversized: Vec, +} + +impl SpatialGrid { + fn build(entries: Vec>) -> Self { + if entries.is_empty() { + return Self { + min: [0.0; 2], + cell: 1.0, + cols: 1, + rows: 1, + entries, + cells: vec![Vec::new()], + oversized: Vec::new(), + }; + } + + let mut min = [f64::INFINITY; 2]; + let mut max = [f64::NEG_INFINITY; 2]; + for entry in &entries { + min[0] = min[0].min(entry.aabb[0]); + min[1] = min[1].min(entry.aabb[1]); + max[0] = max[0].max(entry.aabb[2]); + max[1] = max[1].max(entry.aabb[3]); + } + let ext_x = (max[0] - min[0]).max(1e-9); + let ext_y = (max[1] - min[1]).max(1e-9); + let target_cells = ((entries.len() / TARGET_ENTRIES_PER_CELL).max(1)) as f64; + let cell = ((ext_x * ext_y) / target_cells) + .sqrt() + .max(ext_x / (MAX_AXIS_CELLS - 1) as f64) + .max(ext_y / (MAX_AXIS_CELLS - 1) as f64) + .max(1e-9); + let cols = (((ext_x / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32; + let rows = (((ext_y / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32; + let mut cells = vec![Vec::new(); cols as usize * rows as usize]; + let mut oversized = Vec::new(); + + let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32; + let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32; + + for (idx, entry) in entries.iter().enumerate() { + let c0 = col_of(entry.aabb[0]); + let c1 = col_of(entry.aabb[2]); + let r0 = row_of(entry.aabb[1]); + let r1 = row_of(entry.aabb[3]); + let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; + if span > MAX_SPAN_CELLS { + oversized.push(idx as u32); + continue; + } + for row in r0..=r1 { + let base = row as usize * cols as usize; + for col in c0..=c1 { + cells[base + col as usize].push(idx as u32); + } + } + } + + Self { + min, + cell, + cols, + rows, + entries, + cells, + oversized, + } + } + + fn query(&self, query: [f64; 4]) -> Vec { + let mut entry_indices = self.oversized.clone(); + let grid_max_x = self.min[0] + self.cols as f64 * self.cell; + let grid_max_y = self.min[1] + self.rows as f64 * self.cell; + if query[2] >= self.min[0] + && query[3] >= self.min[1] + && query[0] <= grid_max_x + && query[1] <= grid_max_y + { + let col = |x: f64| { + (((x - self.min[0]) / self.cell).floor()).clamp(0.0, (self.cols - 1) as f64) as u32 + }; + let row = |y: f64| { + (((y - self.min[1]) / self.cell).floor()).clamp(0.0, (self.rows - 1) as f64) as u32 + }; + for r in row(query[1])..=row(query[3]) { + let base = r as usize * self.cols as usize; + for c in col(query[0])..=col(query[2]) { + entry_indices.extend_from_slice(&self.cells[base + c as usize]); + } + } + } + + entry_indices.sort_unstable(); + entry_indices.dedup(); + let mut out: Vec = entry_indices + .into_iter() + .filter_map(|idx| { + let entry = self.entries.get(idx as usize)?; + aabb_overlaps(entry.aabb, query).then_some(entry.value) + }) + .collect(); + out.sort_unstable(); + out.dedup(); + out + } +} + +pub struct InteractionIndex { + wires: SpatialGrid, + segments: SpatialGrid, + unbounded_wires: Vec, +} + +pub struct InteractionHandleIndex { + handles: SpatialGrid, +} + +impl InteractionHandleIndex { + pub fn build(entries: impl IntoIterator) -> Self { + Self { + handles: SpatialGrid::build( + entries + .into_iter() + .filter(|(_, aabb)| { + aabb.iter().all(|value| value.is_finite()) + && aabb[0] <= aabb[2] + && aabb[1] <= aabb[3] + }) + .map(|(value, aabb)| Entry { aabb, value }) + .collect(), + ), + } + } + + pub fn query(&self, aabb: [f64; 4]) -> Vec { + self.handles.query(aabb) + } +} + +impl InteractionIndex { + pub fn build(wires: &[WireModel]) -> Self { + let mut wire_entries = Vec::with_capacity(wires.len()); + let mut segment_entries = Vec::new(); + let mut unbounded_wires = Vec::new(); + + for (wire_idx, wire) in wires.iter().enumerate() { + if let Some(aabb) = finite_wire_aabb(wire) { + wire_entries.push(Entry { + aabb, + value: wire_idx as u32, + }); + } else { + unbounded_wires.push(wire_idx as u32); + } + + for start in 0..wire.points.len().saturating_sub(1) { + let a = wire_point(wire, start); + let b = wire_point(wire, start + 1); + if !a[0].is_finite() || !a[1].is_finite() || !b[0].is_finite() || !b[1].is_finite() + { + continue; + } + segment_entries.push(Entry { + aabb: [ + a[0].min(b[0]), + a[1].min(b[1]), + a[0].max(b[0]), + a[1].max(b[1]), + ], + value: SegmentRef { + wire: wire_idx as u32, + start: start as u32, + }, + }); + } + } + + Self { + wires: SpatialGrid::build(wire_entries), + segments: SpatialGrid::build(segment_entries), + unbounded_wires, + } + } + + pub fn query(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { + let mut wire_indices = self.wires.query(aabb); + wire_indices.extend_from_slice(&self.unbounded_wires); + wire_indices.sort_unstable(); + wire_indices.dedup(); + InteractionCandidates { + wires, + wire_indices: Some(wire_indices), + segments: Some(self.segments.query(aabb)), + query_aabb: Some(aabb), + } + } +} + +pub struct InteractionCandidates { + wires: Arc>, + wire_indices: Option>, + segments: Option>, + query_aabb: Option<[f64; 4]>, +} + +pub trait WireSource { + fn iter(&self) -> WireIter<'_>; + fn len(&self) -> usize; + fn get(&self, index: usize) -> Option<&WireModel>; + fn segments(&self) -> Option<&[SegmentRef]> { + None + } + fn source_wire(&self, index: u32) -> Option<&WireModel>; +} + +impl InteractionCandidates { + pub fn all(wires: Arc>) -> Self { + Self { + wires, + wire_indices: None, + segments: None, + query_aabb: None, + } + } + + pub fn iter(&self) -> WireIter<'_> { + match &self.wire_indices { + Some(indices) => WireIter::Indexed { + wires: &self.wires, + indices: indices.iter(), + }, + None => WireIter::All(self.wires.as_slice().iter()), + } + } + + pub fn len(&self) -> usize { + self.wire_indices + .as_ref() + .map_or(self.wires.len(), Vec::len) + } + + pub fn get(&self, index: usize) -> Option<&WireModel> { + match &self.wire_indices { + Some(indices) => indices + .get(index) + .and_then(|&wire| self.wires.get(wire as usize)), + None => self.wires.get(index), + } + } + + pub fn segments(&self) -> Option<&[SegmentRef]> { + self.segments.as_deref() + } + + pub fn source_wire(&self, index: u32) -> Option<&WireModel> { + self.wires.get(index as usize) + } + + pub fn query_aabb(&self) -> Option<[f64; 4]> { + self.query_aabb + } +} + +impl WireSource for InteractionCandidates { + fn iter(&self) -> WireIter<'_> { + self.iter() + } + + fn len(&self) -> usize { + self.len() + } + + fn get(&self, index: usize) -> Option<&WireModel> { + self.get(index) + } + + fn segments(&self) -> Option<&[SegmentRef]> { + self.segments() + } + + fn source_wire(&self, index: u32) -> Option<&WireModel> { + self.source_wire(index) + } +} + +impl WireSource for [WireModel] { + fn iter(&self) -> WireIter<'_> { + WireIter::All(<[WireModel]>::iter(self)) + } + + fn len(&self) -> usize { + <[WireModel]>::len(self) + } + + fn get(&self, index: usize) -> Option<&WireModel> { + <[WireModel]>::get(self, index) + } + + fn source_wire(&self, index: u32) -> Option<&WireModel> { + self.get(index as usize) + } +} + +impl WireSource for Vec { + fn iter(&self) -> WireIter<'_> { + WireIter::All(self.as_slice().iter()) + } + + fn len(&self) -> usize { + Vec::len(self) + } + + fn get(&self, index: usize) -> Option<&WireModel> { + self.as_slice().get(index) + } + + fn source_wire(&self, index: u32) -> Option<&WireModel> { + self.get(index as usize) + } +} + +impl WireSource for [WireModel; N] { + fn iter(&self) -> WireIter<'_> { + WireIter::All(self.as_slice().iter()) + } + + fn len(&self) -> usize { + N + } + + fn get(&self, index: usize) -> Option<&WireModel> { + self.as_slice().get(index) + } + + fn source_wire(&self, index: u32) -> Option<&WireModel> { + self.get(index as usize) + } +} + +#[derive(Clone)] +pub enum WireIter<'a> { + All(std::slice::Iter<'a, WireModel>), + Indexed { + wires: &'a [WireModel], + indices: std::slice::Iter<'a, u32>, + }, +} + +impl<'a> Iterator for WireIter<'a> { + type Item = &'a WireModel; + + fn next(&mut self) -> Option { + match self { + Self::All(iter) => iter.next(), + Self::Indexed { wires, indices } => { + indices.next().and_then(|&idx| wires.get(idx as usize)) + } + } + } +} + +fn finite_wire_aabb(wire: &WireModel) -> Option<[f64; 4]> { + let [min_x, min_y, max_x, max_y] = wire.aabb; + (min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()).then(|| { + let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs())); + let pad = (mag * f32::EPSILON * 2.0) as f64; + let mut aabb = [ + min_x as f64 - pad, + min_y as f64 - pad, + max_x as f64 + pad, + max_y as f64 + pad, + ]; + // Some valid snap points are outside visible curve bounds: notably the + // center of a short arc. Keep those discoverable without widening the + // segment query used by Intersection/Nearest. + for (point, _) in &wire.snap_pts { + if point.x.is_finite() && point.y.is_finite() { + aabb[0] = aabb[0].min(point.x); + aabb[1] = aabb[1].min(point.y); + aabb[2] = aabb[2].max(point.x); + aabb[3] = aabb[3].max(point.y); + } + } + aabb + }) +} + +fn wire_point(wire: &WireModel, index: usize) -> [f64; 3] { + let high = wire.points[index]; + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ] +} + +fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool { + a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3] +} diff --git a/src/scene/pick/mod.rs b/src/scene/pick/mod.rs index d30c14b70..4ccae76ff 100644 --- a/src/scene/pick/mod.rs +++ b/src/scene/pick/mod.rs @@ -1,6 +1,6 @@ -pub mod hit_test; -pub mod selection_state; pub mod grip; +pub mod hit_test; +pub mod interaction_index; pub mod quadtree; -pub mod wire_grid; +pub mod selection_state; pub mod xclip; diff --git a/src/scene/pick/wire_grid.rs b/src/scene/pick/wire_grid.rs deleted file mode 100644 index cebdbc1c0..000000000 --- a/src/scene/pick/wire_grid.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Uniform spatial grid over a hit-test wire set's world-XY AABBs. -//! -//! Snap and hover hit-testing scan the full model wire set on every cursor -//! move. On a heavily block-instanced drawing that set explodes to millions of -//! wires, so an O(N) per-move scan — even with a cheap AABB reject — stalls the -//! event loop. This grid is built once per geometry epoch (cached alongside the -//! wire set) and answers "which wires are near this cursor point" in O(cells in -//! range), turning the per-move cost from O(all wires) into O(local). -//! -//! Wires whose AABB is unbounded (previews / entities with no usable -//! bounding box) or spans a large fraction of the grid (drawing-length lines) -//! go into an always-checked `oversized` list rather than being smeared across -//! thousands of cells. - -use crate::scene::model::wire_model::WireModel; - -/// A wire spanning more than this many cells is stored once in `oversized` -/// instead of being inserted into every overlapped cell. -const MAX_SPAN_CELLS: u64 = 64; - -pub struct WireGrid { - min: [f64; 2], - cell: f64, - cols: u32, - rows: u32, - cells: Vec>, - /// Indices always returned as candidates (unbounded / huge AABB). - oversized: Vec, -} - -impl WireGrid { - fn finite_aabb(w: &WireModel) -> Option<[f64; 4]> { - let a = w.aabb; - (a[0].is_finite() && a[1].is_finite() && a[2].is_finite() && a[3].is_finite()) - .then_some([a[0] as f64, a[1] as f64, a[2] as f64, a[3] as f64]) - } - - pub fn build(wires: &[WireModel]) -> Self { - // Overall finite extent. - let mut min = [f64::INFINITY; 2]; - let mut max = [f64::NEG_INFINITY; 2]; - for w in wires { - if let Some(a) = Self::finite_aabb(w) { - min[0] = min[0].min(a[0]); - min[1] = min[1].min(a[1]); - max[0] = max[0].max(a[2]); - max[1] = max[1].max(a[3]); - } - } - if !min[0].is_finite() { - // No finite wires — everything is oversized (always checked). - return Self { - min: [0.0, 0.0], - cell: 1.0, - cols: 1, - rows: 1, - cells: vec![Vec::new()], - oversized: (0..wires.len() as u32).collect(), - }; - } - let ext_x = (max[0] - min[0]).max(1e-6); - let ext_y = (max[1] - min[1]).max(1e-6); - // Aim for ~8 wires/cell; a square-ish cell keeps queries tight. - let target_cells = ((wires.len() / 8).max(1)) as f64; - let cell = ((ext_x * ext_y) / target_cells).sqrt().max(1e-6); - let cols = (((ext_x / cell).ceil() as i64) + 1).clamp(1, 8192) as u32; - let rows = (((ext_y / cell).ceil() as i64) + 1).clamp(1, 8192) as u32; - - let mut cells = vec![Vec::::new(); (cols as usize) * (rows as usize)]; - let mut oversized = Vec::new(); - - let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32; - let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32; - - for (idx, w) in wires.iter().enumerate() { - let Some(a) = Self::finite_aabb(w) else { - oversized.push(idx as u32); - continue; - }; - let c0 = col_of(a[0]); - let c1 = col_of(a[2]); - let r0 = row_of(a[1]); - let r1 = row_of(a[3]); - let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; - if span > MAX_SPAN_CELLS { - oversized.push(idx as u32); - continue; - } - for r in r0..=r1 { - let base = (r * cols) as usize; - for c in c0..=c1 { - cells[base + c as usize].push(idx as u32); - } - } - } - - Self { - min, - cell, - cols, - rows, - cells, - oversized, - } - } - - /// Candidate wire indices whose cell overlaps the `radius`-padded box around - /// `(x, y)`. Always includes the oversized list. May contain a wire more - /// than once is avoided by dedup. - pub fn query(&self, x: f64, y: f64, radius: f64) -> Vec { - let r = radius.max(0.0); - let mut out = self.oversized.clone(); - // Query box fully outside the grid → only oversized candidates. - if x + r < self.min[0] - || y + r < self.min[1] - || x - r > self.min[0] + self.cols as f64 * self.cell - || y - r > self.min[1] + self.rows as f64 * self.cell - { - return out; - } - let clampc = |v: f64| v.clamp(0.0, (self.cols - 1) as f64) as u32; - let clampr = |v: f64| v.clamp(0.0, (self.rows - 1) as f64) as u32; - let c0 = clampc(((x - r) - self.min[0]) / self.cell); - let c1 = clampc(((x + r) - self.min[0]) / self.cell); - let r0 = clampr(((y - r) - self.min[1]) / self.cell); - let r1 = clampr(((y + r) - self.min[1]) / self.cell); - for row in r0..=r1 { - let base = (row * self.cols) as usize; - for col in c0..=c1 { - out.extend_from_slice(&self.cells[base + col as usize]); - } - } - out.sort_unstable(); - out.dedup(); - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn wire(x: f32, y: f32) -> WireModel { - let mut w = WireModel::solid_f64("t".into(), vec![[x as f64, y as f64, 0.0]], [0.0; 4], false); - w.aabb = [x, y, x, y]; - w - } - - #[test] - fn query_is_local_and_hits_the_point() { - // 50×50 lattice of point-wires spaced 10 apart (2500 wires). - let mut wires = Vec::new(); - for gx in 0..50 { - for gy in 0..50 { - wires.push(wire(gx as f32 * 10.0, gy as f32 * 10.0)); - } - } - let n = wires.len(); - let grid = WireGrid::build(&wires); - // Tight query around one lattice point. - let hits = grid.query(250.0, 250.0, 5.0); - assert!(hits.len() < n / 10, "query too broad: {} of {n}", hits.len()); - let idx = (25u32 * 50) + 25; // gx=25, gy=25 - assert!(hits.contains(&idx), "the on-point wire must be a candidate"); - // A far point must not drag in the whole lattice. - let far = grid.query(-9999.0, -9999.0, 5.0); - assert!(far.len() < n / 10, "far query too broad: {}", far.len()); - } - - #[test] - fn unbounded_wire_is_always_a_candidate() { - let mut wires = vec![wire(0.0, 0.0), wire(1_000_000.0, 1_000_000.0)]; - wires[1].aabb = WireModel::UNBOUNDED_AABB; - let grid = WireGrid::build(&wires); - let hits = grid.query(0.0, 0.0, 1.0); - assert!(hits.contains(&1), "unbounded wire must always be returned"); - } -} diff --git a/src/snap.rs b/src/snap.rs index 36e675bb2..1599925b2 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -10,6 +10,7 @@ use iced::{Point, Rectangle}; use crate::command::TangentObject; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; +use crate::scene::pick::interaction_index::WireSource; use crate::ui::overlay::CROSSHAIR_ARM; // ── Snap type ───────────────────────────────────────────────────────────── @@ -205,7 +206,6 @@ impl Snapper { } } - /// Whether temporary tracking points are being acquired and drawn: OTRACK /// on, or the Extension object snap on. Extension tracks a segment's line /// only from an acquired endpoint, and works independently of OTRACK's @@ -221,23 +221,6 @@ impl Snapper { self.tracking_active() || (self.snap_enabled && self.is_on(SnapType::Parallel)) } - /// True when `p` coincides with one of the acquired temporary tracking - /// points. Extension snaps a segment's line only from such acquired - /// endpoints (#262), so extensions aren't live for every object in the - /// drawing. The tolerance mirrors `edge_dirs_at`: acquired points are f32 - /// truncations of the true vertices, so the match window scales with - /// coordinate magnitude with a tight floor near the origin. - fn is_tracked_endpoint(&self, p: glam::DVec3) -> bool { - if self.tracking_points.is_empty() { - return false; - } - let tol = 1e-8_f64.max(2e-12 * p.x.abs().max(p.y.abs())); - let tol2 = tol * tol; - self.tracking_points - .iter() - .any(|t| (*t - p).length_squared() < tol2) - } - pub fn toggle_global(&mut self) { self.snap_enabled = !self.snap_enabled; } @@ -278,10 +261,10 @@ impl Snapper { /// Update dwell tracking and possibly acquire a new OST point. /// Should be called on every ViewportMove when snap is active. /// `snap_world` is the current snap result world point (if any). - pub fn update_otrack_dwell( + pub fn update_otrack_dwell( &mut self, snap_world: Option, - wires: &[WireModel], + wires: &W, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, @@ -369,7 +352,12 @@ impl Snapper { /// it is already tracked; drops the oldest when the 4-point cap is reached. /// Edge directions are scanned once here, at acquisition, so OTRACK can align /// to a segment's extension without rescanning geometry per move (#219). - fn acquire_tracking_point(&mut self, p: DVec3, wires: &[WireModel], endpoints_only: bool) { + fn acquire_tracking_point( + &mut self, + p: DVec3, + wires: &W, + endpoints_only: bool, + ) { if self .tracking_points .iter() @@ -399,11 +387,11 @@ impl Snapper { /// is only re-examined once the cursor moves off), acquire it now as the /// cursor leaves. This makes "pause on a corner, then drag along its edge" /// reliably capture the corner (#219). - fn acquire_on_leave( + fn acquire_on_leave( &mut self, now: Instant, dwell_ms: u128, - wires: &[WireModel], + wires: &W, endpoints_only: bool, ) { if self.dwell_acquired { @@ -464,23 +452,32 @@ impl Snapper { ((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt() }; - // Candidate angles in [0,180); each ray extends both ways via the - // signed projection `t`, so 0°/90° cover horizontal/vertical. - let mut angles: Vec = Vec::new(); - match polar_step_deg.filter(|s| *s > 1e-3) { - Some(step) => { - let step = step as f64; - let mut a = 0.0_f64; - while a < 180.0 - 1e-3 { - angles.push(a); - a += step; - } + // Only rays near the cursor direction can fall inside the aperture. + // Quantize that direction and keep its two neighbours; enumerating all + // 180 rays at a 1° polar increment multiplied every tracking-point and + // ray-intersection check for no observable benefit. + let ray_angles = |origin: DVec3| -> Vec { + let Some(step) = polar_step_deg.filter(|s| *s > 1e-3) else { + return vec![0.0, 90.0]; + }; + let d = cursor_world - origin; + let ux = d.dot(ucs_x); + let uy = d.dot(ucs_y); + if ux * ux + uy * uy < 1e-24 { + return vec![0.0]; } - None => { - angles.push(0.0); - angles.push(90.0); - } - } + let step = step as f64; + let angle = uy.atan2(ux).to_degrees().rem_euclid(180.0); + let nearest = (angle / step).round() * step; + let mut out = vec![ + (nearest - step).rem_euclid(180.0), + nearest.rem_euclid(180.0), + (nearest + step).rem_euclid(180.0), + ]; + out.sort_by(f64::total_cmp); + out.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + out + }; // Build candidate rays tagged by origin group so two rays sharing an // origin (a parallel pencil that only meets at that origin) are never @@ -492,7 +489,7 @@ impl Snapper { } let mut rays: Vec = Vec::new(); for (gi, &tp) in self.tracking_points.iter().enumerate() { - for &adeg in &angles { + for adeg in ray_angles(tp) { let ar = adeg.to_radians(); rays.push(Ray { origin: tp, @@ -538,17 +535,14 @@ impl Snapper { let otrack_ray_count = rays.len(); const POLAR_GROUP: usize = usize::MAX; const ORTHO_GROUP: usize = usize::MAX - 1; - if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) { - let step = step as f64; - let mut a = 0.0_f64; - while a < 180.0 - 1e-3 { - let ar = a.to_radians(); + if let (Some(_), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) { + for angle in ray_angles(lp) { + let ar = angle.to_radians(); rays.push(Ray { origin: lp, dir: ucs_x * ar.cos() + ucs_y * ar.sin(), group: POLAR_GROUP, }); - a += step; } } // Ortho axis rays from `last_point`, so a tracking ray crossing the @@ -660,10 +654,10 @@ impl Snapper { /// parallel to it. Dwelling on the SAME reference line again removes it /// (toggle). Curves (circle/arc/ellipse) are ignored — "parallel to a curve" /// is undefined. Call on every viewport move. (#277) - pub fn update_parallel( + pub fn update_parallel( &mut self, cursor_world: Vec3, - wires: &[WireModel], + wires: &W, view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, @@ -676,9 +670,14 @@ impl Snapper { } const PAR_DWELL_MS: u128 = 150; let parallel = |a: Vec3, b: Vec3| (a.x * b.x + a.y * b.y).abs() > 0.9998; - let Some((dir, pt)) = - nearest_segment(cursor_world, wires, view_rot, eye, bounds, self.osnap_radius_px) - else { + let Some((dir, pt)) = nearest_segment( + cursor_world, + wires, + view_rot, + eye, + bounds, + self.osnap_radius_px, + ) else { // Off all lines: drop the in-progress candidate, keep the reference. self.parallel_dwell = None; return; @@ -748,11 +747,11 @@ impl Snapper { } /// Only runs Tangent snap — used when a command needs object picks via tangent. - pub fn snap_tangent_only( + pub fn snap_tangent_only( &self, cursor_world: Vec3, cursor_screen: Point, - wires: &[WireModel], + wires: &W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -792,11 +791,11 @@ impl Snapper { } /// Find the best snap candidate near the cursor. - pub fn snap( + pub fn snap( &self, cursor_world: glam::DVec3, cursor_screen: Point, - wires: &[WireModel], + wires: &W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -897,10 +896,7 @@ impl Snapper { // test by the bound's own quantization so the cull never rejects a // genuinely in-range wire (it only ever over-includes, which the // per-vertex screen test below then rejects precisely). - let mag = wire - .aabb - .iter() - .fold(0.0f32, |m, c| m.max(c.abs())); + let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs())); let pad = (mag * f32::EPSILON * 2.0) as f64; let r = world_snap_r as f64 + pad; cursor_world.x + r >= wire.aabb[0] as f64 @@ -922,13 +918,16 @@ impl Snapper { // cursor cell held ~1k wires / ~240k points → 15 s pre-gate.) const MAX_PAIRWISE_POINTS: usize = 3_000; let mut in_range_pts = 0usize; - for w in wires.iter().filter(|w| wire_in_range(w)) { - in_range_pts += w.points.len(); - if in_range_pts > MAX_PAIRWISE_POINTS { - break; + if wires.segments().is_none() { + for w in wires.iter().filter(|w| wire_in_range(w)) { + in_range_pts += w.points.len(); + if in_range_pts > MAX_PAIRWISE_POINTS { + break; + } } } - let allow_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS; + let allow_pairwise = wires.segments().is_some() || in_range_pts <= MAX_PAIRWISE_POINTS; + let local_segments = indexed_segments(wires); let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { let screen = world_to_screen(world, view_rot, eye, bounds); @@ -960,7 +959,7 @@ impl Snapper { }; // ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ────── - for wire in wires { + for wire in wires.iter() { for &(world, hint) in &wire.snap_pts { let snap_type = match hint { SnapHint::Center => SnapType::Center, @@ -977,17 +976,14 @@ impl Snapper { // ── Endpoint ─────────────────────────────────────────────────────── if self.is_on(SnapType::Endpoint) { - for wire in wires { + for wire in wires.iter() { if !wire_in_range(wire) { continue; } if !wire.key_vertices.is_empty() { // Use explicit vertices (Line, LwPolyline): every vertex is an endpoint. for &p in &wire.key_vertices { - try_pt( - glam::DVec3::new(p[0], p[1], p[2]), - SnapType::Endpoint, - ); + try_pt(glam::DVec3::new(p[0], p[1], p[2]), SnapType::Endpoint); } } else { // Tessellated curves (Circle, Arc, Ellipse): only an OPEN @@ -1002,11 +998,17 @@ impl Snapper { .any(|(_, h)| matches!(h, SnapHint::Quadrant)); if !closed { if let Some(&p) = wire.points.first() { - try_pt(glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), SnapType::Endpoint); + try_pt( + glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), + SnapType::Endpoint, + ); } if wire.points.len() > 1 { if let Some(&p) = wire.points.last() { - try_pt(glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), SnapType::Endpoint); + try_pt( + glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), + SnapType::Endpoint, + ); } } } @@ -1021,7 +1023,7 @@ impl Snapper { // every chord here would otherwise turn a circle's tessellation into // a haze of false midpoint hits. See #34. if self.is_on(SnapType::Midpoint) { - for wire in wires { + for wire in wires.iter() { if !wire_in_range(wire) { continue; } @@ -1039,13 +1041,23 @@ impl Snapper { // ── Nearest — closest point on any segment (clamped) ────────────── if self.is_on(SnapType::Nearest) { - for wire in wires { - if !wire_in_range(wire) { - continue; + if let Some(segments) = &local_segments { + for seg in segments { + try_pt( + nearest_on_segment(cursor_world, seg.a, seg.b), + SnapType::Nearest, + ); } - for i in 0..wire.points.len().saturating_sub(1) { - let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); - try_pt(p, SnapType::Nearest); + } else { + for wire in wires.iter() { + if !wire_in_range(wire) { + continue; + } + for i in 0..wire.points.len().saturating_sub(1) { + let p = + nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); + try_pt(p, SnapType::Nearest); + } } } } @@ -1058,50 +1070,95 @@ impl Snapper { // on its screen distance to the cursor like every other snap, so it // offers when the cursor is near the perpendicular foot. (#118) if self.is_on(SnapType::Perpendicular) { - let q = self.from_point.map(|v| v.as_dvec3()).unwrap_or(cursor_world); - for wire in wires { - if !wire_in_range(wire) { - continue; - } - for i in 0..wire.points.len().saturating_sub(1) { - if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { + let q = self + .from_point + .map(|v| v.as_dvec3()) + .unwrap_or(cursor_world); + if let Some(segments) = &local_segments { + for seg in segments { + if let Some(foot) = perp_foot(q, seg.a, seg.b) { try_pt(foot, SnapType::Perpendicular); } } + } else { + for wire in wires.iter() { + if !wire_in_range(wire) { + continue; + } + for i in 0..wire.points.len().saturating_sub(1) { + if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { + try_pt(foot, SnapType::Perpendicular); + } + } + } } } // ── Intersection — segment-segment intersections (pairwise, gated) ── if self.is_on(SnapType::Intersection) && allow_pairwise { - for i in 0..wires.len() { - if !wire_in_range(&wires[i]) { - continue; + if let Some(segments) = &local_segments { + // Cursor-local segment bounds make this set small. In a + // pathological dense crossing retain closest segments instead + // of disabling Intersection for the whole aperture. + const MAX_LOCAL_INTERSECTION_SEGMENTS: usize = 512; + let mut local: Vec<&IndexedSegment> = segments.iter().collect(); + local.sort_by(|a, b| { + segment_aabb_distance2(a, cursor_world) + .total_cmp(&segment_aabb_distance2(b, cursor_world)) + }); + local.truncate(MAX_LOCAL_INTERSECTION_SEGMENTS); + local.sort_by(|a, b| a.min_x().total_cmp(&b.min_x())); + for i in 0..local.len() { + let a = local[i]; + for &b in local.iter().skip(i + 1) { + if b.min_x() > a.max_x() { + break; + } + if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { + continue; + } + if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) { + try_pt(pt, SnapType::Intersection); + } + } } - for j in (i + 1)..wires.len() { - if !wire_in_range(&wires[j]) { + } else { + for i in 0..wires.len() { + let Some(wire_i) = wires.get(i) else { + continue; + }; + if !wire_in_range(wire_i) { continue; } - for ai in 0..wires[i].points.len().saturating_sub(1) { - // S: pre-convert outside inner loop - let a0 = wp_f64(&wires[i], ai); - let a1 = wp_f64(&wires[i], ai + 1); - let a_min_x = a0.x.min(a1.x); - let a_max_x = a0.x.max(a1.x); - let a_min_y = a0.y.min(a1.y); - let a_max_y = a0.y.max(a1.y); - for bi in 0..wires[j].points.len().saturating_sub(1) { - let b0 = wp_f64(&wires[j], bi); - let b1 = wp_f64(&wires[j], bi + 1); - // O: tight per-segment AABB overlap cull - if a_max_x < b0.x.min(b1.x) - || a_min_x > b0.x.max(b1.x) - || a_max_y < b0.y.min(b1.y) - || a_min_y > b0.y.max(b1.y) - { - continue; - } - if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection); + for j in (i + 1)..wires.len() { + let Some(wire_j) = wires.get(j) else { + continue; + }; + if !wire_in_range(wire_j) { + continue; + } + for ai in 0..wire_i.points.len().saturating_sub(1) { + // S: pre-convert outside inner loop + let a0 = wp_f64(wire_i, ai); + let a1 = wp_f64(wire_i, ai + 1); + let a_min_x = a0.x.min(a1.x); + let a_max_x = a0.x.max(a1.x); + let a_min_y = a0.y.min(a1.y); + let a_max_y = a0.y.max(a1.y); + for bi in 0..wire_j.points.len().saturating_sub(1) { + let b0 = wp_f64(wire_j, bi); + let b1 = wp_f64(wire_j, bi + 1); + // O: tight per-segment AABB overlap cull + if a_max_x < b0.x.min(b1.x) + || a_min_x > b0.x.max(b1.x) + || a_max_y < b0.y.min(b1.y) + || a_min_y > b0.y.max(b1.y) + { + continue; + } + if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { + try_pt(pt, SnapType::Intersection); + } } } } @@ -1109,82 +1166,37 @@ impl Snapper { } } - // ── Extension — along the extension of a segment beyond endpoints ── - // Every segment's line can be extended past either endpoint, so a - // polyline offers an extension off each of its vertices, not just the - // first and last (#259). Extension is live only from endpoints the user - // has acquired as temporary tracking points (#262), so with none - // acquired there is nothing to extend — skip the whole scan. That is the - // common case and keeps a large drawing responsive. + // ── Extension — persistent rays captured at endpoint acquisition ── + // The source geometry may be far outside the cursor's spatial query by + // the time its extension is used. Keep the outward incident directions + // captured with each tracking point instead of rescanning either the + // whole drawing or only the now-local candidates. if self.is_on(SnapType::Extension) && !self.tracking_points.is_empty() { - for wire in wires { - let n = wire.points.len(); - if n < 2 { - continue; - } - for i in 0..n - 1 { - let a = wp_f64(wire, i); - let b = wp_f64(wire, i + 1); - // NaN sentinels separate sub-paths — skip a segment spanning one. - if !a.x.is_finite() || !b.x.is_finite() || (a - b).length_squared() < 1e-18 { - continue; - } - // Only extend from an endpoint the user has acquired as a - // temporary tracking point, so the extension isn't live for - // every object in the drawing (#262). - // Beyond `a`, away from `b`. - if self.is_tracked_endpoint(a) { - if let Some(ext) = extension_snap( - cursor_world, - a, - a - b, - view_rot, - eye, - bounds, - self.osnap_radius_px, - ) { - try_pt(ext, SnapType::Extension); - } - } - // Beyond `b`, away from `a`. - if self.is_tracked_endpoint(b) { - if let Some(ext) = extension_snap( - cursor_world, - b, - b - a, - view_rot, - eye, - bounds, - self.osnap_radius_px, - ) { - try_pt(ext, SnapType::Extension); - } + for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) { + for &dir in dirs { + if let Some(ext) = extension_snap( + cursor_world, + origin, + dir, + view_rot, + eye, + bounds, + self.osnap_radius_px, + ) { + try_pt(ext, SnapType::Extension); } } } - // Extended intersection: where two segments would cross if their - // lines were extended. That crossing can be far from both segments, - // so `wire_in_range` (near-cursor segment) is the wrong gate — gather - // segments whose *infinite line* passes near the cursor instead, then - // pair them. A crossing inside both segments is a real Intersection, - // so skip it here (#247). + // Extended intersection: intersect only outward rays whose infinite + // lines pass near the cursor. At most four acquired points × six + // directions participate, independent of drawing density. let filter2 = (self.osnap_radius_px * 2.0).powi(2); let mut cand: Vec<(glam::DVec3, glam::DVec3)> = Vec::new(); - for wire in wires { - for k in 0..wire.points.len().saturating_sub(1) { - let a0 = wp_f64(wire, k); - let a1 = wp_f64(wire, k + 1); - if !a0.x.is_finite() || !a1.x.is_finite() { - continue; - } - // Only lines with an acquired endpoint contribute an extended - // crossing, matching the per-segment extension gate (#262). - if !self.is_tracked_endpoint(a0) && !self.is_tracked_endpoint(a1) { - continue; - } - let s0 = world_to_screen(a0, view_rot, eye, bounds); - let s1 = world_to_screen(a1, view_rot, eye, bounds); + for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) { + for &dir in dirs { + let s0 = world_to_screen(origin, view_rot, eye, bounds); + let s1 = world_to_screen(origin + dir, view_rot, eye, bounds); let ex = s1.x - s0.x; let ey = s1.y - s0.y; let l2 = ex * ex + ey * ey; @@ -1194,23 +1206,24 @@ impl Snapper { // Perpendicular screen distance² from the cursor to the line. let cross = ex * (cursor_screen.y - s0.y) - ey * (cursor_screen.x - s0.x); if cross * cross / l2 <= filter2 { - cand.push((a0, a1)); + cand.push((origin, dir)); } } } for i in 0..cand.len() { - let (a0, a1) = cand[i]; - let d1 = a1 - a0; - for &(b0, b1) in cand.iter().skip(i + 1) { - let d2 = b1 - b0; + let (a0, d1) = cand[i]; + for &(b0, d2) in cand.iter().skip(i + 1) { + if (a0 - b0).length_squared() < 1e-18 { + continue; + } let denom = d1.x * d2.y - d1.y * d2.x; if denom.abs() < 1e-12 { continue; // parallel } let t1 = ((b0.x - a0.x) * d2.y - (b0.y - a0.y) * d2.x) / denom; let t2 = ((b0.x - a0.x) * d1.y - (b0.y - a0.y) * d1.x) / denom; - if (0.0..=1.0).contains(&t1) && (0.0..=1.0).contains(&t2) { - continue; // real crossing — handled by Intersection + if t1 < 0.05 || t2 < 0.05 { + continue; } // Emit as an Intersection, not an Extension: the crossing is // a distinct point and must outrank the per-segment extension @@ -1225,38 +1238,84 @@ impl Snapper { // ── Apparent Intersection — screen-space intersections (pairwise, gated) ── // L: pre-project each in-range wire's points to screen once, not once per segment pair. if self.is_on(SnapType::ApparentIntersection) && allow_pairwise { - let screen_pts: Vec>> = wires - .iter() - .map(|w| { - if !wire_in_range(w) { - return None; + if let Some(segments) = &local_segments { + const MAX_LOCAL_APPARENT_SEGMENTS: usize = 512; + let mut local: Vec<&IndexedSegment> = segments.iter().collect(); + local.sort_by(|a, b| { + segment_aabb_distance2(a, cursor_world) + .total_cmp(&segment_aabb_distance2(b, cursor_world)) + }); + local.truncate(MAX_LOCAL_APPARENT_SEGMENTS); + let projected: Vec<(Point, Point)> = local + .iter() + .map(|seg| { + ( + world_to_screen(seg.a, view_rot, eye, bounds), + world_to_screen(seg.b, view_rot, eye, bounds), + ) + }) + .collect(); + for i in 0..local.len() { + for j in (i + 1)..local.len() { + if local[i].wire == local[j].wire { + continue; + } + if let Some((ta, _)) = seg_intersect_2d( + projected[i].0, + projected[i].1, + projected[j].0, + projected[j].1, + ) { + try_pt( + local[i].a + ta as f64 * (local[i].b - local[i].a), + SnapType::ApparentIntersection, + ); + } } - Some( - (0..w.points.len()) - .map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds)) - .collect::>(), - ) - }) - .collect(); + } + } else { + let screen_pts: Vec>> = wires + .iter() + .map(|w| { + if !wire_in_range(w) { + return None; + } + Some( + (0..w.points.len()) + .map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds)) + .collect::>(), + ) + }) + .collect(); - for i in 0..wires.len() { - let Some(ref si) = screen_pts[i] else { - continue; - }; - for j in (i + 1)..wires.len() { - let Some(ref sj) = screen_pts[j] else { + for i in 0..wires.len() { + let Some(ref si) = screen_pts[i] else { continue; }; - for ai in 0..wires[i].points.len().saturating_sub(1) { - let sa0 = si[ai]; - let sa1 = si[ai + 1]; - for bi in 0..wires[j].points.len().saturating_sub(1) { - let sb0 = sj[bi]; - let sb1 = sj[bi + 1]; - if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) { - let wa0 = wp_f64(&wires[i], ai); - let wa1 = wp_f64(&wires[i], ai + 1); - try_pt(wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection); + let Some(wire_i) = wires.get(i) else { + continue; + }; + for j in (i + 1)..wires.len() { + let Some(ref sj) = screen_pts[j] else { + continue; + }; + let Some(wire_j) = wires.get(j) else { + continue; + }; + for ai in 0..wire_i.points.len().saturating_sub(1) { + let sa0 = si[ai]; + let sa1 = si[ai + 1]; + for bi in 0..wire_j.points.len().saturating_sub(1) { + let sb0 = sj[bi]; + let sb1 = sj[bi + 1]; + if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) { + let wa0 = wp_f64(wire_i, ai); + let wa1 = wp_f64(wire_i, ai + 1); + try_pt( + wa0 + ta as f64 * (wa1 - wa0), + SnapType::ApparentIntersection, + ); + } } } } @@ -1268,12 +1327,22 @@ impl Snapper { // Operates directly on tangent_geoms geometry — independent of the // wire.points rendering structure so polyline segments work correctly. if self.is_on(SnapType::Tangent) { - for wire in wires { + for wire in wires.iter() { for tg in &wire.tangent_geoms { let (world_pt, d2) = match tg { TangentGeom::Line { p1, p2 } => { - let sp0 = world_to_screen(glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64), view_rot, eye, bounds); - let sp1 = world_to_screen(glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64), view_rot, eye, bounds); + let sp0 = world_to_screen( + glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64), + view_rot, + eye, + bounds, + ); + let sp1 = world_to_screen( + glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64), + view_rot, + eye, + bounds, + ); let d2 = dist2_to_segment(cursor_screen, sp0, sp1); let t = t_on_segment(cursor_screen, sp0, sp1); let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1)); @@ -1322,15 +1391,20 @@ impl Snapper { let dx = cursor_screen.x - sc.x; let dy = cursor_screen.y - sc.y; let dl = (dx * dx + dy * dy).sqrt(); - let (nx, ny) = - if dl > 1e-6 { (dx / dl, -dy / dl) } else { (1.0, 0.0) }; + let (nx, ny) = if dl > 1e-6 { + (dx / dl, -dy / dl) + } else { + (1.0, 0.0) + }; Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z) }); (w, edge_d * edge_d) } }; - let (tier, sub) = - (snap_tier(SnapType::Tangent), snap_priority(SnapType::Tangent)); + let (tier, sub) = ( + snap_tier(SnapType::Tangent), + snap_priority(SnapType::Tangent), + ); let screen_pt = world_to_screen(world_pt.as_dvec3(), view_rot, eye, bounds); if d2 < radius2 && in_bounds(screen_pt) @@ -1345,7 +1419,11 @@ impl Snapper { p2: glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64), }, TangentGeom::Circle { center, radius } => TangentObject::Circle { - center: glam::DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64), + center: glam::DVec3::new( + center[0] as f64, + center[1] as f64, + center[2] as f64, + ), radius: *radius as f64, }, }; @@ -1372,7 +1450,7 @@ impl Snapper { // the cursor is to the curve. Runs here, after `try_pt`'s borrow ends, // so it can update the candidate state directly. (#152) if self.is_on(SnapType::Center) { - for wire in wires { + for wire in wires.iter() { if !wire_in_range(wire) { continue; } @@ -1428,7 +1506,14 @@ impl Snapper { // intersection yields none, so its guides simply don't draw. (#238, #247, #259) if let Some(b) = best.as_mut() { if matches!(b.snap_type, SnapType::Extension | SnapType::Intersection) { - let (b1, b2) = extension_bases_screen(b.world, wires, view_rot, eye, bounds); + let (b1, b2) = extension_bases_screen( + b.world, + &self.tracking_points, + &self.tracking_dirs, + view_rot, + eye, + bounds, + ); b.extension_base = b1; b.extension_base2 = b2; } @@ -1507,14 +1592,57 @@ fn snap_priority(t: SnapType) -> u8 { /// alignment ray along each so the cursor can track a segment's extension, not /// just the ortho/polar axes (#219). Scanned once, at acquisition — not per /// move. Empty when `p` is not a segment endpoint (midpoint / centre / node). -fn edge_dirs_at(p: DVec3, wires: &[WireModel]) -> Vec { +fn edge_dirs_at(p: DVec3, wires: &W) -> Vec { // Acquired points and reconstructed wire vertices are both f64. Keep a // small scale-aware window for double-single reconstruction residuals // without allowing unrelated UTM-scale vertices to match. let tol = 1e-8_f64.max(2e-12 * p.x.abs().max(p.y.abs())); let tol2 = tol * tol; let mut dirs: Vec = Vec::new(); - 'outer: for wire in wires { + let mut consider = |a: DVec3, b: DVec3| { + if !a.x.is_finite() || !b.x.is_finite() { + return false; + } + let at_a = (a - p).length_squared() < tol2; + let at_b = (b - p).length_squared() < tol2; + if !at_a && !at_b { + return false; + } + // Store the outward ray from the acquired endpoint. Unlike OTRACK's + // infinite alignment line, Extension needs the sign, so opposite + // collinear incident edges remain distinct. + let seg = if at_a { a - b } else { b - a }; + let l = (seg.x * seg.x + seg.y * seg.y).sqrt(); + if l < 1e-9 { + return false; + } + let d = DVec3::new(seg.x / l, seg.y / l, 0.0); + if dirs.iter().any(|e| e.x * d.x + e.y * d.y > 0.99996) { + return false; + } + dirs.push(d); + dirs.len() >= 6 + }; + let is_round = |wire: &WireModel| { + wire.snap_pts + .iter() + .any(|(_, hint)| matches!(hint, SnapHint::Quadrant | SnapHint::Center)) + }; + if let Some(segments) = indexed_segments(wires) { + for segment in segments { + if wires.source_wire(segment.wire).is_some_and(&is_round) { + continue; + } + if consider(segment.a, segment.b) { + break; + } + } + return dirs; + } + 'outer: for wire in wires.iter() { + if is_round(wire) { + continue; + } let n = wire.points.len(); if n < 2 { continue; @@ -1522,25 +1650,7 @@ fn edge_dirs_at(p: DVec3, wires: &[WireModel]) -> Vec { for i in 0..n - 1 { let a = wp_f64(wire, i); let b = wp_f64(wire, i + 1); - if !a.x.is_finite() || !b.x.is_finite() { - continue; // NaN sentinel separates sub-paths - } - if (a - p).length_squared() >= tol2 && (b - p).length_squared() >= tol2 { - continue; // neither endpoint is the acquired corner - } - let seg = b - a; - let l = (seg.x * seg.x + seg.y * seg.y).sqrt(); - if l < 1e-9 { - continue; - } - let d = DVec3::new(seg.x / l, seg.y / l, 0.0); - // Skip a direction already present (parallel within ~0.5°); the ray - // is bidirectional, so opposite signs are the same alignment line. - if dirs.iter().any(|e| (e.x * d.x + e.y * d.y).abs() > 0.99996) { - continue; - } - dirs.push(d); - if dirs.len() >= 6 { + if consider(a, b) { break 'outer; } } @@ -1548,6 +1658,72 @@ fn edge_dirs_at(p: DVec3, wires: &[WireModel]) -> Vec { dirs } +#[derive(Clone, Copy)] +struct IndexedSegment { + wire: u32, + a: DVec3, + b: DVec3, +} + +impl IndexedSegment { + fn min_x(self) -> f64 { + self.a.x.min(self.b.x) + } + + fn max_x(self) -> f64 { + self.a.x.max(self.b.x) + } + + fn min_y(self) -> f64 { + self.a.y.min(self.b.y) + } + + fn max_y(self) -> f64 { + self.a.y.max(self.b.y) + } +} + +fn indexed_segments(wires: &W) -> Option> { + let refs = wires.segments()?; + Some( + refs.iter() + .filter_map(|seg| { + let wire = wires.source_wire(seg.wire)?; + let start = seg.start as usize; + if start + 1 >= wire.points.len() { + return None; + } + let a = wp_f64(wire, start); + let b = wp_f64(wire, start + 1); + (a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) + .then_some(IndexedSegment { + wire: seg.wire, + a, + b, + }) + }) + .collect(), + ) +} + +fn segment_aabb_distance2(segment: &IndexedSegment, point: DVec3) -> f64 { + let dx = if point.x < segment.min_x() { + segment.min_x() - point.x + } else if point.x > segment.max_x() { + point.x - segment.max_x() + } else { + 0.0 + }; + let dy = if point.y < segment.min_y() { + segment.min_y() - point.y + } else if point.y > segment.max_y() { + point.y - segment.max_y() + } else { + 0.0 + }; + dx * dx + dy * dy +} + /// Reconstruct the absolute f64 position of wire vertex `i` from its /// double-single high/low pair. At UTM-scale coordinates the `points` (high) /// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so @@ -1588,7 +1764,11 @@ fn perp_foot(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option 2.0 { return None; } - Some(glam::DVec3::new(p0.x + t * d.x, p0.y + t * d.y, p0.z + t * d.z)) + Some(glam::DVec3::new( + p0.x + t * d.x, + p0.y + t * d.y, + p0.z + t * d.z, + )) } /// XY-plane segment-segment intersection. Returns `None` if parallel or outside. @@ -1598,7 +1778,12 @@ fn perp_foot(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option Option { +fn seg_intersect_3d( + a0: glam::DVec3, + a1: glam::DVec3, + b0: glam::DVec3, + b1: glam::DVec3, +) -> Option { let d1x = a1.x - a0.x; let d1y = a1.y - a0.y; let d2x = b1.x - b0.x; @@ -1623,7 +1808,11 @@ fn seg_intersect_3d(a0: glam::DVec3, a1: glam::DVec3, b0: glam::DVec3, b1: glam: if (za - zb).abs() > tol { return None; } - Some(glam::DVec3::new(a0.x + t * d1x, a0.y + t * d1y, 0.5 * (za + zb))) + Some(glam::DVec3::new( + a0.x + t * d1x, + a0.y + t * d1y, + 0.5 * (za + zb), + )) } /// Intersection of two infinite lines in the XY plane, each given by an origin @@ -1675,7 +1864,13 @@ fn circle_tangent_points(p: Vec3, center: Vec3, radius: f32) -> Option<(Vec3, Ve } let base = vy.atan2(vx); let off = (radius / d).acos(); - let at = |a: f32| Vec3::new(center.x + radius * a.cos(), center.y + radius * a.sin(), center.z); + let at = |a: f32| { + Vec3::new( + center.x + radius * a.cos(), + center.y + radius * a.sin(), + center.z, + ) + }; Some((at(base + off), at(base - off))) } @@ -1686,7 +1881,7 @@ fn extension_snap( origin: glam::DVec3, dir: glam::DVec3, view_rot: Mat4, - eye: glam::DVec3, + eye: glam::DVec3, bounds: Rectangle, radius_px: f32, ) -> Option { @@ -1707,16 +1902,13 @@ fn extension_snap( Some(world_pt) } -/// Find the endpoint(s) whose outward extension the snapped point lies on, and -/// return their screen positions so the overlay can draw a dashed guide from -/// each back to the snap point. A lone Extension snap yields one base; an -/// extended intersection (two extension lines crossing) yields both — so both -/// contributing extensions stay drawn when the crossing is caught. A genuine -/// on-segment intersection yields none: its crossing is between the endpoints, -/// never past them (`t < 0.05`). (#238, #247, #259) +/// Find acquired endpoint(s) whose outward ray contains `snapped`, returning +/// their screen positions for Extension guides. The captured ray set persists +/// after its source geometry leaves the cursor-local spatial query. fn extension_bases_screen( snapped: glam::DVec3, - wires: &[WireModel], + tracking_points: &[glam::DVec3], + tracking_dirs: &[Vec], view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -1726,50 +1918,21 @@ fn extension_bases_screen( // the tolerance stays scale-independent at UTM coordinates (a world² test // would reject the crossing base once coordinates reach ~1e7). let mut found: Vec<(f32, glam::DVec3, Point)> = Vec::new(); - for wire in wires { - let n = wire.points.len(); - if n < 2 { - continue; - } - // A round curve (circle / arc / ellipse) has no meaningful straight - // extension: its tessellation chords point every which way, so one - // almost always has an outward chord-extension that grazes the snapped - // point, drawing a spurious dashed guide radiating from the curve when - // the user only caught a plain line-line intersection (#276). An arc is - // open, so it carries no Quadrant hint — key off the Center hint that - // every round curve carries (the same signal `nearest_segment` uses), - // which straight lines never have. - if wire - .snap_pts - .iter() - .any(|(_, h)| matches!(h, SnapHint::Quadrant | SnapHint::Center)) - { - continue; - } - // Match the extension snap: every segment can be extended past either - // endpoint, so scan them all to find the base(s) the snapped point sits on. - for i in 0..n - 1 { - let a = wp_f64(wire, i); - let b = wp_f64(wire, i + 1); - if !a.x.is_finite() || !b.x.is_finite() { + for (&origin, dirs) in tracking_points.iter().zip(tracking_dirs) { + for &dir in dirs { + let len2 = dir.x * dir.x + dir.y * dir.y; + if len2 < 1e-12 { continue; } - for (origin, other) in [(a, b), (b, a)] { - let dir = origin - other; - let len2 = dir.x * dir.x + dir.y * dir.y; - if len2 < 1e-12 { - continue; - } - let t = ((snapped.x - origin.x) * dir.x + (snapped.y - origin.y) * dir.y) / len2; - if t < 0.05 { - continue; // must be beyond the endpoint, matching extension_snap - } - let on = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z); - let off = dist2(world_to_screen(on, view_rot, eye, bounds), snapped_screen); - if off <= 4.0 { - let base = world_to_screen(origin, view_rot, eye, bounds); - found.push((off, origin, base)); - } + let t = ((snapped.x - origin.x) * dir.x + (snapped.y - origin.y) * dir.y) / len2; + if t < 0.05 { + continue; + } + let on = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z); + let off = dist2(world_to_screen(on, view_rot, eye, bounds), snapped_screen); + if off <= 4.0 { + let base = world_to_screen(origin, view_rot, eye, bounds); + found.push((off, origin, base)); } } } @@ -1779,7 +1942,10 @@ fn extension_bases_screen( let mut bases: [Option; 2] = [None, None]; let mut origins: Vec = Vec::new(); for (_, origin, base) in found { - if origins.iter().any(|o| (*o - origin).length_squared() < 1e-12) { + if origins + .iter() + .any(|o| (*o - origin).length_squared() < 1e-12) + { continue; } origins.push(origin); @@ -1799,7 +1965,12 @@ fn extension_bases_screen( /// so the result is precise at UTM-scale absolute coordinates (a full /// view-projection with a ~1e7 translation cancels catastrophically in f32). /// `view_rot` is the rotation-only view-projection (Camera::view_proj_rte). -fn world_to_screen(world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point { +fn world_to_screen( + world: glam::DVec3, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Point { let rel = (world - eye).as_vec3(); let ndc = view_rot.project_point3(rel); Point::new( @@ -1820,9 +1991,9 @@ fn dist2(a: Point, b: Point) -> f32 { /// Tessellated curves (circle / arc / ellipse) are skipped — they carry a /// Center snap hint and "parallel to a curve" is meaningless. Used to acquire /// the Parallel-snap reference. (#277) -fn nearest_segment( +fn nearest_segment( cursor_world: Vec3, - wires: &[WireModel], + wires: &W, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, @@ -1831,7 +2002,37 @@ fn nearest_segment( let cs = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds); let mut best_d2 = aperture_px * aperture_px; let mut best: Option<(Vec3, Vec3)> = None; - for wire in wires { + if let Some(segments) = indexed_segments(wires) { + for segment in segments { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + if wire + .snap_pts + .iter() + .any(|(_, h)| matches!(h, SnapHint::Center)) + { + continue; + } + let sa = world_to_screen(segment.a, view_rot, eye, bounds); + let sb = world_to_screen(segment.b, view_rot, eye, bounds); + let d2 = dist2_to_segment(cs, sa, sb); + if d2 < best_d2 { + let dx = segment.b.x - segment.a.x; + let dy = segment.b.y - segment.a.y; + let l = (dx * dx + dy * dy).sqrt(); + if l > 1e-9 { + best_d2 = d2; + let dir = Vec3::new((dx / l) as f32, (dy / l) as f32, 0.0); + let np = + nearest_on_segment(cursor_world.as_dvec3(), segment.a, segment.b).as_vec3(); + best = Some((dir, np)); + } + } + } + return best; + } + for wire in wires.iter() { if wire .snap_pts .iter() @@ -1938,17 +2139,6 @@ mod ext_tests { assert!(s.tracking_active()); } - #[test] - fn extension_only_tracks_acquired_endpoints() { - let mut s = Snapper::default(); - // Nothing acquired → no endpoint is a live extension source (#262). - assert!(!s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0))); - // Acquire an endpoint → only that vertex tracks. - s.tracking_points.push(DVec3::new(10.0, 0.0, 0.0)); - assert!(s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0))); - assert!(!s.is_tracked_endpoint(glam::DVec3::new(5.0, 0.0, 0.0))); - } - #[test] fn extension_acquisition_keeps_only_endpoints() { let mut s = Snapper::default(); @@ -2025,7 +2215,10 @@ mod ext_tests { "aligned point off the base→corner line: {:?}", hit.aligned ); - assert!((hit.base - base).length() < 1e-12, "ray base is not the command base"); + assert!( + (hit.base - base).length() < 1e-12, + "ray base is not the command base" + ); // Without a base point the alignment does not exist (nothing to join). let none = s.otrack_snap( @@ -2101,6 +2294,10 @@ mod ext_tests { DVec3::new(0.0, 1.0, 5.0), ) .expect("coplanar crossing at z=5"); - assert!((hi.z - 5.0).abs() < 1e-9, "z should be the true height, got {}", hi.z); + assert!( + (hi.z - 5.0).abs() < 1e-9, + "z should be the true height, got {}", + hi.z + ); } }