Keep grip edits on the drawing plane and preserve constraint undo

This commit is contained in:
Hakan Seven committed 2026-09-13 22:33:17 +03:00
1 parent 64e64c80a1
commit 78ab1c8013
4 files changed
+124 -30

No files matched your search

Generated
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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"] }
+57 -4
View File
@@ -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() {
+63 -22
View File
@@ -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.
///