From 0fef87950926e762c8aeec680293dbf94af1f76a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:48 +0200 Subject: [PATCH] Grip edits act on the whole selection (fixes #320) (#322) Stretching a grip now drags the grips of the other selected objects that sit on the same point, so connected lines stay connected, and the Move, Rotate, Scale and Mirror grip modes transform every selected object about the hot grip, as in AutoCAD. grip.move/grip.stretch take the other objects in `handles`; with only `handle` they edit that object alone, as before. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/gripcmds.rs | 30 +++++++++++--- crates/engine/src/grips.rs | 21 +++++----- crates/engine/tests/grip_selection.rs | 60 +++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 crates/engine/tests/grip_selection.rs diff --git a/crates/engine/src/cmd/gripcmds.rs b/crates/engine/src/cmd/gripcmds.rs index 74b91ee6f..7065a8c69 100644 --- a/crates/engine/src/cmd/gripcmds.rs +++ b/crates/engine/src/cmd/gripcmds.rs @@ -11,9 +11,10 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - CommandSpec::new("grip.move", "Grip Stretch", run_grip_move) - .params("{handle, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)"), - CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle, index, to}"), + CommandSpec::new("grip.move", "Grip Stretch", run_grip_move).params( + "{handle?, handles?, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)", + ), + CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle?, handles?, index, to}"), CommandSpec::new("grip.rotate", "Grip Rotate", run_grip_rotate).params("{handles?, base | baseHandle+index, angle (degrees) | to, copy?}"), CommandSpec::new("grip.scale", "Grip Scale", run_grip_scale).params("{handles?, base | baseHandle+index, factor | to, copy?}"), CommandSpec::new("grip.mirror", "Grip Mirror", run_grip_mirror) @@ -37,8 +38,27 @@ fn stretch(s: &mut Session, cmd: &str, p: &Value) -> Result { } let e = curves::entity(s, h)?; let k = stretch_grip(&e.kind, i, to).ok_or_else(|| bad(cmd, "no such grip, or the result would be degenerate"))?; - s.doc_mut()?.modify_entity(h, |e| e.kind = k)?; - Ok(json!({ "handle": h.hex() })) + let mut edits = vec![(h, k)]; + // The other `handles` with a grip on the same point stretch with it (connected objects stay + // connected); locked ones and edits that would be degenerate are left alone. + let from = e.kind.grips().get(i).copied().unwrap_or(to); + let tol = 1e-9 * (1.0 + from.len()); + for o in targets(s, p)? { + if o == h || edits.iter().any(|(x, _)| *x == o) || curves::is_locked(s, o) { + continue; + } + let Ok(oe) = curves::entity(s, o) else { continue }; + if let Some(j) = oe.kind.grips().iter().position(|g| g.near(from, tol)) + && let Some(k) = stretch_grip(&oe.kind, j, to) + { + edits.push((o, k)); + } + } + let d = s.doc_mut()?; + for (o, k) in &edits { + d.modify_entity(*o, |e| e.kind = k.clone())?; + } + Ok(json!({ "handle": h.hex(), "handles": edits.iter().map(|(o, _)| o.hex()).collect::>() })) } /// Base point: `base`, or grip `index` of `baseHandle` (default: the first target). diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index d08f47bcf..f0e2d2d49 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -291,21 +291,20 @@ pub fn mode_matrix(mode: GripMode, base: Vec2, to: Vec2) -> Option { impl Session { /// Drag grip `grip_index` of `handle` to `new_point` in `mode` (one undo step). - /// The base point of the rotate/scale/mirror modes is the grip itself. + /// The base point of the rotate/scale/mirror modes is the grip itself. As in AutoCAD the edit + /// covers the selection: a stretch drags the grips of other selected objects that sit on the + /// same point, and the other modes transform every selected object. pub fn grip_edit(&mut self, handle: Handle, grip_index: usize, new_point: Vec2, mode: GripMode) -> Result<()> { let to = [new_point.x, new_point.y]; + let hs: Vec = std::iter::once(handle).chain(self.selection().into_iter().filter(|h| *h != handle)).map(|h| h.hex()).collect(); let r = match mode { - GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "index": grip_index, "to": to })), - GripMode::Move => self.execute("grip.move", &json!({ "handles": [handle.hex()], "index": grip_index, "to": to, "mode": "move" })), - GripMode::Rotate => { - self.execute("grip.rotate", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to })) - } - GripMode::Scale => { - self.execute("grip.scale", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to })) - } - GripMode::Mirror => { - self.execute("grip.mirror", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to })) + GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "handles": hs, "index": grip_index, "to": to })), + GripMode::Move => { + self.execute("grip.move", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to, "mode": "move" })) } + GripMode::Rotate => self.execute("grip.rotate", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })), + GripMode::Scale => self.execute("grip.scale", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })), + GripMode::Mirror => self.execute("grip.mirror", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })), }; r.map(|_| ()) } diff --git a/crates/engine/tests/grip_selection.rs b/crates/engine/tests/grip_selection.rs new file mode 100644 index 000000000..09032d8ba --- /dev/null +++ b/crates/engine/tests/grip_selection.rs @@ -0,0 +1,60 @@ +//! Grip editing acts on the whole selection, as in AutoCAD: a stretched grip drags the +//! coincident grips of the other selected objects with it, and Move/Rotate/Scale/Mirror +//! transform every selected object. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::grips::GripMode; +use serde_json::json; + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap() +} + +fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) { + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Line(l) => (l.a.xy(), l.b.xy()), + k => panic!("not a line: {k:?}"), + } +} + +fn near(p: Vec2, x: f64, y: f64) -> bool { + p.near(Vec2::new(x, y), 1e-9) +} + +#[test] +fn grip_edits_act_on_the_selection() { + // Two connected lines and a third one elsewhere, all selected. + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let b = line(&mut s, [10.0, 0.0], [10.0, 10.0]); + let c = line(&mut s, [20.0, 0.0], [30.0, 0.0]); + s.set_selection(vec![a, b, c]); + let undo = s.state().unwrap().undo.len(); + + // Stretching the shared corner grip of `a` moves `b`'s start with it; `c` stays. + s.grip_edit(a, 2, Vec2::new(12.0, 3.0), GripMode::Stretch).unwrap(); + assert!(near(ends(&s, a).1, 12.0, 3.0)); + assert!(near(ends(&s, b).0, 12.0, 3.0) && near(ends(&s, b).1, 10.0, 10.0), "{:?}", ends(&s, b)); + assert!(near(ends(&s, c).0, 20.0, 0.0) && near(ends(&s, c).1, 30.0, 0.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step"); + + // Move about a grip of `a`: every selected object moves. + s.grip_edit(a, 0, Vec2::new(1.0, 1.0), GripMode::Move).unwrap(); + assert!(near(ends(&s, a).0, 1.0, 1.0)); + assert!(near(ends(&s, b).1, 11.0, 11.0)); + assert!(near(ends(&s, c).0, 21.0, 1.0)); + + // Rotate 90° about the same grip: `c` turns too. + s.grip_edit(a, 0, Vec2::new(1.0, 5.0), GripMode::Rotate).unwrap(); + assert!(near(ends(&s, c).0, 1.0, 21.0), "{:?}", ends(&s, c)); + + // An object that isn't selected is only edited through its own grip. + s.set_selection(vec![a]); + let before = ends(&s, b); + let g = s.grips_of(a).unwrap()[2]; + s.grip_edit(a, 2, g + Vec2::new(1.0, 0.0), GripMode::Stretch).unwrap(); + assert_eq!(ends(&s, b), before); +}