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); +}