From 78ab1c801365695212fb54d8bb285c3e08018064 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 13 Sep 2026 22:33:17 +0300 Subject: [PATCH] Keep grip edits on the drawing plane and preserve constraint undo --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/update/viewport.rs | 61 +++++++++++++++++++++++++-- src/scene/view/camera.rs | 85 ++++++++++++++++++++++++++++---------- 4 files changed, 124 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10090ba49..6563ff46e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -896,7 +896,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d3830bb5230bd80e9f8abaa78b9f85d15161536a#d3830bb5230bd80e9f8abaa78b9f85d15161536a" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6ceda43233e59224b7683a69917b62ab26ea3ec3#6ceda43233e59224b7683a69917b62ab26ea3ec3" dependencies = [ "acadrust", "cavalier_contours", @@ -907,7 +907,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d3830bb5230bd80e9f8abaa78b9f85d15161536a#d3830bb5230bd80e9f8abaa78b9f85d15161536a" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6ceda43233e59224b7683a69917b62ab26ea3ec3#6ceda43233e59224b7683a69917b62ab26ea3ec3" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index ab49a55da..86f8b894a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,8 +32,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d3830bb5230bd80e9f8abaa78b9f85d15161536a", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d3830bb5230bd80e9f8abaa78b9f85d15161536a" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6ceda43233e59224b7683a69917b62ab26ea3ec3", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6ceda43233e59224b7683a69917b62ab26ea3ec3" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index cfcd763a5..004625a8e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1254,14 +1254,12 @@ impl OpenCADStudio { .filter(|handle| seen_handles.insert(*handle)) .collect(); + // Initialize once: constraint solves may add neighbors to this gesture. // Wire entities use the overlay; solid meshes stay visible and move live. - if self.grip_preview_handles != edited_handles { + if self.grip_preview_handles.is_empty() { if self.grip_dirty_before.is_none() { self.grip_dirty_before = Some(self.tabs[i].dirty); } - for handle in std::mem::take(&mut self.grip_preview_handles) { - self.tabs[i].scene.preview_hidden.remove(&handle); - } // Interactive Add Vertex seeds this with the entity from // before insertion so append + placement is one undo step. // Normal grip drags still snapshot their current entities here. @@ -5964,6 +5962,61 @@ mod selection_preview_tests { use super::*; use crate::app::{HoverDwell, OpenCADStudio, HOVER_DWELL_MS}; + #[test] + fn grip_moves_keep_perpendicular_constraints_live_and_undoable() { + use acadrust::{entities::Line, types::Vector3, EntityType}; + use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let a = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(-10.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), + ))); + let b = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(15.0, -10.0, 0.0), Vector3::new(15.0, 10.0, 0.0), + ))); + let _ = app.apply_cmd_result(crate::command::CmdResult::AddSketchConstraint { + kind: ConstraintKind::Perpendicular, + refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + driving_param: None, + label: "Perpendicular constraint", + }); + let geometry = |app: &OpenCADStudio| [a, b].map(|handle| { + match app.tabs[i].scene.document.get_entity(handle).unwrap() { + EntityType::Line(line) => [line.start, line.end], + _ => panic!("expected line"), + } + }); + let before = geometry(&app); + app.tabs[i].active_grip = Some(GripEdit::single(a, 1, false, glam::DVec3::new(10.0, 0.0, 0.0))); + for cursor in [Point::new(520.0, 220.0), Point::new(490.0, 180.0)] { + let _ = app.on_viewport_move(cursor); + let current = geometry(&app); + for point in current.iter().flatten() { + assert_eq!(point.z, 0.0, "grip left the drawing plane: {point:?}"); + } + let direction = |ends: [Vector3; 2]| glam::DVec3::new( + ends[1].x - ends[0].x, ends[1].y - ends[0].y, ends[1].z - ends[0].z, + ).normalize(); + assert!(direction(current[0]).dot(direction(current[1])).abs() < 1e-8); + assert_ne!(current[1], before[1], "constrained neighbor must follow each grip frame"); + } + let _ = app.on_viewport_left_release(); + assert!(app.tabs[i].active_grip.is_none()); + let after = geometry(&app); + app.undo_steps(1); + assert_eq!(geometry(&app), before); + app.redo_steps(1); + assert_eq!(geometry(&app), after); + } + #[cfg(target_os = "linux")] #[test] fn hyperlink_click_respects_existing_pointer_owners() { diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index e34b550ba..7b87a2a8d 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -317,10 +317,8 @@ impl Camera { )) } - /// Unproject a screen point onto an arbitrary world plane in f64. The ray - /// is built in eye-relative space (precise), intersected with the plane - /// expressed relative to the eye, then shifted back by the f64 eye — so the - /// returned world point keeps full precision at large absolute coordinates. + /// Unproject in f64, with the kernel intersection relative to the plane + /// origin so rendering precision never becomes a geometry elevation. pub fn unproject_on_plane( &self, screen: Point, @@ -329,32 +327,31 @@ impl Camera { plane_point: glam::DVec3, ) -> glam::DVec3 { let eye = self.eye(); - let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; - let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; - let inv = self.view_proj_rte(bounds).inverse(); + let ndc_x = (screen.x as f64 / bounds.width as f64) * 2.0 - 1.0; + let ndc_y = 1.0 - (screen.y as f64 / bounds.height as f64) * 2.0; + let inv = self.view_proj_rte(bounds).as_dmat4().inverse(); // Ray origin / direction in eye-relative space. let (ray_origin, ray_dir) = match self.projection { Projection::Perspective => { - let near_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); - let far_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 1.0)); + let near_pt = inv.project_point3(DVec3::new(ndc_x, ndc_y, 0.0)); + let far_pt = inv.project_point3(DVec3::new(ndc_x, ndc_y, 1.0)); (near_pt, (far_pt - near_pt).normalize()) } Projection::Orthographic => { - let origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); - let forward = self.rotation * Vec3::NEG_Z; + let origin = inv.project_point3(DVec3::new(ndc_x, ndc_y, 0.0)); + let forward = (self.rotation * Vec3::NEG_Z).as_dvec3(); (origin, forward) } }; - // Plane point relative to the eye (small) for a precise intersection. - let plane_rel = (plane_point - eye).as_vec3(); - let denom = ray_dir.dot(plane_normal); - let rel_hit = if denom.abs() < 1e-6 { - plane_rel - } else { - let t = (plane_rel - ray_origin).dot(plane_normal) / denom; - ray_origin + ray_dir * t - }; - eye + rel_hit.as_dvec3() + cadkernel::space::plane::intersect_line_plane( + (ray_origin - (plane_point - eye)).to_array(), + ray_dir.to_array(), + [0.0; 3], + plane_normal.as_dvec3().to_array(), + 1e-6, + ) + .map(|point| plane_point + DVec3::from_array(point)) + .unwrap_or(plane_point) } /// Eye position split into two f32 (high + low) emulating f64, for the @@ -389,7 +386,7 @@ impl Camera { /// 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().as_vec3()).normalize_or(Vec3::NEG_Z); + let forward = self.rotation * Vec3::NEG_Z; self.unproject_on_plane(screen, bounds, forward, self.target) } @@ -904,6 +901,50 @@ mod rte_tests { use super::*; use iced::Rectangle; + #[test] + fn cursor_picking_preserves_the_requested_plane() { + let bounds = Rectangle::new(Point::ORIGIN, iced::Size::new(800.0, 600.0)); + for projection in [Projection::Orthographic, Projection::Perspective] { + for distance in [0.01, 60.36, 71.08106, 100_000.0] { + for origin in [DVec3::ZERO, DVec3::new(639_792.184_2, 4_517_057.531_7, 12.5)] { + let camera = Camera { + target: origin, + distance, + projection, + ..Camera::default() + }; + for cursor in [Point::new(400.0, 300.0), Point::new(530.0, 215.0)] { + let point = camera.pick_on_target_plane(cursor, bounds); + assert_eq!(point.z, origin.z, "{projection:?}, distance={distance}, {point:?}"); + let normal = Vec3::new(0.2, -0.3, 1.0).normalize(); + let point = camera.pick_on_plane(cursor, bounds, normal, origin); + let residual = (point - origin).dot(normal.as_dvec3()); + assert!(residual.abs() < 1e-9, "plane residual={residual}, {projection:?}, distance={distance}"); + } + } + } + } + } + + #[test] + fn target_plane_picking_is_translation_invariant() { + let bounds = Rectangle::new(Point::ORIGIN, iced::Size::new(800.0, 600.0)); + let cursor = Point::new(530.0, 215.0); + for projection in [Projection::Orthographic, Projection::Perspective] { + let camera = Camera { + rotation: Quat::from_rotation_z(0.7) * Quat::from_rotation_x(0.4), + projection, + distance: 0.01, + ..Camera::default() + }; + let offset = DVec3::new(639_792.184_2, 4_517_057.531_7, 12.5); + let translated = Camera { target: offset, ..camera.clone() }; + let here = camera.pick_on_target_plane(cursor, bounds); + let there = translated.pick_on_target_plane(cursor, bounds) - offset; + assert!(here.distance(there) < 1e-9, "{projection:?}: {here:?} vs {there:?}"); + } + } + /// The relative-to-eye view carries rotation and nothing else, so where the /// camera stands must not reach it at all. ///