diff --git a/src/app/update.rs b/src/app/update.rs index c96b53b21..e4ebef400 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -4,7 +4,7 @@ use super::helpers::{ }; use super::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, GripEdit}; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, @@ -360,21 +360,21 @@ impl OpenCADStudio { /// model tile so a save writes them to that viewport's VPort entry (#121). fn sync_vport_display(&mut self, i: usize) { let grid_on = self.show_grid; - let snap_on = self.snapper.is_on(crate::snap::SnapType::Grid); + let snap_on = self.snapper.grid_snap(); self.tabs[i].scene.set_active_tile_grid_snap(grid_on, snap_on); } - /// Adopt the active viewport's grid display + grid-snap into the live - /// toggles. Called on load and whenever the active tab or viewport changes, - /// so grid/snap follow the active viewport rather than a global setting. + /// Adopt the active viewport's grid *display* into the live toggle. Called + /// on load and whenever the active tab or viewport changes, so the grid + /// drawing follows the active viewport. + /// + /// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays + /// off by default everywhere and is controlled solely by the user's snap + /// toggle, so it never silently leaks into object-snap when entering a + /// viewport whose stored `snap_on` flag happens to be set. fn adopt_view_display(&mut self, i: usize) { - if let Some((grid_on, snap_on)) = self.tabs[i].scene.active_tile_grid_snap() { + if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() { self.show_grid = grid_on; - if snap_on { - self.snapper.enabled.insert(crate::snap::SnapType::Grid); - } else { - self.snapper.enabled.remove(&crate::snap::SnapType::Grid); - } } } @@ -2598,9 +2598,20 @@ impl OpenCADStudio { // the active pane. `p_full` keeps the canvas-space cursor // for screen overlays (cursor marker, snap glyph). let p_full = p; - let tile_b = self.tabs[i] - .scene - .active_model_tile_bounds(vp_size.0, vp_size.1); + // 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, @@ -2616,11 +2627,28 @@ impl OpenCADStudio { width: vw, height: vh, }; - let cam = self.tabs[i].scene.camera.borrow(); - let raw_paper = cam.pick_on_target_plane(p, bounds); - let view_rot = cam.view_proj_rte(bounds); let eye = cam.eye_f64(); - drop(cam); - let raw = self.tabs[i].scene.paper_to_model(raw_paper); + // 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_f64()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye_f64()) + } + }; // First move of this drag: hide the edited entity from the // base tessellation (one re-tess) so subsequent moves only @@ -2643,12 +2671,21 @@ impl OpenCADStudio { // 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 = self.tabs[i].scene.hit_test_wires(); + 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() + }; // 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(); - let snap_hit = - self.snapper.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr); + // `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() { @@ -2713,22 +2750,7 @@ impl OpenCADStudio { width: vp_size.0, height: vp_size.1, }; - let paper = if let Some(ref ucs) = self.tabs[i].active_ucs { - let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z); - let normal = ucs_z_axis(ucs); - self.tabs[i] - .scene - .camera - .borrow() - .pick_on_plane(p, bounds, normal, origin) - } else { - self.tabs[i] - .scene - .camera - .borrow() - .pick_on_target_plane(p, bounds) - }; - let world = self.tabs[i].scene.paper_to_model(paper); + let world = self.cursor_model_point(i, &edit_cam, p, bounds); self.tabs[i].last_cursor_world = world.as_vec3(); } @@ -2761,43 +2783,31 @@ impl OpenCADStudio { width: vw, height: vh, }; - let cursor_paper = if let Some(ref ucs) = self.tabs[i].active_ucs { - let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z); - let normal = ucs_z_axis(ucs); - self.tabs[i] - .scene - .camera - .borrow() - .pick_on_plane(p, bounds, normal, origin) - } else { - self.tabs[i] - .scene - .camera - .borrow() - .pick_on_target_plane(p, bounds) - }; - let (view_rot, eye) = { - let cam = self.tabs[i].scene.camera.borrow(); - (cam.view_proj_rte(bounds), cam.eye_f64()) + // 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_f64()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye_f64()) + } }; // 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); - // In MSPACE, map paper-space cursor to model space so that - // command previews and snapping work in the correct coordinate space. - let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper); - // Inside a viewport the hit-test wires are projected into - // PAPER coordinates, so snapping must compare against the - // paper cursor — feeding the model-space (UTM) cursor would - // make `wire_in_range` reject every wire and snap nothing. - // The snapped paper point is mapped back to model below. - let snap_cursor = if self.tabs[i].scene.active_viewport.is_some() { - cursor_paper - } else { - cursor_world - }; + // Cursor and wires are model-space; the snap result is model. + let snap_cursor = cursor_world; - let all_wires = self.tabs[i].scene.hit_test_wires(); + 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 needs_entity = self.tabs[i] .active_cmd .as_ref() @@ -2876,11 +2886,11 @@ impl OpenCADStudio { // ortho/polar don't re-constrain it. h.aligned.as_dvec3() } else { - // snap.world is paper-space for viewport-projected - // wires; convert to model-space for previews. + // 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| self.tabs[i].scene.paper_to_model(s.world)) + .map(|s| s.world) .unwrap_or(cursor_world); // Object snap wins over ortho/polar: a snapped point // is taken as-is so it isn't pulled onto the ortho @@ -2924,32 +2934,25 @@ impl OpenCADStudio { // 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::Vec3| { let ndc = view_rot.project_point3((bp.as_dvec3() - eye).as_vec3()); iced::Point::new( - (ndc.x + 1.0) * 0.5 * bounds.width, - (1.0 - ndc.y) * 0.5 * bounds.height, + (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; inside a viewport they - // must be mapped onto the paper sheet before projecting with - // the paper camera — otherwise a UTM model point lands - // millions of pixels away and the dynamic-input guide arc - // (radius = anchor→cursor distance) tessellates into a buffer - // big enough to abort the GPU. Compute into locals first to - // release the scene borrow before assigning. - let in_vp = self.tabs[i].scene.active_viewport.is_some(); + // 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 map_paper = |bp: glam::Vec3| -> glam::Vec3 { - if in_vp { - self.tabs[i].scene.model_to_paper(bp.as_dvec3()).as_vec3() - } else { - bp - } - }; - let lps = anchor.map(|a| proj(map_paper(a))); - let drs = dyn_ref.map(|r| proj(map_paper(r))); + 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; @@ -3276,7 +3279,14 @@ impl OpenCADStudio { // 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; - let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh); + // 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, @@ -3295,7 +3305,19 @@ impl OpenCADStudio { { if let Some(handle) = self.tabs[i].selected_handle { let is_paper = self.tabs[i].scene.current_layout != "Model"; - let grip_hit = if is_paper { + // 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_f64(), + 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(); @@ -3420,11 +3442,22 @@ impl OpenCADStudio { // camera + bounds. `p_full` keeps the canvas point for the // box/poly selection rectangle (drawn in canvas space). let p_full = p; - let (tile_vw, tile_vh, tile_off) = { - let (svw, svh) = self.tabs[i].scene.selection.borrow().vp_size; - let tb = self.tabs[i].scene.active_model_tile_bounds(svw, svh); - (tb.width, tb.height, iced::Point::new(tb.x, tb.y)) + // 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, @@ -3449,35 +3482,24 @@ impl OpenCADStudio { let tangent_obj_at_click = snap_taken.and_then(|s| s.tangent_obj); let world_pt = { - // Project screen point onto the active UCS XY plane (or world XY when - // no UCS is active). - let raw_paper = if let Some(ref ucs) = self.tabs[i].active_ucs { - let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z); - let normal = ucs_z_axis(ucs); - self.tabs[i] - .scene - .camera - .borrow() - .pick_on_plane(p, bounds, normal, origin) - } else { - self.tabs[i] - .scene - .camera - .borrow() - .pick_on_target_plane(p, bounds) + // 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_f64()), + None => { + let c = self.tabs[i].scene.camera.borrow(); + (c.view_proj_rte(bounds), c.eye_f64()) + } }; - // Convert paper-space → model-space when inside a viewport. - let raw = self.tabs[i].scene.paper_to_model(raw_paper); - // Inside a viewport snapping runs in paper coords (the - // hit-test wires are paper-projected); the snapped paper - // point is mapped back to model below. - let snap_cursor = if self.tabs[i].scene.active_viewport.is_some() { - raw_paper + 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 { - raw + self.tabs[i].scene.hit_test_wires() }; - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; - let all_wires = self.tabs[i].scene.hit_test_wires(); let needs_tan = self.tabs[i] .active_cmd .as_ref() @@ -3498,11 +3520,8 @@ impl OpenCADStudio { self.snapper.from_point = self.last_point; self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr) }; - // snap.world is in paper-space (projected wire coords in MSPACE); - // convert to model-space so commands receive consistent coordinates. - let mut pt = snap_hit - .map(|s| self.tabs[i].scene.paper_to_model(s.world)) - .unwrap_or(raw); + // 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() { @@ -3607,8 +3626,7 @@ impl OpenCADStudio { .map(|c| c.needs_entity_pick()) .unwrap_or(false) { - let (view_rot2, eye2) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; - let all_wires2 = self.tabs[i].scene.hit_test_wires(); + let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds); let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { @@ -3796,8 +3814,7 @@ impl OpenCADStudio { if elapsed_ms < POLY_START_DELAY_MS { if let Some(a) = box_anchor { let crossing = box_crossing; - let all_wires = self.tabs[i].scene.hit_test_wires(); - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; + 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, @@ -3857,8 +3874,7 @@ impl OpenCADStudio { (pts, sel.poly_crossing) }; self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing; - let all_wires = self.tabs[i].scene.hit_test_wires(); - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; + 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, @@ -3911,8 +3927,8 @@ impl OpenCADStudio { sel.box_current = None; } else { if box_anchor.is_none() { - let all_wires = self.tabs[i].scene.hit_test_wires(); - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; + 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 @@ -4004,8 +4020,7 @@ impl OpenCADStudio { } else { let a = box_anchor.unwrap(); let crossing = box_crossing; - let all_wires = self.tabs[i].scene.hit_test_wires(); - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; + 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, @@ -4463,8 +4478,16 @@ impl OpenCADStudio { height: dwell.tile_size.1, }; let p = dwell.point; - let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; - let all_wires = self.tabs[i].scene.hit_test_wires(); + // Inside a viewport, hover-pick through the viewport camera + + // model wires so the rollover highlights the entity under the + // cursor (dwell.point / dwell.tile_size are already pane-local). + let canvas_sz = self.tabs[i].scene.selection.borrow().vp_size; + let edit_cam = self + .tabs[i] + .scene + .viewport_edit_frame(canvas_sz) + .map(|(cam, _)| cam); + let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); // Mirror the click-selection pick order so the rollover // highlights every selectable object: wire → hatch → // block-internal hatch → shaded 3D solid body. @@ -4617,7 +4640,7 @@ impl OpenCADStudio { Task::none() } Message::ToggleGridSnap => { - self.snapper.toggle(crate::snap::SnapType::Grid); + self.snapper.toggle_grid_snap(); self.sync_vport_display(self.active_tab); Task::none() } @@ -8287,6 +8310,70 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); } + /// Project a pane-local cursor onto the active drawing plane and return a + /// **model-space** point. Inside a floating viewport `edit_cam` is the + /// viewport's own camera (a target-plane / UCS pick there already yields + /// model coords); otherwise the paper camera projects onto the sheet and + /// the result is mapped paper→model. Used by the readout, snap and click + /// paths so all three agree on the cursor's model location. + fn cursor_model_point( + &self, + i: usize, + edit_cam: &Option, + p: iced::Point, + bounds: iced::Rectangle, + ) -> glam::DVec3 { + let plane = self.tabs[i].active_ucs.as_ref().map(|ucs| { + ( + ucs_z_axis(ucs), + glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z), + ) + }); + let pick = |cam: &crate::scene::view::camera::Camera| match plane { + Some((normal, origin)) => cam.pick_on_plane(p, bounds, normal, origin), + None => cam.pick_on_target_plane(p, bounds), + }; + match edit_cam { + Some(cam) => pick(cam), + None => { + let paper = { + let c = self.tabs[i].scene.camera.borrow(); + pick(&c) + }; + self.tabs[i].scene.paper_to_model(paper) + } + } + } + + /// Projection + hit-test wires for the active pane. Inside a floating + /// viewport (`edit_cam` Some) it returns the viewport camera and the live + /// **model** wires, so wire / hatch picking lands on the entity under the + /// cursor exactly where the GPU draws it; otherwise the model/paper camera + /// and the normal hit-test wires. `bounds` is the pane-local rectangle. + fn pick_view( + &self, + i: usize, + edit_cam: &Option, + bounds: iced::Rectangle, + ) -> (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), + None => self.tabs[i].scene.hit_test_wires(), + }; + (cam.view_proj_rte(bounds), cam.eye_f64(), wires) + } + None => { + let (view_rot, eye) = { + let c = self.tabs[i].scene.camera.borrow(); + (c.view_proj_rte(bounds), c.eye_f64()) + }; + (view_rot, eye, self.tabs[i].scene.hit_test_wires()) + } + } + } + fn update_grip_hover(&mut self, i: usize, p: iced::Point) { const HOVER_OPEN_MS: u128 = 600; const POPUP_DISMISS_PX: f32 = 80.0; @@ -8311,7 +8398,25 @@ impl OpenCADStudio { height: vh, }; let is_paper = self.tabs[i].scene.current_layout != "Model"; - let hit = if is_paper { + // In-viewport grips are model-space — project with the viewport camera + // at the viewport's own rect; the cursor is mapped into that rect. + let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); + let hit = if let Some((cam, full)) = &edit_frame { + let local = iced::Rectangle { + x: 0.0, + y: 0.0, + width: full.width, + height: full.height, + }; + let p_local = iced::Point::new(p.x - full.x, p.y - full.y); + find_hit_grip_rte( + p_local, + &self.tabs[i].selected_grips, + cam.view_proj_rte(local), + cam.eye_f64(), + local, + ) + } else if is_paper { let cam = self.tabs[i].scene.camera.borrow(); let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; let half_h = cam.ortho_size(); diff --git a/src/app/view.rs b/src/app/view.rs index 32eab6a7e..f98540c3b 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -3,7 +3,7 @@ use super::document::{DynComponent, DynFieldEntry}; use super::helpers::grid_plane_from_camera; use super::history::history_dropdown_labels; use super::{Message, OpenCADStudio}; -use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper}; +use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; use crate::ui::overlay; @@ -86,9 +86,26 @@ impl OpenCADStudio { // they must use the active tile's screen rectangle (with // its canvas offset) — not the whole canvas — or they // land in the wrong place in a tiled layout. - let bounds = tab.scene.active_model_tile_bounds(vw, vh); + // Inside a floating viewport the pane is the viewport's own + // rect + camera; otherwise the active model tile. + let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => tab.scene.active_model_tile_bounds(vw, vh), + }; let sel_h = tab.selected_handle; - let screen_grips = if is_paper { + // In-viewport grips are model-space; project them with the + // viewport camera so they sit on the wire the GPU draws. + // Paper entities use the 2-D paper transform; the model tab + // uses the model camera. + let screen_grips = if let Some((cam, _)) = &edit_frame { + grips_to_screen_rte( + &tab.selected_grips, + cam.view_proj_rte(bounds), + cam.eye_f64(), + bounds, + ) + } else if is_paper { let cam = tab.scene.camera.borrow(); let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; let half_h = cam.ortho_size(); @@ -388,7 +405,7 @@ impl OpenCADStudio { let bar = viewport_controls( tab.render_mode, self.show_grid, - self.snapper.is_on(crate::snap::SnapType::Grid), + self.snapper.grid_snap(), true, ); // Position the bar at the active model tile's top-left corner so @@ -464,7 +481,7 @@ impl OpenCADStudio { iced::widget::opaque(viewport_controls( vp_mode, self.show_grid, - self.snapper.is_on(crate::snap::SnapType::Grid), + self.snapper.grid_snap(), false, )), ], diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 56b2d25a2..914e405a1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3677,10 +3677,10 @@ impl Scene { /// Inverse of [`paper_to_model`]: map a model-space point to the paper /// sheet through the active viewport. Returns the input unchanged when - /// there is no active viewport. Used to place overlays (dynamic-input - /// anchors, guides) that are stored in model coords while editing inside a - /// viewport — projecting the raw UTM model point with the paper camera puts - /// them millions of pixels away. + /// there is no active viewport. Kept as the inverse companion to + /// `paper_to_model`; in-viewport overlays now project via the viewport + /// camera ([`viewport_edit_frame`]) rather than mapping onto the sheet. + #[allow(dead_code)] pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 { let vp_handle = match self.active_viewport { Some(h) => h, @@ -3698,6 +3698,33 @@ impl Scene { ) } + /// In-viewport (MSPACE) editing frame: the active floating viewport's own + /// camera — *exactly* the one the GPU renders its content with + /// ([`camera_for_viewport`]) — together with the viewport's full screen + /// rectangle in canvas pixels ([`viewport_screen_rect`]). + /// + /// This is the unified editing adapter (the "süzgeç"). Inside a viewport, + /// editing IS model-space: treat the returned camera as *the* camera, the + /// returned rect as *the* pane, and the cursor relative to that rect — then + /// the existing model-space snap / hit-test / grip / preview / plane-pick + /// code runs unchanged and lands on the same pixels the GPU draws. Results + /// come back as model coordinates directly (no paper round-trip). + /// + /// Because the camera is the real GPU camera, this tracks the viewport's + /// pan / zoom / twist / oblique view correctly — unlike a linear + /// paper-projection, whose auto-fit / saved-view / crop divergence left the + /// snap stale after pan/zoom. Returns `None` when not editing inside a + /// floating viewport, or the camera / rect cannot be derived. + pub fn viewport_edit_frame( + &self, + canvas_px: (f32, f32), + ) -> Option<(view::camera::Camera, iced::Rectangle)> { + let vp_handle = self.active_viewport?; + let cam = self.camera_for_viewport(vp_handle)?; + let full = self.viewport_screen_rect(vp_handle, canvas_px)?; + Some((cam, full)) + } + /// Fold the active viewport's saved view onto the effective camera (the /// auto-fit centre for stale UTM views) and persist it into `view_target` / /// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index acb1e10cc..75561eaa4 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -1,7 +1,7 @@ //! OpenCADStudio-style grip editing. use acadrust::Handle; -use glam::DVec3; +use glam::{DVec3, Mat4, Vec2}; use iced::{Point, Rectangle}; use crate::scene::model::object::{GripDef, GripShape}; @@ -129,3 +129,68 @@ pub fn find_hit_grip( } best } + +/// Project an f64 world point with an explicit relative-to-eye `(view_rot, +/// eye)` pair — the camera-less form of `Camera::project_f64`. Used by the +/// in-viewport editing path, which supplies a *composed* model→screen view +/// (see `Scene::composed_viewport_view`) instead of a real camera. +fn project_rte(world: DVec3, view_rot: Mat4, eye: DVec3, bounds: Rectangle) -> Option { + let rel = (world - eye).as_vec3(); + let clip = view_rot * rel.extend(1.0); + if clip.w.abs() < 1e-9 { + return None; + } + let ndc = clip.truncate() / clip.w; + Some(Vec2::new( + (ndc.x * 0.5 + 0.5) * bounds.width, + (0.5 - ndc.y * 0.5) * bounds.height, + )) +} + +/// Relative-to-eye variant of [`grips_to_screen`] taking a `(view_rot, eye)` +/// pair instead of a `Camera`, so the composed in-viewport view can project +/// model-space grips exactly like the GPU renders the viewport content. +pub fn grips_to_screen_rte( + grips: &[GripDef], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, +) -> Vec<(usize, Point, bool, GripShape, Option<[f32; 2]>)> { + grips + .iter() + .map(|g| { + let screen = match project_rte(g.world, view_rot, eye, bounds) { + Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), + None => Point::new(f32::NAN, f32::NAN), + }; + (g.id, screen, g.is_midpoint, g.shape, g.dir) + }) + .collect() +} + +/// Relative-to-eye variant of [`find_hit_grip`] taking a `(view_rot, eye)` +/// pair instead of a `Camera`, for in-viewport (MSPACE) grip hit-testing. +pub fn find_hit_grip_rte( + cursor: Point, + grips: &[GripDef], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, +) -> Option<(usize, bool, DVec3)> { + let mut best_dist = GRIP_THRESHOLD_PX; + let mut best: Option<(usize, bool, DVec3)> = None; + + for g in grips { + let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else { + continue; + }; + let dx = screen.x - cursor.x; + let dy = screen.y - cursor.y; + let d = (dx * dx + dy * dy).sqrt(); + if d < best_dist { + best_dist = d; + best = Some((g.id, g.is_midpoint, g.world)); + } + } + best +} diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 168ee417a..8db7c63f2 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -50,7 +50,9 @@ pub const ALL_SNAP_MODES: &[(SnapType, &str, &str)] = &[ (SnapType::Nearest, "✧", "Nearest"), (SnapType::ApparentIntersection, "✗", "Apparent Intersection"), (SnapType::Parallel, "∥", "Parallel"), - (SnapType::Grid, "⊞", "Grid"), + // NOTE: Grid is intentionally NOT an object-snap mode. Grid snap is a + // separate system (`Snapper::grid_snap_on`) so object snap never catches a + // grid point; it is toggled on its own and handled directly in `snap()`. ]; // ── Snap result ─────────────────────────────────────────────────────────── @@ -86,6 +88,9 @@ pub struct Snapper { pub snap_enabled: bool, /// Which snap modes are configured (used when `snap_enabled` is true). pub enabled: HashSet, + /// Grid snap on/off — a system fully separate from object snap. When on, + /// `snap()` can pick the nearest grid corner; object snap never does. + pub grid_snap_on: bool, /// World-space grid spacing. pub grid_spacing: f32, /// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and @@ -121,6 +126,7 @@ impl Default for Snapper { Self { snap_enabled: false, enabled, + grid_snap_on: false, grid_spacing: 1.0, osnap_radius_px: CROSSHAIR_ARM * 0.25, otrack_enabled: false, @@ -147,6 +153,19 @@ impl Snapper { self.snap_enabled = !self.snap_enabled; } + /// Grid snap on/off — independent of the object-snap master and mode set. + pub fn grid_snap(&self) -> bool { + self.grid_snap_on + } + + pub fn toggle_grid_snap(&mut self) { + self.grid_snap_on = !self.grid_snap_on; + } + + pub fn set_grid_snap(&mut self, on: bool) { + self.grid_snap_on = on; + } + pub fn toggle(&mut self, t: SnapType) { if !self.enabled.remove(&t) { self.enabled.insert(t); @@ -422,6 +441,7 @@ impl Snapper { s.insert(SnapType::Tangent); s }, + grid_snap_on: false, grid_spacing: self.grid_spacing, osnap_radius_px: self.osnap_radius_px, otrack_enabled: false, @@ -458,9 +478,6 @@ impl Snapper { grid_origin: Vec3, grid_rot: Mat4, ) -> Option { - if !self.snap_enabled { - return None; - } // Object-snap selection is priority-then-distance, NOT nearest-wins. // "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry // and are therefore almost always closer to the cursor than a discrete @@ -473,6 +490,46 @@ impl Snapper { let mut best_rank = u8::MAX; let mut best_d2 = f32::MAX; + // ── Grid snap — a SEPARATE system from object snap ─────────────────── + // Grid snap has its own toggle (`grid_snap_on`) and is independent of + // the object-snap master (`snap_enabled`) and the object-snap mode set. + // Object snaps therefore NEVER catch grid points; only when grid snap is + // on can a grid corner be picked. It is evaluated first and at the + // lowest priority, so any object snap inside the aperture overrides it. + if self.grid_snap_on { + let s = self.grid_spacing as f64; + if s.abs() > 1e-9 { + // Round in the UCS grid frame, then map back to world. + let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3(); + let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3(); + let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3(); + let origin = grid_origin.as_dvec3(); + let rel = cursor_world - origin; + let ux = (rel.dot(ax) / s).round() * s; + let uy = (rel.dot(ay) / s).round() * s; + let uz = (rel.dot(az) / s).round() * s; + let gp = origin + ax * ux + ay * uy + az * uz; + let screen = world_to_screen(gp, view_rot, eye, bounds); + let d2 = dist2(screen, cursor_screen); + if d2 < radius2 { + best = Some(SnapResult { + world: gp, + screen, + snap_type: SnapType::Grid, + tangent_obj: None, + }); + best_rank = snap_priority(SnapType::Grid); + best_d2 = d2; + } + } + } + + // Object snaps are gated by the object-snap master toggle. With it off + // only the grid result (if any) stands. + if !self.snap_enabled { + return best; + } + // World-space snap radius — derived from the view scale so wires whose // entire extent is clearly outside the snap circle can be skipped cheaply // before projecting any of their vertices to screen space. @@ -757,21 +814,6 @@ impl Snapper { } } - // ── Grid ─────────────────────────────────────────────────────────── - if self.is_on(SnapType::Grid) { - let s = self.grid_spacing as f64; - // Round in the UCS grid frame, then map back to world. - let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3(); - let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3(); - let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3(); - let origin = grid_origin.as_dvec3(); - let rel = cursor_world - origin; - let ux = (rel.dot(ax) / s).round() * s; - let uy = (rel.dot(ay) / s).round() * s; - let uz = (rel.dot(az) / s).round() * s; - try_pt(origin + ax * ux + ay * uy + az * uz, SnapType::Grid); - } - // ── Tangent ──────────────────────────────────────────────────────── // Operates directly on tangent_geoms geometry — independent of the // wire.points rendering structure so polyline segments work correctly.