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 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-10 16:54:48 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 049504a81e
commit 0fef879509
3 changed files with 95 additions and 16 deletions
+25 -5
View File
@@ -11,9 +11,10 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
}
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::<Vec<_>>() }))
}
/// Base point: `base`, or grip `index` of `baseHandle` (default: the first target).
+10 -11
View File
@@ -291,21 +291,20 @@ pub fn mode_matrix(mode: GripMode, base: Vec2, to: Vec2) -> Option<Mat3> {
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<String> = 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(|_| ())
}
+60
View File
@@ -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);
}