diff --git a/src/app/update.rs b/src/app/update.rs index a5fc607a7..c96b53b21 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2617,10 +2617,10 @@ impl OpenCADStudio { height: vh, }; let cam = self.tabs[i].scene.camera.borrow(); - let raw_paper = cam.pick_on_target_plane_f64(p, bounds); + 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_f64(raw_paper); + let raw = self.tabs[i].scene.paper_to_model(raw_paper); // First move of this drag: hide the edited entity from the // base tessellation (one re-tess) so subsequent moves only @@ -2714,11 +2714,7 @@ impl OpenCADStudio { height: vp_size.1, }; let paper = if let Some(ref ucs) = self.tabs[i].active_ucs { - let origin = glam::Vec3::new( - ucs.origin.x as f32, - ucs.origin.y as f32, - ucs.origin.z as f32, - ); + let origin = glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z); let normal = ucs_z_axis(ucs); self.tabs[i] .scene @@ -2733,7 +2729,7 @@ impl OpenCADStudio { .pick_on_target_plane(p, bounds) }; let world = self.tabs[i].scene.paper_to_model(paper); - self.tabs[i].last_cursor_world = world; + self.tabs[i].last_cursor_world = world.as_vec3(); } // Rollover highlight: when idle (no active command, no @@ -2772,13 +2768,13 @@ impl OpenCADStudio { .scene .camera .borrow() - .pick_on_plane_f64(p, bounds, normal, origin) + .pick_on_plane(p, bounds, normal, origin) } else { self.tabs[i] .scene .camera .borrow() - .pick_on_target_plane_f64(p, bounds) + .pick_on_target_plane(p, bounds) }; let (view_rot, eye) = { let cam = self.tabs[i].scene.camera.borrow(); @@ -2789,7 +2785,17 @@ impl OpenCADStudio { 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_f64(cursor_paper); + 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 + }; let all_wires = self.tabs[i].scene.hit_test_wires(); let needs_entity = self.tabs[i] @@ -2816,7 +2822,7 @@ impl OpenCADStudio { None } else if needs_tan { self.snapper.snap_tangent_only( - cursor_world.as_vec3(), + snap_cursor.as_vec3(), p, &all_wires[..], view_rot, @@ -2827,7 +2833,7 @@ impl OpenCADStudio { let (go, gr) = self.tabs[i].ucs_grid_basis(); self.snapper.from_point = self.last_point; self.snapper - .snap(cursor_world, p, &all_wires[..], view_rot, eye, bounds, go, gr) + .snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr) }; // Object Snap Tracking: update dwell, then align the cursor @@ -2874,7 +2880,7 @@ impl OpenCADStudio { // wires; convert to model-space for previews. let mut pt = self.tabs[i] .snap_result - .map(|s| self.tabs[i].scene.paper_to_model_f64(s.world)) + .map(|s| self.tabs[i].scene.paper_to_model(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 @@ -2918,16 +2924,34 @@ 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. - let project = |bp: glam::Vec3| { + 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, ) }; + // 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(); let anchor = self.tabs[i].dyn_anchor.or(self.last_point); - self.tabs[i].last_point_screen = anchor.map(project); - self.tabs[i].dyn_ref_screen = self.tabs[i].dyn_ref.map(project); + 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))); + self.tabs[i].last_point_screen = lps; + self.tabs[i].dyn_ref_screen = drs; // Entity-pick previews (TRIM/EXTEND/FILLET…) compare the // cursor against WCS document entities and return WCS wires. @@ -3434,16 +3458,24 @@ impl OpenCADStudio { .scene .camera .borrow() - .pick_on_plane_f64(p, bounds, normal, origin) + .pick_on_plane(p, bounds, normal, origin) } else { self.tabs[i] .scene .camera .borrow() - .pick_on_target_plane_f64(p, bounds) + .pick_on_target_plane(p, bounds) }; // Convert paper-space → model-space when inside a viewport. - let raw = self.tabs[i].scene.paper_to_model_f64(raw_paper); + 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 + } else { + raw + }; 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] @@ -3460,16 +3492,16 @@ impl OpenCADStudio { None } else if needs_tan { self.snapper - .snap_tangent_only(raw.as_vec3(), p, &all_wires[..], view_rot, eye, bounds) + .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; - self.snapper.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr) + 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_f64(s.world)) + .map(|s| self.tabs[i].scene.paper_to_model(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. @@ -4178,7 +4210,7 @@ impl OpenCADStudio { .pick_on_target_plane(p, bounds); self.tabs[i] .scene - .viewport_at_paper_point(paper_pt.x, paper_pt.y) + .viewport_at_paper_point(paper_pt.x as f32, paper_pt.y as f32) }); if let Some(handle) = hit_vp { @@ -4284,7 +4316,7 @@ impl OpenCADStudio { .camera .borrow() .pick_on_target_plane(cp, bounds); - glam::Vec2::new(pt.x, pt.y) + 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 @@ -6151,6 +6183,11 @@ impl OpenCADStudio { // Clear paper-space selection before entering model space. self.tabs[i].scene.deselect_all(); self.tabs[i].scene.active_viewport = Some(handle); + // Fold a stale UTM saved view onto the effective (auto-fit) + // centre so pan/zoom, paper↔model and the display all agree — + // otherwise the camera auto-fits to the model while the cursor + // math stays at the origin, jittering as pan toggles the two. + self.tabs[i].scene.normalize_active_viewport_view(); // Grid/snap follow the entered viewport. self.adopt_view_display(i); self.refresh_properties(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index abbd4abf8..56b2d25a2 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3483,49 +3483,49 @@ impl Scene { let scale_d = scale as f64; let pcx_d = pcx as f64; let pcy_d = pcy as f64; - let [wo_x, wo_y, wo_z] = [0.0_f64; 3]; + // Project one ABSOLUTE-WCS model point (f64) onto the paper sheet. + // Shared by the polyline points, snap points and key vertices so the + // hit-test / snap geometry lands in the same paper frame the wire is + // drawn in — otherwise snaps and the click-AABB stay in model (UTM) + // space and the cursor never reaches them. + let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] { + let mp_x = ax - display_center_x; + let mp_y = ay - display_center_y; + let mp_z = az - display_center_z; + let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2; + let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2; + if use_perspective { + let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2; + let fwd = camera_dist_d - d_vd; + if fwd <= 0.001 { + return [f32::NAN; 3]; + } + let factor = camera_dist_d / fwd; + [ + (pcx_d + u * factor * scale_d) as f32, + (pcy_d + v * factor * scale_d) as f32, + pcz, + ] + } else { + [(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz] + } + }; + let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1; for wire in model_wires.iter() { let projected_pts: Vec<[f32; 3]> = wire .points .iter() - .map(|&[mx, my, mz]| { + .enumerate() + .map(|(pi, &[mx, my, mz])| { if mx.is_nan() || my.is_nan() || mz.is_nan() { return [f32::NAN; 3]; } - // wire stored offset-rel; reconstruct WCS in f64 - // then subtract display center in WCS → small - // f64 mp_proj with full precision. - let mp_x = (mx as f64 + wo_x) - display_center_x; - let mp_y = (my as f64 + wo_y) - display_center_y; - let mp_z = (mz as f64 + wo_z) - display_center_z; - let u = mp_x * view_right_d.0 - + mp_y * view_right_d.1 - + mp_z * view_right_d.2; - let v = mp_x * view_up_d.0 - + mp_y * view_up_d.1 - + mp_z * view_up_d.2; - if use_perspective { - let d_vd = mp_x * view_fwd_d.0 - + mp_y * view_fwd_d.1 - + mp_z * view_fwd_d.2; - let fwd = camera_dist_d - d_vd; - if fwd <= 0.001 { - return [f32::NAN; 3]; - } - let factor = camera_dist_d / fwd; - [ - (pcx_d + u * factor * scale_d) as f32, - (pcy_d + v * factor * scale_d) as f32, - pcz, - ] - } else { - [ - (pcx_d + u * scale_d) as f32, - (pcy_d + v * scale_d) as f32, - pcz, - ] - } + // Reconstruct absolute WCS from the double-single high + // (`points`) + low (`points_low`) pair — the high f32 + // alone is ~0.5 m off at UTM scale. + let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]); + proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64) }) .collect(); @@ -3562,10 +3562,63 @@ impl Scene { continue; } + // Paper-space AABB of the clipped polyline — the cloned model + // (UTM) AABB would make click_hit's screen-projected pre-reject + // discard the wire (box selection has no pre-reject, which is why + // it kept working while picking didn't). + let mut pmnx = f32::INFINITY; + let mut pmny = f32::INFINITY; + let mut pmxx = f32::NEG_INFINITY; + let mut pmxy = f32::NEG_INFINITY; + for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) { + pmnx = pmnx.min(x); + pmny = pmny.min(y); + pmxx = pmxx.max(x); + pmxy = pmxy.max(y); + } + + // Project snap points + key vertices into the same paper frame, + // keeping only those inside the viewport rect, so endpoint / + // midpoint / centre snaps land on the visible sheet geometry + // instead of the model's UTM coordinates. + let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire + .snap_pts + .iter() + .filter_map(|(w, h)| { + let p = proj_abs(w.x, w.y, w.z); + (p[0].is_finite() && in_vp(p[0], p[1])) + .then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h)) + }) + .collect(); + let key_vertices: Vec<[f64; 3]> = wire + .key_vertices + .iter() + .filter_map(|&[kx, ky, kz]| { + let p = proj_abs(kx, ky, kz); + (p[0].is_finite() && in_vp(p[0], p[1])) + .then(|| [p[0] as f64, p[1] as f64, p[2] as f64]) + }) + .collect(); + let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color); let [r, g, b, a] = adapted; let mut out = wire.clone(); out.points = clipped; + // Paper coordinates are small sheet units — no relative-to-eye + // residual is needed, and keeping the model wire's points_low + // here would add a model-scale offset to the paper points. + out.points_low = Vec::new(); + out.snap_pts = snap_pts; + out.key_vertices = key_vertices; + // Tangent geometry is in model space and can't be trivially + // re-expressed in paper coords — drop it (no tangent snap on + // projected viewport content) rather than snap to UTM. + out.tangent_geoms = Vec::new(); + out.aabb = if pmnx.is_finite() { + [pmnx, pmny, pmxx, pmxy] + } else { + WireModel::UNBOUNDED_AABB + }; out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85]; out.line_weight_px = wire.line_weight_px; // Wire's pattern was sized for model-space coords during @@ -3593,12 +3646,8 @@ impl Scene { /// Convert a **paper-space** world coordinate to **model-space** using the /// geometry of the currently active viewport. Returns the input unchanged /// when there is no active viewport. - pub fn paper_to_model(&self, paper_pt: glam::Vec3) -> glam::Vec3 { - self.paper_to_model_f64(paper_pt.as_dvec3()).as_vec3() - } - - /// f64 variant of [`paper_to_model`] — preserves UTM-scale precision. - pub fn paper_to_model_f64(&self, paper_pt: glam::DVec3) -> glam::DVec3 { + /// Convert a paper-space point to model space (precise at UTM scale). + pub fn paper_to_model(&self, paper_pt: glam::DVec3) -> glam::DVec3 { let vp_handle = match self.active_viewport { Some(h) => h, None => return paper_pt, @@ -3607,6 +3656,10 @@ impl Scene { Some(acadrust::EntityType::Viewport(vp)) => vp, _ => return paper_pt, }; + // Uses the viewport's own `view_target` — kept valid by + // `normalize_active_viewport_view` on entry, which folds a stale UTM + // saved view onto the auto-fit centre so the display, pan/zoom and this + // inverse all agree. Cheap (no per-call camera rebuild). let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); if scale.abs() < 1e-9 { return paper_pt; @@ -3622,6 +3675,54 @@ 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. + pub fn model_to_paper(&self, model_pt: glam::DVec3) -> glam::DVec3 { + let vp_handle = match self.active_viewport { + Some(h) => h, + None => return model_pt, + }; + let vp = match self.document.get_entity(vp_handle) { + Some(acadrust::EntityType::Viewport(vp)) => vp, + _ => return model_pt, + }; + let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); + glam::DVec3::new( + (model_pt.x - vp.view_target.x) * scale + vp.center.x, + (model_pt.y - vp.view_target.y) * scale + vp.center.y, + model_pt.z, + ) + } + + /// 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 + /// rendered content all share one valid view — otherwise a stale `(0,0,0)` + /// target left the camera auto-fitting to the model centre while the cursor + /// math used the origin, and pan toggled the two (jitter). + pub fn normalize_active_viewport_view(&mut self) { + let Some(vp_handle) = self.active_viewport else { + return; + }; + let Some(cam) = self.camera_for_viewport(vp_handle) else { + return; + }; + let eff_h = cam.ortho_size() as f64 * 2.0; + if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { + vp.view_target.x = cam.target.x; + vp.view_target.y = cam.target.y; + vp.view_center.x = 0.0; + vp.view_center.y = 0.0; + if eff_h > 1e-9 { + vp.view_height = eff_h; + } + } + } + /// Pan the active viewport's model-space view by `(screen_dx, screen_dy)` pixels. /// The delta is converted to model-space units using the camera and viewport scale. /// No-op when there is no active viewport. diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 9d5d9c048..dedb73bac 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -232,28 +232,10 @@ impl Camera { /// Project a screen point onto an arbitrary world-space plane. /// /// The plane is defined by `plane_normal` (unit vector) and a `plane_point` - /// that lies on it. Returns the intersection of the view ray with the plane; - /// falls back to `plane_point` when the ray is nearly parallel to the plane. + /// that lies on it. Returns the ray–plane intersection (falling back to + /// `plane_point` when nearly parallel), eye-relative in f64 so the cursor + /// stays precise at UTM-scale coordinates. pub fn pick_on_plane( - &self, - screen: Point, - bounds: Rectangle, - plane_normal: Vec3, - plane_point: Vec3, - ) -> Vec3 { - // Delegate to the eye-relative f64 unproject so the cursor stays - // precise at UTM-scale coordinates (the old full view_proj.inverse() - // cancelled catastrophically in f32). - self.unproject_on_plane_f64(screen, bounds, plane_normal, plane_point.as_dvec3()) - .as_vec3() - } - - pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 { - self.pick_on_target_plane_f64(screen, bounds).as_vec3() - } - - /// f64 variant of [`pick_on_plane`] — keeps the cursor precise at UTM-scale. - pub fn pick_on_plane_f64( &self, screen: Point, bounds: Rectangle, @@ -263,8 +245,8 @@ impl Camera { self.unproject_on_plane_f64(screen, bounds, plane_normal, plane_point) } - /// f64 variant of [`pick_on_target_plane`]. - pub fn pick_on_target_plane_f64(&self, screen: Point, bounds: Rectangle) -> glam::DVec3 { + /// Project a screen point onto the plane through the orbit target. + pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> glam::DVec3 { let forward = (self.target.as_vec3() - self.eye()).normalize_or(Vec3::NEG_Z); self.unproject_on_plane_f64(screen, bounds, forward, self.target) }