From 67b2213957f2397be768e292adf252bb124a4042 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 09:40:18 +0700 Subject: [PATCH] M8.5: Puppet Warp pins rotate, multi-select and Select All; Control bar and hint - Alt-dragging near (not on) a selected pin turns the art around it; the pin keeps that turn through later warps. arap::Pin gains an angle constraint and object.puppetWarp takes angles: [deg|null...]. - Shift-click adds pins to the selection or takes them out; dragging a pin moves every selected pin. Select All Pins selects them all (it used to deselect). Alt-click no longer deletes a pin; Delete removes the selection. - Control bar: Expand (0 allowed), Show Mesh (on by default), Select All Pins. Hint-bar text for the tool. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/distortcmds.rs | 9 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_puppetwarp.rs | 67 +++++ crates/engine/tests/command_sweep.rs | 2 + crates/tools/src/distort/arap.rs | 62 ++++- crates/tools/src/distort/puppet.rs | 333 ++++++++++++++++++++----- crates/ui-egui/src/chrome.rs | 12 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_puppetwarp.rs | 35 +++ crates/ui-egui/src/toolbar.rs | 27 +- docs/control-protocol.md | 7 + docs/mcp.md | 5 + 12 files changed, 489 insertions(+), 74 deletions(-) create mode 100644 crates/engine/src/tests_puppetwarp.rs create mode 100644 crates/ui-egui/src/tests_puppetwarp.rs diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index faf9a1c5..697b662d 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Puppet Warp", [], None, - "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles", + "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles", has_doc, puppet_warp ), @@ -408,7 +408,12 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { } let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?; - let pins: Vec = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect(); + let angles: Vec> = match p.get("angles") { + None | Some(Value::Null) => vec![None; pins.len()], + Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(), + Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")), + }; + let pins: Vec = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect(); let deformed = arap::deform(&mesh, &pins); let f = |q: Point| mesh.map(&deformed, q); s.edit("Puppet Warp", |d, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index be8d1e33..b3a085d4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1272,6 +1272,8 @@ mod tests_printtiling; #[cfg(test)] mod tests_proxyitems; #[cfg(test)] +mod tests_puppetwarp; +#[cfg(test)] mod tests_rastersettings; #[cfg(test)] mod tests_recolor; diff --git a/crates/engine/src/tests_puppetwarp.rs b/crates/engine/src/tests_puppetwarp.rs new file mode 100644 index 00000000..887e445f --- /dev/null +++ b/crates/engine/src/tests_puppetwarp.rs @@ -0,0 +1,67 @@ +//! Puppet Warp: turning the art around a pin (`object.puppetWarp {angles}`) and the tool's pins. + +use serde_json::json; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +#[test] +fn a_pin_with_an_angle_turns_the_art_around_it() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + // One pin at the centre, held at a quarter turn: the 200 × 100 rectangle stands up. + s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": [90]})).unwrap(); + let b = bounds(&s, a); + assert!((b.width() - 100.0).abs() < 4.0 && (b.height() - 200.0).abs() < 4.0, "{b:?}"); + assert!(b.center().distance(Point::new(200.0, 150.0)) < 2.0, "{b:?}"); + // null leaves a pin free; the list must be as long as the pins. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[210, 150]], "angles": [null]})).unwrap(); + assert!((bounds(&s, a).x0 - 110.0).abs() < 0.5, "a free single pin only moves the art"); + for bad in [json!([90, 0]), json!("90"), json!({})] { + assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": bad})).is_err(), "{bad}"); + } +} + +#[test] +fn alt_drag_with_the_tool_turns_the_art_in_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("puppetWarp", v).unwrap(); + s.set_tool_option("selectAllPins", &json!(false)); + // Select the pin in the middle, then Alt-drag about a quarter turn around it, 20 pt out. + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let alt = Mods { alt: true, ..Default::default() }; + for (k, x, y) in + [(PointerKind::Down, 220.0, 150.0), (PointerKind::Drag, 214.0, 164.0), (PointerKind::Drag, 200.0, 170.0), (PointerKind::Up, 200.0, 170.0)] + { + s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), v).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let (cmd, p) = s.journal.last().unwrap(); + assert_eq!(cmd, "object.puppetWarp"); + let angles = p["angles"].as_array().unwrap(); + assert!(angles.iter().any(|a| a.as_f64().is_some_and(|d| (d - 90.0).abs() < 15.0)), "about a quarter turn: {p}"); + let b = bounds(&s, a); + assert!(b.height() > 110.0, "the art turned: {b:?}"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index a4584c7a..def2abe5 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -262,6 +262,8 @@ fn structured_junk() { ("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})), ("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})), ("command.batch", json!({"commands": "nope"})), + ("object.puppetWarp", json!({"pins": [[0, 0]], "moved": [[0, 0]], "angles": [1e308]})), + ("object.puppetWarp", json!({"pins": [[0, 0], [5, 5]], "moved": [[0, 0], [1e9, 1e9]], "angles": [-1e308, "x"]})), ]; let mut failures = vec![]; for fx in Fixture::ALL { diff --git a/crates/tools/src/distort/arap.rs b/crates/tools/src/distort/arap.rs index 946ed52a..0bb93ad8 100644 --- a/crates/tools/src/distort/arap.rs +++ b/crates/tools/src/distort/arap.rs @@ -308,6 +308,26 @@ const REG: f64 = 1.0e-7; pub struct Pin { pub rest: Point, pub target: Point, + /// The rotation (radians) the art takes around the pin; `None` leaves it free (the solve + /// picks it), as for a pin that was only moved. + pub angle: Option, +} + +impl Pin { + /// A pin free to turn. + pub fn new(rest: Point, target: Point) -> Self { + Self { rest, target, angle: None } + } + + /// The soft constraints this pin puts on the mesh: the pin itself and, when its rotation is + /// held, two points a cell away along the rest axes, turned by the angle around the target. + fn constraints(&self, reach: f64) -> impl Iterator + use<> { + let frame = self.angle.map(|a| { + let (s, c) = a.sin_cos(); + [Vec2::new(reach, 0.0), Vec2::new(0.0, reach)].map(|d| (self.rest + d, self.target + Vec2::new(d.x * c - d.y * s, d.x * s + d.y * c))) + }); + std::iter::once((self.rest, self.target)).chain(frame.into_iter().flatten()) + } } /// Run both ARAP steps. Returns the deformed vertex positions (the rest mesh if fewer than one @@ -317,7 +337,8 @@ pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec { if n == 0 || pins.is_empty() { return mesh.verts.clone(); } - let located: Vec<(usize, [f64; 3], Point)> = pins.iter().filter_map(|p| mesh.locate(p.rest).map(|(t, w)| (t, w, p.target))).collect(); + let located: Vec<(usize, [f64; 3], Point)> = + pins.iter().flat_map(|p| p.constraints(mesh.cell)).filter_map(|(rest, target)| mesh.locate(rest).map(|(t, w)| (t, w, target))).collect(); let vb = mesh.vertex_band(); // ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...). let mut g = BandMatrix::new(2 * n, 2 * vb + 1); @@ -528,7 +549,7 @@ mod tests { fn pins_at_rest_leave_the_mesh_unchanged() { let m = rect_mesh(); let pins: Vec = - [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect(); + [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin::new(Point::new(x, y), Point::new(x, y))).collect(); let d = deform(&m, &pins); let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max); assert!(err < 1e-4, "{err}"); @@ -541,10 +562,8 @@ mod tests { let (s, c) = 0.5f64.sin_cos(); Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0) }; - let pins: Vec = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)] - .iter() - .map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) }) - .collect(); + let pins: Vec = + [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)].iter().map(|&(x, y)| Pin::new(Point::new(x, y), rot(Point::new(x, y)))).collect(); let d = deform(&m, &pins); let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max); assert!(err < 1e-2, "{err}"); @@ -555,9 +574,9 @@ mod tests { let m = rect_mesh(); // Hold the left end, lift the right end. let pins = vec![ - Pin { rest: Point::new(10.0, 30.0), target: Point::new(10.0, 30.0) }, - Pin { rest: Point::new(10.0, 70.0), target: Point::new(10.0, 70.0) }, - Pin { rest: Point::new(190.0, 50.0), target: Point::new(180.0, -30.0) }, + Pin::new(Point::new(10.0, 30.0), Point::new(10.0, 30.0)), + Pin::new(Point::new(10.0, 70.0), Point::new(10.0, 70.0)), + Pin::new(Point::new(190.0, 50.0), Point::new(180.0, -30.0)), ]; let d = deform(&m, &pins); for p in &pins { @@ -579,6 +598,31 @@ mod tests { assert!(worst < 0.3, "edge stretch {worst}"); } + #[test] + fn a_held_rotation_turns_the_art_around_its_pin() { + let m = rect_mesh(); + let c = Point::new(100.0, 50.0); + let rot = |p: Point| { + let (s, co) = 0.4f64.sin_cos(); + let d = p - c; + c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) + }; + // One pin, held at 0.4 rad: the whole shape turns rigidly around it. + let d = deform(&m, &[Pin { angle: Some(0.4), ..Pin::new(c, c) }]); + let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max); + assert!(err < 0.05, "{err}"); + // A free pin alone leaves the shape where it is. + let d = deform(&m, &[Pin::new(c, c)]); + assert!(m.verts.iter().zip(&d).all(|(a, b)| a.distance(*b) < 1e-3)); + // Held ends: the turned end turns, the other end stays. + let left = Point::new(10.0, 50.0); + let pins = [Pin { angle: Some(0.5), ..Pin::new(left, left) }, Pin::new(Point::new(190.0, 50.0), Point::new(190.0, 50.0))]; + let d = deform(&m, &pins); + let near = m.map(&d, Point::new(10.0, 62.0)) - left; + assert!((near.atan2() - (Vec2::new(0.0, 12.0).atan2() + 0.5)).abs() < 0.1, "the left end turned: {near:?}"); + assert!(m.map(&d, Point::new(190.0, 80.0)).distance(Point::new(190.0, 80.0)) < 5.0); + } + #[test] fn auto_pins_are_spread_inside_the_shape() { let m = rect_mesh(); diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index c10d9da9..3d804892 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -1,11 +1,16 @@ //! Puppet Warp tool. //! -//! With artwork selected the tool places three pins automatically (spread over the mesh). Click -//! on the artwork adds a pin, Alt-click deletes one, Delete/Backspace removes the selected pin, -//! and dragging a pin warps the art: every drag previews -//! `object.puppetWarp {ids, pins, moved}` (pins = current pin positions, moved = the same list -//! with the dragged pin at the pointer), committed on release as one undo step. The mesh follows -//! the current shape, so successive drags compose. +//! With artwork selected the tool places three pins automatically (spread over the mesh). A click +//! on the artwork adds a pin and selects it; Shift-click adds pins to the selection (or takes them +//! out); dragging a pin moves every selected pin and warps the art; Alt-dragging near (not on) a +//! selected pin turns the art around it, and that pin keeps the turn through later warps; +//! Delete/Backspace remove the selected pins. Every drag previews +//! `object.puppetWarp {ids, pins, moved, angles, expand}` (pins = current pin positions, moved = +//! where they go, angles = the turn held at each pin), committed on release as one undo step. The +//! mesh follows the current shape, so successive drags compose. +//! +//! Control bar (`tool.setOption`): `showMesh` (on by default), `expand` (Expand Mesh, points, 0 +//! allowed) and `selectAllPins`. use serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId}; @@ -39,18 +44,48 @@ pub fn mesh_for(doc: &Document, ids: &[NodeId], expand: f64) -> Option { Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() })) } -#[derive(Default)] +/// Within this many pixels of a selected pin (and not on it), Alt-dragging turns the art around it. +const TURN_REACH_PX: f64 = 24.0; +/// Radius (pixels) of the dotted circle shown around the pin the art would turn around. +const TURN_RING_PX: f64 = 16.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct PinState { + p: Point, + /// The art was turned around the pin: later warps keep that turn. + held: bool, +} + +/// A drag in progress. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Gesture { + /// The selected pins move by `to - from`. + Move { from: Point, to: Point }, + /// The art turns by `angle` (radians) around pin `pin`; `from`: the pointer's angle around it + /// when the drag began. + Turn { pin: usize, from: f64, angle: f64 }, +} + pub struct PuppetWarpTool { ids: Vec, - pins: Vec, - selected: Option, - drag: Option<(usize, Point)>, + pins: Vec, + /// Selected pins (indices into `pins`). + selected: Vec, + drag: Option, + /// The pointer and the modifiers held while hovering (for the turn ring). + hover: Option<(Point, Mods)>, /// Control bar: Show Mesh. show_mesh: bool, - /// Control bar: Expand Mesh (points). + /// Control bar: Expand Mesh (points, 0 allowed). expand: f64, } +impl Default for PuppetWarpTool { + fn default() -> Self { + Self { ids: vec![], pins: vec![], selected: vec![], drag: None, hover: None, show_mesh: true, expand: 3.0 } + } +} + impl PuppetWarpTool { /// Re-seed pins when the selection changed. fn sync(&mut self, cx: &ToolContext) { @@ -59,25 +94,77 @@ impl PuppetWarpTool { return; } self.ids = ids; - self.selected = None; + self.selected.clear(); self.pins = if self.ids.is_empty() { vec![] } else { - mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default() + let auto = mesh_for(cx.doc, &self.ids, self.expand).map(|m| auto_pins(&m, 3)).unwrap_or_default(); + auto.into_iter().map(|p| PinState { p, held: false }).collect() }; } - fn expand_or_default(&self) -> f64 { - if self.expand > 0.0 { self.expand } else { 3.0 } - } fn pin_at(&self, cx: &ToolContext, p: Point) -> Option { let tol = cx.tol(7.0); - self.pins.iter().enumerate().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))).map(|(i, _)| i) + self.pins + .iter() + .enumerate() + .filter(|(_, q)| q.p.distance(p) <= tol) + .min_by(|a, b| a.1.p.distance(p).total_cmp(&b.1.p.distance(p))) + .map(|(i, _)| i) } - fn params(&self, i: usize, to: Point) -> Value { - let mut moved = self.pins.clone(); - moved[i] = to; - let pj = |v: &[Point]| Value::Array(v.iter().map(|p| json!([p.x, p.y])).collect()); - json!({"ids": crate::json_ids(&self.ids), "pins": pj(&self.pins), "moved": pj(&moved), "expand": self.expand_or_default()}) + /// The selected pin the art would turn around with Alt held at `p`: near it, not on a pin. + fn turn_pin_at(&self, cx: &ToolContext, p: Point) -> Option { + if self.pin_at(cx, p).is_some() { + return None; + } + let reach = cx.tol(TURN_REACH_PX); + self.selected + .iter() + .filter_map(|&i| Some((i, self.pins.get(i)?.p.distance(p)))) + .filter(|(_, d)| *d <= reach) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// Where each pin is during gesture `g`, and the turn it holds (radians). + fn targets(&self, g: Gesture) -> Vec<(Point, Option)> { + self.pins + .iter() + .enumerate() + .map(|(i, q)| match g { + Gesture::Move { from, to } if self.selected.contains(&i) => (q.p + (to - from), q.held.then_some(0.0)), + Gesture::Turn { pin, angle, .. } if pin == i => (q.p, Some(angle)), + _ => (q.p, q.held.then_some(0.0)), + }) + .collect() + } + fn params(&self, g: Gesture) -> Value { + let moved = self.targets(g); + let pj = |v: &mut dyn Iterator| Value::Array(v.map(|p| json!([p.x, p.y])).collect()); + json!({ + "ids": crate::json_ids(&self.ids), + "pins": pj(&mut self.pins.iter().map(|q| q.p)), + "moved": pj(&mut moved.iter().map(|m| m.0)), + "angles": moved.iter().map(|m| m.1.map(f64::to_degrees)).collect::>(), + "expand": self.expand, + }) + } + /// Select pin `i` (Shift: add it to the selection or take it out). → whether it is selected. + fn pick(&mut self, i: usize, shift: bool) -> bool { + match (shift, self.selected.iter().position(|s| *s == i)) { + (true, Some(k)) => { + self.selected.remove(k); + false + } + (true, None) => { + self.selected.push(i); + true + } + // A press on one of several selected pins drags them all. + (false, Some(_)) => true, + (false, None) => { + self.selected = vec![i]; + true + } + } } } @@ -88,6 +175,10 @@ impl Tool for PuppetWarpTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; match ev.kind { + PointerKind::Move => { + self.hover = Some((p, ev.mods)); + vec![] + } PointerKind::Down => { self.sync(cx); if self.ids.is_empty() { @@ -97,54 +188,66 @@ impl Tool for PuppetWarpTool { } return vec![]; } + if ev.mods.alt + && let Some(i) = self.turn_pin_at(cx, p) + && let Some(c) = self.pins.get(i).map(|q| q.p) + { + self.drag = Some(Gesture::Turn { pin: i, from: (p - c).atan2(), angle: 0.0 }); + return vec![Action::Begin("Puppet Warp".into())]; + } if let Some(i) = self.pin_at(cx, p) { - if ev.mods.alt { - self.pins.remove(i); - self.selected = None; + if !self.pick(i, ev.mods.shift) { return vec![]; } - self.selected = Some(i); - self.drag = Some((i, p)); + self.drag = Some(Gesture::Move { from: p, to: p }); return vec![Action::Begin("Puppet Warp".into())]; } let inside = cx.doc.bounds_of(&self.ids, false).is_some_and(|b| b.inflate(cx.tol(4.0), cx.tol(4.0)).contains(p)); - if inside && !ev.mods.alt { - self.pins.push(p); - self.selected = Some(self.pins.len() - 1); + if inside { + self.pins.push(PinState { p, held: false }); + self.pick(self.pins.len() - 1, ev.mods.shift); + } else if !ev.mods.shift { + self.selected.clear(); } vec![] } - PointerKind::Drag => match self.drag { - Some((i, _)) => { - self.drag = Some((i, p)); - vec![Action::Preview("object.puppetWarp".into(), self.params(i, p))] + PointerKind::Drag => { + let Some(g) = self.drag.as_mut() else { return vec![] }; + match g { + Gesture::Move { to, .. } => *to = p, + Gesture::Turn { pin, from, angle } => { + let Some(c) = self.pins.get(*pin).map(|q| q.p) else { return vec![] }; + *angle = (p - c).atan2() - *from; + } } - None => vec![], - }, + let g = *g; + vec![Action::Preview("object.puppetWarp".into(), self.params(g))] + } PointerKind::Up => match self.drag.take() { - Some((i, _)) => { - self.pins[i] = p; + Some(g) => { + let moved = self.targets(g); + for (q, (to, angle)) in self.pins.iter_mut().zip(moved) { + (q.p, q.held) = (to, angle.is_some()); + } vec![Action::Commit] } None => vec![], }, - _ => vec![], + PointerKind::DoubleClick => vec![], } } - /// Delete/Backspace remove the selected pin; with none selected they are the shortcut's + /// Delete/Backspace remove the selected pins; with none selected they are the shortcut's /// (Clear deletes the selected objects). fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { matches!(key, ToolKey::Delete | ToolKey::Backspace) && self.drag.is_none() && cx.selection.objects == self.ids - && self.selected.is_some_and(|i| i < self.pins.len()) + && self.selected.iter().any(|i| *i < self.pins.len()) } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if matches!(key, ToolKey::Delete | ToolKey::Backspace) - && let Some(i) = self.selected.take() - && i < self.pins.len() - { - self.pins.remove(i); + if matches!(key, ToolKey::Delete | ToolKey::Backspace) && self.drag.is_none() { + let gone = std::mem::take(&mut self.selected); + self.pins = std::mem::take(&mut self.pins).into_iter().enumerate().filter(|(i, _)| !gone.contains(i)).map(|(_, q)| q).collect(); } vec![] } @@ -152,7 +255,7 @@ impl Tool for PuppetWarpTool { let mut out = vec![]; if self.show_mesh && cx.selection.objects == self.ids - && let Some(m) = mesh_for(cx.doc, &self.ids, self.expand_or_default()) + && let Some(m) = mesh_for(cx.doc, &self.ids, self.expand) { let mut bp = BezPath::new(); for t in &m.tris { @@ -164,32 +267,54 @@ impl Tool for PuppetWarpTool { out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false }); } let r = cx.tol(5.0); - for (i, q) in self.pins.iter().enumerate() { - let q = match self.drag { - Some((d, to)) if d == i => to, - _ => *q, - }; - let circle = super::ellipse_path(q, r, r, 0.0); - out.push(Overlay::Path { path: circle, color: BLUE, width: if self.selected == Some(i) { 3.0 } else { 1.5 }, dashed: false }); - out.push(Overlay::Anchor { p: q, color: BLUE, filled: self.selected == Some(i), size: 3.0 }); + let shown: Vec = match self.drag { + Some(g) => self.targets(g).into_iter().map(|t| t.0).collect(), + None => self.pins.iter().map(|q| q.p).collect(), + }; + for (i, q) in shown.iter().enumerate() { + let sel = self.selected.contains(&i); + out.push(Overlay::Path { path: super::ellipse_path(*q, r, r, 0.0), color: BLUE, width: if sel { 3.0 } else { 1.5 }, dashed: false }); + out.push(Overlay::Anchor { p: *q, color: BLUE, filled: sel, size: 3.0 }); + } + // The dotted circle of a turn: around the pin being turned, or the one Alt would turn. + let ring = match (self.drag, self.hover) { + (Some(Gesture::Turn { pin, .. }), _) => Some(pin), + (None, Some((h, mods))) if mods.alt => self.turn_pin_at(cx, h), + _ => None, + }; + if let Some(c) = ring.and_then(|i| self.pins.get(i)) { + let rr = cx.tol(TURN_RING_PX); + out.push(Overlay::Path { path: super::ellipse_path(c.p, rr, rr, 0.0), color: BLUE, width: 1.0, dashed: true }); } out } fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor { + if mods.alt && self.turn_pin_at(cx, p).is_some() { + return Cursor::Rotate; + } match self.pin_at(cx, p) { - Some(_) if mods.alt => Cursor::PenDelete, Some(_) => Cursor::Move, None => Cursor::PenAdd, } } fn options(&self) -> Value { - json!({"showMesh": self.show_mesh, "expand": self.expand_or_default(), "pins": self.pins.iter().map(|p| json!([p.x, p.y])).collect::>()}) + json!({ + "showMesh": self.show_mesh, + "expand": self.expand, + "pins": self.pins.iter().map(|q| json!([q.p.x, q.p.y])).collect::>(), + "selected": self.selected, + }) } fn set_option(&mut self, key: &str, value: &Value) { match key { "showMesh" => self.show_mesh = value.as_bool().unwrap_or(self.show_mesh), - "expand" => self.expand = value.as_f64().unwrap_or(self.expand).clamp(0.0, 1000.0), - "selectAllPins" => self.selected = None, + "expand" => { + if let Some(v) = value.as_f64().filter(|v| v.is_finite()) { + self.expand = v.clamp(0.0, 1000.0); + } + } + // Select All Pins (false: deselect them all). + "selectAllPins" => self.selected = if value.as_bool() == Some(false) { vec![] } else { (0..self.pins.len()).collect() }, _ => {} } } @@ -202,6 +327,8 @@ impl Tool for PuppetWarpTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.ids.clear(); self.pins.clear(); + self.selected.clear(); + self.hover = None; if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } } } @@ -212,6 +339,14 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + fn alt() -> Mods { + Mods { alt: true, ..Default::default() } + } + + fn shift() -> Mods { + Mods { shift: true, ..Default::default() } + } + #[test] fn puppet_tool_autopins_adds_and_drags() { let (d, id) = doc_with_rect(); @@ -232,8 +367,84 @@ mod tests { assert_eq!(v["moved"][3], json!([120.0, 190.0])); assert_eq!(v["pins"][3], json!([130.0, 170.0])); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0)), vec![Action::Commit]); - // Alt-click deletes it. - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 120.0, 190.0).with_mods(Mods { alt: true, ..Default::default() })); + // Alt-click on a pin no longer deletes it (Alt turns the art around a pin): Delete does. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 120.0, 190.0).with_mods(alt())); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0).with_mods(alt())); + assert_eq!(t.pins.len(), 4); + assert!(t.claims_key(&c, ToolKey::Delete)); + t.key(&c, ToolKey::Delete, Mods::default()); assert_eq!(t.pins.len(), 3); + assert!(!t.claims_key(&c, ToolKey::Delete), "nothing selected: Delete is Clear's"); + } + + #[test] + fn shift_click_selects_several_pins_that_move_together() { + let (d, id) = doc_with_rect(); + let s = Selection { objects: vec![id], ..Default::default() }; + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = PuppetWarpTool::default(); + let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); + // Two new pins, the second Shift-added to the selection. + t.pointer(&c, &ev(PointerKind::Down, 110.0, 110.0, Mods::default())); + t.pointer(&c, &ev(PointerKind::Down, 190.0, 110.0, shift())); + let (a, b) = (t.pins.len() - 2, t.pins.len() - 1); + assert_eq!(t.selected, vec![a, b]); + // Dragging one moves both. + t.pointer(&c, &ev(PointerKind::Down, 110.0, 110.0, Mods::default())); + let acts = t.pointer(&c, &ev(PointerKind::Drag, 110.0, 100.0, Mods::default())); + let Action::Preview(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((v["moved"][a].clone(), v["moved"][b].clone()), (json!([110.0, 100.0]), json!([190.0, 100.0]))); + assert_eq!(v["moved"][0], v["pins"][0], "unselected pins stay"); + t.pointer(&c, &ev(PointerKind::Up, 110.0, 100.0, Mods::default())); + // Shift-click takes a pin out of the selection without dragging. + assert!(t.pointer(&c, &ev(PointerKind::Down, 190.0, 100.0, shift())).is_empty()); + assert_eq!(t.selected, vec![a]); + // Select All Pins. + t.set_option("selectAllPins", &json!(true)); + assert_eq!(t.selected, (0..t.pins.len()).collect::>()); + t.key(&c, ToolKey::Backspace, Mods::default()); + assert!(t.pins.is_empty()); + } + + #[test] + fn alt_drag_near_a_selected_pin_turns_the_art_around_it() { + let (d, id) = doc_with_rect(); + let s = Selection { objects: vec![id], ..Default::default() }; + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = PuppetWarpTool::default(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)); + let i = t.pins.len() - 1; + // Alt near (not on) the selected pin: the turn ring and cursor. + t.pointer(&c, &PointerEvent::new(PointerKind::Move, 125.0, 150.0).with_mods(alt())); + assert_eq!(t.cursor(&c, Point::new(125.0, 150.0), alt()), Cursor::Rotate); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "dotted ring"); + assert_ne!(t.cursor(&c, Point::new(125.0, 150.0), Mods::default()), Cursor::Rotate, "only with Alt"); + // Drag a quarter turn around it. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 125.0, 150.0).with_mods(alt())), vec![Action::Begin("Puppet Warp".into())]); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 110.0, 165.0).with_mods(alt())); + let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(cmd, "object.puppetWarp"); + assert!((v["angles"][i].as_f64().unwrap() - 90.0).abs() < 1e-6, "{v}"); + assert_eq!(v["moved"][i], v["pins"][i], "the pin stays put"); + assert_eq!(v["angles"][0], Value::Null, "other pins turn freely"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 110.0, 165.0).with_mods(alt())); + // The turned pin keeps its turn through the next warp. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, t.pins[0].p.x, t.pins[0].p.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, t.pins[0].p.x + 5.0, t.pins[0].p.y)); + let Action::Preview(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(v["angles"][i], json!(0.0)); + } + + #[test] + fn show_mesh_is_on_by_default_and_expand_may_be_zero() { + let mut t = PuppetWarpTool::default(); + assert_eq!((t.options()["showMesh"].clone(), t.options()["expand"].clone()), (json!(true), json!(3.0))); + t.set_option("expand", &json!(0)); + assert_eq!(t.options()["expand"], json!(0.0)); + t.set_option("expand", &json!(f64::NAN)); + t.set_option("expand", &json!("x")); + assert_eq!(t.options()["expand"], json!(0.0)); } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ee282f72..c793b14e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -490,6 +490,18 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { ], "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], "artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)], + "puppetWarp" => &[ + ("Click", true), + (" the art to add a pin | ", false), + ("Shift+Click", true), + (" to select more pins | ", false), + ("Drag", true), + (" a pin to warp | ", false), + ("Alt+Drag", true), + (" near a selected pin to rotate | ", false), + ("Delete", true), + (" to remove pins", false), + ], _ => return None, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8d36c58b..93745c45 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -79,6 +79,8 @@ mod tests_printps; #[cfg(test)] mod tests_printtiling; #[cfg(test)] +mod tests_puppetwarp; +#[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_recovery; diff --git a/crates/ui-egui/src/tests_puppetwarp.rs b/crates/ui-egui/src/tests_puppetwarp.rs new file mode 100644 index 00000000..1ad830bc --- /dev/null +++ b/crates/ui-egui/src/tests_puppetwarp.rs @@ -0,0 +1,35 @@ +//! The Puppet Warp tool in the UI: its Control bar (Expand, Show Mesh, Select All Pins) and hint. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, chrome, tests_labels::painted_text}; + +#[test] +fn the_control_bar_shows_puppet_warp_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 200, "height": 100})).unwrap(); + app.run("tool.select", json!({"tool": "puppetWarp"})).unwrap(); + let text = painted_text(&mut app, chrome::control_bar); + for s in ["Expand:", "3 pt", "Show Mesh", "Select All Pins"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + // The options it sets: Expand may be 0, Show Mesh turns off. + let opts = app.run("tool.setOption", json!({"key": "expand", "value": 0})).unwrap(); + assert_eq!(opts["expand"], json!(0.0)); + let opts = app.run("tool.setOption", json!({"key": "showMesh", "value": false})).unwrap(); + assert_eq!(opts["showMesh"], json!(false)); + assert!(painted_text(&mut app, chrome::control_bar).lines().any(|l| l == "0 pt")); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + assert!(!painted_text(&mut app, chrome::control_bar).contains("Show Mesh"), "only with Puppet Warp"); +} + +#[test] +fn the_hint_bar_explains_puppet_warp() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "puppetWarp"})).unwrap(); + let text = painted_text(&mut app, chrome::hint_bar).replace('\n', ""); + assert!(text.contains("add a pin") && text.contains("rotate") && text.contains("Delete"), "{text}"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 37dfd269..b302aaeb 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -217,8 +217,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result