From 117aa0b5a3cfd783c6dccbc601ef98e07bbe2cfe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:42:28 +0700 Subject: [PATCH] M8.6: Puppet Warp pins appear at once, warp from the rest shape and follow Undo - Pins show as soon as the tool has art selected: automatic ones at the centre and the middle of each limb's end (at least three), until pins are placed. They stay across tool switches while the selection is unchanged and a new selection starts afresh. - The pins live in the document (Document.puppet, never saved) with the rest shape they started from: every warp starts from it, so warps don't stack and dragging a pin back restores the original; Undo/Redo take the pins back with the art. Adding and deleting pins are undo steps too. - object.puppetWarp gains rest: true (the tool's pins); the new query object.puppetWarp.pins reads them. A click off the mesh adds nothing and says so (not-allowed cursor, canvas label); the command refuses such pins. - The mesh grid covers the expanded art, so tiny art with a wide Expand no longer allocates millions of cells. - Tests on groups, compound paths and open strokes. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 7 + crates/doc/src/puppet.rs | 89 ++++ crates/engine/src/cmd/distortcmds.rs | 67 ++- crates/engine/src/lib.rs | 4 + crates/engine/src/tests_distort.rs | 18 +- crates/engine/src/tests_puppetwarp.rs | 202 +++++++- crates/engine/tests/command_sweep.rs | 4 + crates/tools/src/distort.rs | 2 +- crates/tools/src/distort/arap.rs | 239 ++++++++- crates/tools/src/distort/puppet.rs | 717 ++++++++++++++++++-------- docs/control-protocol.md | 17 +- docs/mcp.md | 12 + 12 files changed, 1115 insertions(+), 263 deletions(-) create mode 100644 crates/doc/src/puppet.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f7e6d979..3fad4703 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -23,6 +23,7 @@ pub mod pattern; pub mod perspective; mod pixels; pub mod profiles; +pub mod puppet; pub mod range; pub mod rastersettings; mod reach; @@ -69,6 +70,7 @@ pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; pub use profiles::ColorProfiles; +pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use selection::{AnchorRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; @@ -566,6 +568,10 @@ pub struct Document { /// export settings are Export for Screens' ([`Document::export_settings`]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub assets: Vec, + /// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing + /// state, never saved. + #[serde(skip)] + pub puppet: Option>, } fn ppi72() -> f64 { @@ -633,6 +639,7 @@ impl Document { slices_clip_to_artboard: true, print_setup: None, assets: vec![], + puppet: None, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/puppet.rs b/crates/doc/src/puppet.rs new file mode 100644 index 00000000..708460bb --- /dev/null +++ b/crates/doc/src/puppet.rs @@ -0,0 +1,89 @@ +//! Puppet Warp pins: editing state the Puppet Warp tool and `object.puppetWarp` share while the +//! same artwork stays selected. It lives in the document (never saved) so Undo and Redo take the +//! pins back with the warps they made. + +use std::sync::Arc; + +use vectorcraft_geom::Point; + +use crate::{Document, Node, NodeId}; + +/// One pin: where it sits on the rest shape, where it is now, and the turn it holds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PuppetPin { + pub rest: Point, + pub at: Point, + /// Radians the art is turned around the pin; `None`: free to turn. + pub angle: Option, +} + +/// The pins on some objects and the shape they warp from. +#[derive(Clone, Debug, PartialEq)] +pub struct PuppetPins { + /// The warped objects. + pub ids: Vec, + /// Their rest shape: the objects as they were when pinning began (the warp always starts + /// from it, so warps don't stack and dragging a pin back restores the original). + pub rest: Vec>, + /// The objects as the last warp left them: once anything else edits them, the pins start + /// again from the objects as they are. + pub result: Vec>, + pub pins: Vec, +} + +impl PuppetPins { + /// The pins are still those of `ids` in `doc`: the same objects, as the last warp left them. + pub fn holds(&self, doc: &Document, ids: &[NodeId]) -> bool { + self.ids == ids + && self.ids.len() == self.result.len() + && ids.iter().zip(&self.result).all(|(id, r)| doc.node_arc(*id).is_some_and(|a| Arc::ptr_eq(a, r))) + } +} + +impl Document { + /// The node `id` as the document holds it (shared: comparing pointers says whether it changed). + pub fn node_arc(&self, id: NodeId) -> Option<&Arc> { + fn find(nodes: &[Arc], id: NodeId) -> Option<&Arc> { + for n in nodes { + if n.id == id { + return Some(n); + } + if let Some(f) = n.children().and_then(|ch| find(ch, id)) { + return Some(f); + } + } + None + } + find(&self.layers, id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Appearance; + use vectorcraft_geom::{Rect, shapes}; + + #[test] + fn pins_hold_until_their_objects_change_and_are_never_saved() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + let node = d.node_arc(id).cloned().unwrap(); + let pin = PuppetPin { rest: Point::new(5.0, 5.0), at: Point::new(6.0, 5.0), angle: Some(0.5) }; + d.puppet = Some(Arc::new(PuppetPins { ids: vec![id], rest: vec![node.clone()], result: vec![node], pins: vec![pin] })); + let pins = d.puppet.clone().unwrap(); + assert!(pins.holds(&d, &[id])); + assert!(!pins.holds(&d, &[id, l]), "other objects"); + // Saved and read back without them (editing state). + let json = serde_json::to_string(&d).unwrap(); + assert!(!json.contains("puppet")); + let back: Document = serde_json::from_str(&json).unwrap(); + assert!(back.puppet.is_none()); + assert_eq!(back.node(id), d.node(id)); + // An edit by anything else: the pins no longer hold. + d.node_mut(id).unwrap().name = Some("moved".into()); + assert!(!pins.holds(&d, &[id])); + } +} diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index dbe7d58f..13abcef8 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -7,11 +7,11 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile}; +use vectorcraft_doc::{Document, NodeId, NodeKind, PuppetPin, WidthProfile}; use vectorcraft_geom::{Affine, Homography, Point}; use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample}; use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; -use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with}; +use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, warp_from_rest, warp_node_with}; use super::edit::{duplicate_in, selected_roots}; use super::*; @@ -69,10 +69,19 @@ pub fn specs() -> Vec { "Puppet Warp", [], None, - "{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", + "{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, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", has_doc, puppet_warp ), + cmd!( + "object.puppetWarp.pins", + "Puppet Warp Pins", + [], + None, + "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", + has_doc, + puppet_pins + ), cmd!( "perspective.grid.set", "Define Perspective Grid", @@ -424,30 +433,43 @@ fn liquify(s: &mut Session, p: &Value) -> Result { // ---------- puppet warp ---------- +/// The objects a Puppet Warp command works on: `id`/`ids`, else `default`. +fn puppet_ids(p: &Value, default: impl FnOnce() -> Result>) -> Result> { + let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => v, + None => default()?, + }; + if ids.is_empty() { + return Err(EngineError::Other("select the artwork to warp".into())); + } + Ok(ids) +} + fn puppet_warp(s: &mut Session, p: &Value) -> Result { const C: &str = "object.puppetWarp"; let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?; let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?; - if pins.is_empty() || pins.len() != moved.len() { + let rest = bool_or(p, "rest", false); + if (pins.is_empty() && !rest) || pins.len() != moved.len() { return Err(bad(C, "pins and moved must be non-empty lists of the same length")); } if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { - Some(v) => v, - None => selected_roots(s)?, - }; - if ids.is_empty() { - return Err(EngineError::Other("select the artwork to warp".into())); - } + let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? }; 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 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 ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); + if rest { + let pins: Vec = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect(); + s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?; + return Ok(ids_json); + } + 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).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); @@ -458,7 +480,26 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) + Ok(ids_json) +} + +/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are +/// never saved). +pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) { + let clean = !st.is_dirty(); + Arc::make_mut(&mut st.doc).puppet = None; + if clean { + st.mark_saved(); + } +} + +fn puppet_pins(s: &mut Session, p: &Value) -> Result { + let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?; + let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); + let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?; + let mut v = set.params(&set.pins, expand); + v["auto"] = json!(set.auto); + Ok(v) } // ---------- perspective grid ---------- diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17795365..a6026cc1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1049,6 +1049,10 @@ impl Session { f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); st.revision += 1; + // Puppet Warp pins belong to the art they were placed on: another selection starts afresh. + if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) { + cmd::distortcmds::drop_puppet_pins(st); + } Ok(()) } diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests_distort.rs index 832073a4..5e352cd1 100644 --- a/crates/engine/src/tests_distort.rs +++ b/crates/engine/src/tests_distort.rs @@ -207,13 +207,19 @@ fn puppet_tool_drag_warps_selection() { s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); let v = ViewInfo::default(); s.select_tool("puppetWarp", v).unwrap(); - // First click seeds pins and adds one at the right end, then drag it. - s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 150.0), v).unwrap(); + // The automatic pins show at once, one in the middle of each end: drag the right end's up. + let pins = s.execute("object.puppetWarp.pins", &json!({})).unwrap()["moved"].clone(); + let right = pins + .as_array() + .unwrap() + .iter() + .map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())) + .fold((0.0, 0.0), |a, q| if q.0 > a.0 { q } else { a }); + assert!(right.0 > 350.0, "{pins}"); let n = undo_len(&s); - s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Drag, 385.0, 120.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 120.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, right.0, right.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, right.0, right.1 - 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, right.0, right.1 - 30.0), v).unwrap(); assert_eq!(undo_len(&s), n + 1); assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp"); assert!(path(&s, a).bounds().unwrap().y0 < 95.0); diff --git a/crates/engine/src/tests_puppetwarp.rs b/crates/engine/src/tests_puppetwarp.rs index 887e445f..fab70c7a 100644 --- a/crates/engine/src/tests_puppetwarp.rs +++ b/crates/engine/src/tests_puppetwarp.rs @@ -1,6 +1,6 @@ //! Puppet Warp: turning the art around a pin (`object.puppetWarp {angles}`) and the tool's pins. -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -60,8 +60,204 @@ fn alt_drag_with_the_tool_turns_the_art_in_one_undo_step() { 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}"); + // The turn is the angle the pointer swept around the pin. + let i = p["angles"].as_array().unwrap().iter().position(|a| a.is_number()).unwrap(); + let (cx, cy) = (p["moved"][i][0].as_f64().unwrap(), p["moved"][i][1].as_f64().unwrap()); + let swept = ((170.0 - cy).atan2(200.0 - cx) - (150.0 - cy).atan2(220.0 - cx)).to_degrees(); + assert!((p["angles"][i].as_f64().unwrap() - swept).abs() < 1e-6 && swept > 60.0, "{p}"); let b = bounds(&s, a); assert!(b.height() > 110.0, "the art turned: {b:?}"); } + +// ---------- the pins' session (M8.6) ---------- + +fn pins(s: &mut Session) -> Value { + s.execute("object.puppetWarp.pins", &json!({})).unwrap() +} + +fn anchors(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| { + if let Some(p) = n.path_data() { + out.extend(p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))); + } + }); + out +} + +fn worst(a: &[Point], b: &[Point]) -> f64 { + assert_eq!(a.len(), b.len()); + a.iter().zip(b).map(|(x, y)| x.distance(*y)).fold(0.0, f64::max) +} + +fn click(s: &mut Session, x: f64, y: f64) { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); +} + +fn pin_overlays(s: &mut Session) -> usize { + s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count() +} + +#[test] +fn pins_appear_when_the_tool_is_chosen_or_art_selected() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + // Art selected, then the tool: the automatic pins show at once (the centre and both ends). + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + assert_eq!(pin_overlays(&mut s), 3); + let p = pins(&mut s); + assert_eq!((p["auto"].clone(), p["ids"].clone(), p["rest"].clone()), (json!(true), json!([a.0]), json!(true))); + assert_eq!(p["pins"], p["moved"], "automatic pins sit where they started"); + // The tool first, then art selected. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(pin_overlays(&mut s), 0); + assert!(s.execute("object.puppetWarp.pins", &json!({})).is_err(), "nothing selected"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(pin_overlays(&mut s), 3); +} + +#[test] +fn warps_start_from_the_rest_shape_and_dont_stack() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + let rest = anchors(&s, a); + let p = pins(&mut s); + // Move the right-end pin up, then back: the original comes back. + let right = p["pins"].as_array().unwrap().iter().position(|q| q[0].as_f64().unwrap() > 300.0).unwrap(); + let mut up = p.clone(); + up["moved"][right][1] = json!(up["moved"][right][1].as_f64().unwrap() - 60.0); + s.execute("object.puppetWarp", &up).unwrap(); + let once = anchors(&s, a); + assert!(worst(&rest, &once) > 30.0); + // The same pins again: the same warp (it doesn't stack on the last one). + s.execute("object.puppetWarp", &up).unwrap(); + assert!(worst(&once, &anchors(&s, a)) < 1e-9); + s.execute("object.puppetWarp", &p).unwrap(); + assert!(worst(&rest, &anchors(&s, a)) < 0.01, "back to the original"); + // The query reads the pins back: where they started, where they are. + let now = pins(&mut s); + assert_eq!((now["pins"].clone(), now["auto"].clone()), (p["pins"].clone(), json!(false))); + // A pin off the mesh is refused. + let mut off = p.clone(); + off["pins"][0] = json!([900, 900]); + let e = s.execute("object.puppetWarp", &off).unwrap_err().to_string(); + assert!(e.contains("off the artwork"), "{e}"); +} + +#[test] +fn pins_follow_undo_and_redo() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + let rest = anchors(&s, a); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + // Adding a pin is one undo step; dragging it is another. + click(&mut s, 250.0, 150.0); + let added = pins(&mut s); + assert_eq!(added["pins"].as_array().unwrap().len(), 4); + drag(&mut s, (250.0, 150.0), (250.0, 110.0)); + let warped = anchors(&s, a); + let moved = pins(&mut s); + assert_eq!(moved["moved"][3], json!([250.0, 110.0])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pins(&mut s), added, "the pin went back with the art"); + assert!(worst(&rest, &anchors(&s, a)) < 1e-9); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pins(&mut s)["auto"], json!(true), "no pin placed"); + assert_eq!(pin_overlays(&mut s), 3); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(pins(&mut s), moved); + assert!(worst(&warped, &anchors(&s, a)) < 1e-9); + // Pins persist across tool switches while the selection stays. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + assert_eq!(pins(&mut s), moved); + assert_eq!(pin_overlays(&mut s), 4); +} + +#[test] +fn a_new_selection_clears_the_pins() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + let b = rect(&mut s, 100.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + click(&mut s, 250.0, 150.0); + drag(&mut s, (250.0, 150.0), (250.0, 120.0)); + let warped = anchors(&s, a); + assert_eq!(pins(&mut s)["auto"], json!(false)); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let dirty = s.doc().unwrap().is_dirty(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + // Fresh automatic pins on the art as it is now (the warp stays). + let p = pins(&mut s); + assert_eq!(p["auto"], json!(true)); + assert!(worst(&warped, &anchors(&s, a)) < 1e-9); + assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().is_dirty()), (undo, dirty), "not an edit"); + // Something else editing the art keeps the pins, starting again from the art as it is. + click(&mut s, 200.0, 140.0); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + let p2 = pins(&mut s); + assert_eq!(p2["auto"], json!(false)); + assert_eq!(p2["pins"], p2["moved"]); +} + +#[test] +fn puppet_warp_works_on_groups_compound_paths_and_open_strokes() { + for kind in ["group", "compound", "open"] { + let mut s = session(); + let id = match kind { + "open" => { + let r = s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 400, "y2": 200})).unwrap(); + s.execute("stroke.set", &json!({"weight": 8})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + } + _ => { + let a = rect(&mut s, 100.0, 170.0, 140.0, 60.0); + let b = rect(&mut s, 260.0, 170.0, 140.0, 60.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cmd = if kind == "group" { "object.group" } else { "object.compoundPath.make" }; + let r = s.execute(cmd, &json!({})).unwrap(); + r["id"].as_u64().map(NodeId).unwrap_or_else(|| s.doc().unwrap().selection.objects[0]) + } + }; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let rest = anchors(&s, id); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + // Pin both ends, lift the right one with the tool, then put it back. + click(&mut s, 104.0, 200.0); + click(&mut s, 396.0, 200.0); + drag(&mut s, (396.0, 200.0), (396.0, 140.0)); + assert!(worst(&rest, &anchors(&s, id)) > 20.0, "{kind}: warped"); + let n = s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len); + drag(&mut s, (396.0, 140.0), (396.0, 200.0)); + assert!(worst(&rest, &anchors(&s, id)) < 0.05, "{kind}: back to the original"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len), n, "{kind}: the structure stays"); + } +} + +#[test] +fn pins_are_never_saved() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 300.0, 60.0); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + click(&mut s, 250.0, 150.0); + assert!(s.doc().unwrap().doc.puppet.is_some()); + let r = s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap(); + let data = r["dataBase64"].as_str().unwrap().to_string(); + s.execute("document.open", &json!({"name": "pins.vectorcraft", "dataBase64": data})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.puppet.is_none()); + assert_eq!(st.doc.node_count(), 2, "layer + rectangle"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 9effcfe4..6ffc2722 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -279,6 +279,10 @@ fn structured_junk() { ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.removeAnchor", json!({"anchor": -1})), + ("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})), + ("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})), + ("object.puppetWarp", json!({"rest": true, "ids": [u64::MAX], "pins": [[0, 0]], "moved": [[0, 0]]})), + ("object.puppetWarp.pins", json!({"ids": [0, 1, 2], "expand": 1e308})), ]; let mut failures = vec![]; for fx in Fixture::ALL { diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index 0eb1d57c..dc0391bb 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -26,7 +26,7 @@ use crate::Tool; pub use liquify::LiquifyTool; pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool}; -pub use puppet::{PuppetWarpTool, mesh_for, mesh_input}; +pub use puppet::{PinSet, PuppetWarpTool, mesh_for, mesh_input, rest_and_pins, warp_from_rest}; pub use width::WidthTool; /// Create a distortion tool by id (None = not one of ours). diff --git a/crates/tools/src/distort/arap.rs b/crates/tools/src/distort/arap.rs index 0bb93ad8..10c35554 100644 --- a/crates/tools/src/distort/arap.rs +++ b/crates/tools/src/distort/arap.rs @@ -132,7 +132,8 @@ impl Mesh { /// Build a mesh covering `bounds` restricted to the region of `outlines` (filled interiors of /// closed subpaths plus a band around every path). With no outlines, the whole bounds. pub fn build(outlines: &[BezPath], bounds: Rect, opt: MeshOptions) -> Mesh { - let side = bounds.width().max(bounds.height()).max(1e-3); + // The cells cover the expanded art, so tiny art with a wide Expand stays a small grid. + let side = (bounds.width().max(bounds.height()) + 2.0 * opt.expand.max(0.0)).max(1e-3); let cell = (side / opt.resolution.max(1) as f64).max(1e-3); let pad = opt.expand.max(0.0) + cell * 0.01; let b = bounds.inflate(pad, pad); @@ -267,6 +268,99 @@ impl Mesh { let [a, b, c] = self.tris[t].map(|i| pos[i]); (b - a).cross(c - a) / 2.0 } + + /// Is `p` (rest space) on the mesh? + pub fn contains(&self, p: Point) -> bool { + self.cell_at(p).is_some_and(|k| self.cells.get(k).is_some_and(Option::is_some)) + } + + /// The grid cell under `p`, if inside the grid. + fn cell_at(&self, p: Point) -> Option { + let fi = ((p.x - self.origin.x) / self.cell).floor(); + let fj = ((p.y - self.origin.y) / self.cell).floor(); + (fi >= 0.0 && fj >= 0.0 && fi < self.nx as f64 && fj < self.ny as f64).then(|| fj as usize * self.nx + fi as usize) + } + + /// The rest-space point that the `deformed` mesh shows at `q` (None: `q` is off the mesh). + /// Where the warp folds the mesh over itself, the triangle `q` is deepest inside wins. + pub fn unmap(&self, deformed: &[Point], q: Point) -> Option { + let mut best: Option<(f64, Point)> = None; + for t in &self.tris { + let [a, b, c] = t.map(|i| deformed.get(i).copied().unwrap_or(self.verts[i])); + let den = (b - a).cross(c - a); + if den.abs() < 1e-18 { + continue; + } + let l1 = (q - a).cross(c - a) / den; + let l2 = (b - a).cross(q - a) / den; + let w = [1.0 - l1 - l2, l1, l2]; + let depth = w.iter().copied().fold(f64::MAX, f64::min); + if depth >= -1e-9 && best.is_none_or(|(d, _)| depth > d) { + let [ra, rb, rc] = t.map(|i| self.verts[i].to_vec2()); + best = Some((depth, (ra * w[0] + rb * w[1] + rc * w[2]).to_point())); + } + } + best.map(|b| b.1) + } + + /// How far (radians) the `deformed` mesh turned the art at rest point `p`. + pub fn turn_at(&self, deformed: &[Point], p: Point) -> Option { + let (t, _) = self.locate(p)?; + let tri = self.tris.get(t)?; + let cur = [*deformed.get(tri[0])?, *deformed.get(tri[1])?, *deformed.get(tri[2])?]; + Some(fit_angle(&tri.map(|i| self.verts[i]), &cur)) + } + + /// Centre of grid cell `k`. + fn cell_centre(&self, k: usize) -> Point { + Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell) + } + + /// The cells pins go in: those inside the art (or every kept cell when none is). + fn pin_cells(&self) -> Vec { + let any_core = self.core.iter().any(|c| *c); + self.cells.iter().zip(&self.core).map(|(c, core)| c.is_some() && (*core || !any_core)).collect() + } + + /// The 8 neighbours of cell `k` inside the grid, with the step length (in cells). + fn neighbours(&self, k: usize) -> impl Iterator + use<> { + let (nx, ny) = (self.nx as i64, self.ny as i64); + let (i, j) = ((k % self.nx) as i64, (k / self.nx) as i64); + (-1i64..=1).flat_map(move |dj| (-1i64..=1).map(move |di| (di, dj))).filter_map(move |(di, dj)| { + let (a, b) = (i + di, j + dj); + let step = if di != 0 && dj != 0 { std::f64::consts::SQRT_2 } else { 1.0 }; + ((di, dj) != (0, 0) && a >= 0 && b >= 0 && a < nx && b < ny).then_some(((b * nx + a) as usize, step)) + }) + } + + /// Distances (in cells, through the cells where `inside`) from `sources` (cells with their + /// start distances) to every cell; cells out of reach stay at infinity. + fn geodesic(&self, inside: &[bool], sources: &[(usize, f64)]) -> Vec { + let mut dist = vec![f64::INFINITY; self.cells.len()]; + for &(k, d) in sources { + if let Some(v) = dist.get_mut(k) { + *v = v.min(d); + } + } + let inside = |k: usize| inside.get(k).copied().unwrap_or(false); + // Relax until nothing improves (the grid is small: a few dozen cells a side). + let mut changed = true; + while changed { + changed = false; + for k in 0..dist.len() { + if !inside(k) || !dist[k].is_finite() { + continue; + } + for (m, step) in self.neighbours(k) { + if inside(m) && dist[k] + step < dist[m] - 1e-12 { + dist[m] = dist[k] + step; + changed = true; + } + } + } + } + dist + } } /// Winding number of the closed subpaths of `o` at `p` (open subpaths ignored). @@ -401,16 +495,8 @@ pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec { for tri in &mesh.tris { let rest = tri.map(|i| mesh.verts[i]); let cur = tri.map(|i| step1[i]); - let rc = centroid(&rest); - let cc = centroid(&cur); - let (mut sc, mut ss) = (0.0, 0.0); - for k in 0..3 { - let (r, c) = (rest[k] - rc, cur[k] - cc); - sc += r.dot(c); - ss += r.cross(c); - } - let th = ss.atan2(sc); - let (s, co) = th.sin_cos(); + let (rc, cc) = (centroid(&rest), centroid(&cur)); + let (s, co) = fit_angle(&rest, &cur).sin_cos(); let fit = rest.map(|r| { let d = r - rc; cc + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) @@ -455,24 +541,88 @@ fn centroid(p: &[Point; 3]) -> Point { Point::new((p[0].x + p[1].x + p[2].x) / 3.0, (p[0].y + p[1].y + p[2].y) / 3.0) } +/// The rotation (radians) that best fits triangle `rest` onto `cur` (least squares, about their +/// centroids). +fn fit_angle(rest: &[Point; 3], cur: &[Point; 3]) -> f64 { + let (rc, cc) = (centroid(rest), centroid(cur)); + let (mut sc, mut ss) = (0.0, 0.0); + for k in 0..3 { + let (r, c) = (rest[k] - rc, cur[k] - cc); + sc += r.dot(c); + ss += r.cross(c); + } + ss.atan2(sc) +} + /// Pick `count` well-spread pin positions inside the mesh (farthest-point sampling over the /// kept cell centres, starting from the one nearest the centroid). pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec { - let centres: Vec = mesh - .cells - .iter() - .enumerate() - .filter(|(k, c)| c.is_some() && (mesh.core[*k] || !mesh.core.iter().any(|x| *x))) - .map(|(k, _)| Point::new(mesh.origin.x + ((k % mesh.nx) as f64 + 0.5) * mesh.cell, mesh.origin.y + ((k / mesh.nx) as f64 + 0.5) * mesh.cell)) + let mut out: Vec = centre_of(mesh).map(|c| c.1).into_iter().collect(); + spread_pins(mesh, &mut out, count); + out +} + +/// The centre of the cells pins go in: the cell nearest their centroid, and the centroid itself +/// (that cell's centre when the centroid falls off the art, as in a ring). +fn centre_of(mesh: &Mesh) -> Option<(usize, Point)> { + let cells: Vec = mesh.pin_cells().iter().enumerate().filter(|(_, c)| **c).map(|(k, _)| k).collect(); + let c = (cells.iter().fold(Vec2::ZERO, |a, k| a + mesh.cell_centre(*k).to_vec2()) / cells.len().max(1) as f64).to_point(); + let k = cells.into_iter().min_by(|a, b| mesh.cell_centre(*a).distance_squared(c).total_cmp(&mesh.cell_centre(*b).distance_squared(c)))?; + Some((k, if mesh.contains(c) { c } else { mesh.cell_centre(k) })) +} + +/// The most pins placed automatically. +const MAX_AUTO_PINS: usize = 12; + +/// The pins the Puppet Warp tool places by itself: one at the centre of the art and one at the +/// end of each limb (a part reaching well beyond the art's thickness, like the arms of a star or +/// the ends of a long bar); at least three in all where the art has room. +pub fn default_pins(mesh: &Mesh) -> Vec { + let Some((centre, middle)) = centre_of(mesh) else { return vec![] }; + let inside = mesh.pin_cells(); + // Thickness: the largest distance from inside the art to its edge. + let edge: Vec<(usize, f64)> = (0..inside.len()) + .filter(|k| inside[*k] && (mesh.neighbours(*k).count() < 8 || mesh.neighbours(*k).any(|(m, _)| !inside[m]))) + .map(|k| (k, 0.5)) .collect(); - if centres.is_empty() { - return vec![]; + let radius = mesh.geodesic(&inside, &edge).into_iter().filter(|d| d.is_finite()).fold(0.0, f64::max); + // Limb ends: the cells farthest from the centre (through the art), well beyond the thickness. + let from_centre = mesh.geodesic(&inside, &[(centre, 0.0)]); + let mut ends: Vec = (0..inside.len()) + .filter(|k| { + let d = from_centre[*k]; + inside[*k] && d.is_finite() && d >= 1.5 * radius && mesh.neighbours(*k).all(|(m, _)| !inside[m] || from_centre[m] <= d) + }) + .collect(); + ends.sort_by(|a, b| from_centre[*b].total_cmp(&from_centre[*a])); + // One pin per limb: ends closer than about the art's width belong to the same limb. + let apart = 2.2 * radius * mesh.cell; + let mut out = vec![middle]; + let mut tips: Vec = vec![]; + for k in ends { + let p = mesh.cell_centre(k); + if out.len() >= MAX_AUTO_PINS || tips.iter().any(|q| q.distance(p) < apart) { + continue; + } + tips.push(p); + // The pin goes in the middle of the limb's end: the centroid of the cells about as far out + // near its tip (the tip itself if that falls off the art). + let cap: Vec = (0..inside.len()) + .filter(|m| inside[*m] && from_centre[*m] >= from_centre[k] - radius) + .map(|m| mesh.cell_centre(m)) + .filter(|q| q.distance(p) < apart) + .collect(); + let mid = (cap.iter().fold(Vec2::ZERO, |a, q| a + q.to_vec2()) / cap.len().max(1) as f64).to_point(); + out.push(if mesh.contains(mid) { mid } else { p }); } - let c = centres.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / centres.len() as f64; - let Some(&first) = centres.iter().min_by(|a, b| a.distance_squared(c.to_point()).total_cmp(&b.distance_squared(c.to_point()))) else { - return vec![]; - }; - let mut out = vec![first]; + spread_pins(mesh, &mut out, 3); + out +} + +/// Add pins to `out` until there are `count`, each as far as possible from the others +/// (farthest-point sampling over the cells pins go in). +fn spread_pins(mesh: &Mesh, out: &mut Vec, count: usize) { + let centres: Vec = mesh.pin_cells().iter().enumerate().filter(|(_, c)| **c).map(|(k, _)| mesh.cell_centre(k)).collect(); while out.len() < count.min(centres.len()) { let Some(&next) = centres.iter().max_by(|a, b| { let da = out.iter().map(|o| o.distance_squared(**a)).fold(f64::MAX, f64::min); @@ -483,7 +633,6 @@ pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec { }; out.push(next); } - out } #[cfg(test)] @@ -623,6 +772,46 @@ mod tests { assert!(m.map(&d, Point::new(190.0, 80.0)).distance(Point::new(190.0, 80.0)) < 5.0); } + #[test] + fn default_pins_go_at_the_centre_and_the_end_of_each_limb() { + // A long bar: the centre and both ends. + let m = rect_mesh(); + let p = default_pins(&m); + assert_eq!(p.len(), 3, "{p:?}"); + assert!(p[0].distance(Point::new(100.0, 50.0)) < 10.0, "{p:?}"); + let mut xs: Vec = p[1..].iter().map(|q| q.x).collect(); + xs.sort_by(f64::total_cmp); + assert!(xs[0] < 25.0 && xs[1] > 175.0, "{p:?}"); + // A five-pointed star: the centre and its five tips. + let star = vectorcraft_geom::shapes::star(Point::new(100.0, 100.0), 100.0, 35.0, 5, 0.0).to_bezpath(); + let sm = Mesh::build(std::slice::from_ref(&star), star.bounding_box(), MeshOptions { resolution: 30, expand: 0.0 }); + let p = default_pins(&sm); + assert_eq!(p.len(), 6, "{p:?}"); + assert!(p[1..].iter().all(|q| q.distance(Point::new(100.0, 100.0)) > 55.0), "{p:?}"); + // A disc has no limbs: the centre, topped up to three. + let disc = vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0).to_path(0.1); + let dm = Mesh::build(std::slice::from_ref(&disc), disc.bounding_box(), MeshOptions::default()); + assert_eq!(default_pins(&dm).len(), 3); + // A point with a wide Expand stays a small grid. + let dot = Mesh::build(&[], Rect::new(5.0, 5.0, 5.0, 5.0), MeshOptions { expand: 1000.0, ..Default::default() }); + assert!(dot.verts.len() < 30 * 30 && default_pins(&dot).len() == 3, "{}", dot.verts.len()); + } + + #[test] + fn unmap_and_turn_read_a_warped_mesh_back() { + let m = rect_mesh(); + let rot = |p: Point| { + let (s, c) = 0.3f64.sin_cos(); + Point::new(p.x * c - p.y * s + 20.0, p.x * s + p.y * c) + }; + let d: Vec = m.verts.iter().map(|v| rot(*v)).collect(); + let p = Point::new(60.0, 40.0); + assert!(m.unmap(&d, rot(p)).unwrap().distance(p) < 1e-6); + assert!(m.unmap(&d, Point::new(-500.0, 0.0)).is_none(), "off the mesh"); + assert!((m.turn_at(&d, p).unwrap() - 0.3).abs() < 1e-9); + assert!(m.contains(p) && !m.contains(Point::new(300.0, 50.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 3d804892..648621ba 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -1,31 +1,42 @@ //! Puppet Warp tool. //! -//! 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. +//! With artwork selected the tool shows pins at once: the ones already placed on it, else +//! automatic pins at the centre and the end of each limb ([`default_pins`]). 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; Delete/Backspace remove the selected pins. +//! A click off the mesh places nothing and says so. +//! +//! The pins live in the document ([`PuppetPins`]) with the rest shape they started from, so every +//! warp starts from that shape (dragging a pin back restores the original, warps don't stack) and +//! Undo/Redo take the pins back with the art. They stay while the same artwork is selected; a new +//! selection starts afresh. Every change runs `object.puppetWarp {rest: true, ids, pins, moved, +//! angles, expand}` (pins = rest positions, moved = where they are now): adding or deleting pins +//! at once, drags as a preview committed on release, each one undo step. //! //! Control bar (`tool.setOption`): `showMesh` (on by default), `expand` (Expand Mesh, points, 0 //! allowed) and `selectAllPins`. +use std::sync::{Arc, Mutex}; + use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, Node, NodeId, PuppetPin, PuppetPins}; use vectorcraft_geom::{BezPath, Point, Rect}; -use super::arap::{Mesh, MeshOptions, auto_pins}; -use super::{BLUE, collect_points}; +use super::arap::{self, Mesh, MeshOptions, default_pins}; +use super::{BLUE, collect_points, warp_node_with}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// Outlines and bounds of the given objects (what the mesh is built over). pub fn mesh_input(doc: &Document, ids: &[NodeId]) -> Option<(Vec, Rect)> { + let nodes: Vec<&Node> = ids.iter().map(|id| doc.node(*id)).collect::>()?; + outlines_of(&nodes) +} + +fn outlines_of(nodes: &[&Node]) -> Option<(Vec, Rect)> { let mut outlines = vec![]; let mut pts = vec![]; - for id in ids { - let n = doc.node(*id)?; + for n in nodes { collect_points(n, &mut pts); n.walk(&mut |c| { if let Some(p) = c.path_data() { @@ -44,108 +55,264 @@ pub fn mesh_for(doc: &Document, ids: &[NodeId], expand: f64) -> Option { Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() })) } +/// The mesh over `nodes` (a rest shape). +fn mesh_of(nodes: &[Arc], expand: f64) -> Option { + let refs: Vec<&Node> = nodes.iter().map(|n| n.as_ref()).collect(); + let (o, b) = outlines_of(&refs)?; + Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() })) +} + +/// The solver's view of the pins. +fn solver_pins(pins: &[PuppetPin]) -> Vec { + pins.iter().map(|q| arap::Pin { angle: q.angle, ..arap::Pin::new(q.rest, q.at) }).collect() +} + +/// Do the pins leave the art as it is (each where it started, turned by nothing)? +fn at_rest(pins: &[PuppetPin]) -> bool { + pins.iter().all(|q| q.rest == q.at && q.angle.is_none_or(|a| a == 0.0)) +} + +/// A rest shape and the pins placed on it (`None`: none yet). +pub type RestPins = (Vec>, Option>); + +/// The rest shape of `ids` and the pins placed on it: the document's pins when they belong to +/// these objects as the last warp left them; when something else edited them since, the same pins +/// starting again from the objects as they are; `None` pins: none placed yet. +pub fn rest_and_pins(doc: &Document, ids: &[NodeId]) -> Option { + let current: Vec> = ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::>()?; + Some(match doc.puppet.as_deref().filter(|p| p.ids == ids) { + Some(p) if p.holds(doc, ids) => (p.rest.clone(), Some(p.pins.clone())), + Some(p) => (current, Some(p.pins.iter().map(|q| PuppetPin { rest: q.at, at: q.at, angle: q.angle.map(|_| 0.0) }).collect())), + None => (current, None), + }) +} + +/// The Puppet Warp pins in effect on some objects, with the mesh they warp. +pub struct PinSet { + pub ids: Vec, + pub pins: Vec, + /// No pin was placed yet: these are the automatic ones. + pub auto: bool, + pub mesh: Mesh, + /// The mesh vertices as the pins warp them. + pub deformed: Vec, + /// The pins warp the art (else `deformed` is the rest mesh). + warped: bool, +} + +impl PinSet { + /// The pins on `ids` (see [`rest_and_pins`]) with the mesh built with Expand `expand`. + pub fn of(doc: &Document, ids: &[NodeId], expand: f64) -> Option { + let (rest, pins) = rest_and_pins(doc, ids)?; + let mesh = mesh_of(&rest, expand)?; + let auto = pins.is_none(); + let pins = pins.unwrap_or_else(|| default_pins(&mesh).into_iter().map(|p| PuppetPin { rest: p, at: p, angle: None }).collect()); + let warped = !at_rest(&pins); + let deformed = if warped { arap::deform(&mesh, &solver_pins(&pins)) } else { mesh.verts.clone() }; + Some(Self { ids: ids.to_vec(), pins, auto, mesh, deformed, warped }) + } + /// Where on the rest shape the art now at `q` comes from (None: off the mesh). + pub fn rest_of(&self, q: Point) -> Option { + if self.warped { self.mesh.unmap(&self.deformed, q) } else { self.mesh.contains(q).then_some(q) } + } + /// The turn (radians) the art has around pin `i`: the one it holds, else the warp's there. + pub fn turn_of(&self, i: usize) -> f64 { + self.pins.get(i).map_or(0.0, |q| q.angle.or_else(|| self.mesh.turn_at(&self.deformed, q.rest)).unwrap_or(0.0)) + } + /// The `object.puppetWarp` params that set `pins` on these objects. + pub fn params(&self, pins: &[PuppetPin], expand: f64) -> Value { + json!({ + "rest": true, + "ids": crate::json_ids(&self.ids), + "pins": pins.iter().map(|q| json!([q.rest.x, q.rest.y])).collect::>(), + "moved": pins.iter().map(|q| json!([q.at.x, q.at.y])).collect::>(), + "angles": pins.iter().map(|q| q.angle.map(f64::to_degrees)).collect::>(), + "expand": expand, + }) + } + /// The warped mesh as one path (for the overlay). + fn mesh_path(&self) -> BezPath { + let mut bp = BezPath::new(); + for t in &self.mesh.tris { + let [a, b, c] = t.map(|i| self.deformed.get(i).copied().unwrap_or(self.mesh.verts[i])); + bp.move_to(a); + bp.line_to(b); + bp.line_to(c); + bp.close_path(); + } + bp + } +} + +/// Put `pins` on `ids`: warp their rest shape (see [`rest_and_pins`]) so each pin lands where it +/// is now, and keep the pins in the document. With no pins the art stays as it is and the pins +/// start again from it. +pub fn warp_from_rest(doc: &mut Document, ids: &[NodeId], pins: Vec, expand: f64) -> Result<(), String> { + let (rest, _) = rest_and_pins(doc, ids).ok_or("nothing to warp")?; + let rest = if pins.is_empty() { + ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::>>().ok_or("nothing to warp")? + } else { + let mesh = mesh_of(&rest, expand).ok_or("nothing to warp")?; + if let Some(i) = pins.iter().position(|q| !mesh.contains(q.rest)) { + return Err(format!("pin {i} is off the artwork (outside its mesh)")); + } + let deformed = arap::deform(&mesh, &solver_pins(&pins)); + let f = |q: Point| mesh.map(&deformed, q); + for (id, r) in ids.iter().zip(&rest) { + let mut n = Node::clone(r); + warp_node_with(&mut n, &f); + *doc.node_mut(*id).ok_or("the artwork is gone")? = n; + } + rest + }; + let result = ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::>>().ok_or("the artwork is gone")?; + doc.puppet = Some(Arc::new(PuppetPins { ids: ids.to_vec(), rest, result, pins })); + Ok(()) +} + /// 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, -} +/// The feedback for a click off the mesh. +const OFF_MESH: &str = "Pins go on the artwork"; +const WARN: [u8; 3] = [0xd9, 0x3f, 0x3f]; /// 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 }, + /// The art turns around pin `pin`: `base` (radians) is its turn when the drag began, `from` + /// the pointer's angle around it then, `angle` how far the drag has turned it since. + Turn { pin: usize, base: f64, from: f64, angle: f64 }, +} + +/// What the cached pins were computed from (holding the objects keeps the pointers unique). +struct Key { + ids: Vec, + nodes: Vec>, + puppet: Option>, + expand: f64, +} + +impl Key { + fn of(cx: &ToolContext, expand: f64) -> Option { + let ids = cx.selection.objects.clone(); + let nodes = ids.iter().map(|id| cx.doc.node_arc(*id).cloned()).collect::>()?; + Some(Self { ids, nodes, puppet: cx.doc.puppet.clone(), expand }) + } + fn same(&self, o: &Key) -> bool { + let ptrs = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y)); + let puppet = match (&self.puppet, &o.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + }; + self.ids == o.ids && ptrs(&self.nodes, &o.nodes) && puppet && self.expand == o.expand + } +} + +struct Cached { + key: Key, + set: PinSet, + mesh: BezPath, } pub struct PuppetWarpTool { + /// The objects the selected pins belong to. ids: Vec, - pins: Vec, - /// Selected pins (indices into `pins`). + /// Selected pins (indices into the pins). selected: Vec, drag: Option, + /// The pins as the drag began (each preview starts from them). + start: Option>, /// The pointer and the modifiers held while hovering (for the turn ring). hover: Option<(Point, Mods)>, + /// Where a click off the mesh was (its feedback shows until the pointer moves on). + off_mesh: Option, /// Control bar: Show Mesh. show_mesh: bool, /// Control bar: Expand Mesh (points, 0 allowed). expand: f64, + /// The pins and mesh of the selection, recomputed when the art, its pins or Expand change + /// (overlays ask every frame). + cache: Mutex>>, } impl Default for PuppetWarpTool { fn default() -> Self { - Self { ids: vec![], pins: vec![], selected: vec![], drag: None, hover: None, show_mesh: true, expand: 3.0 } + Self { + ids: vec![], + selected: vec![], + drag: None, + start: None, + hover: None, + off_mesh: None, + show_mesh: true, + expand: 3.0, + cache: Mutex::new(None), + } } } impl PuppetWarpTool { - /// Re-seed pins when the selection changed. - fn sync(&mut self, cx: &ToolContext) { - let ids = cx.selection.objects.clone(); - if ids == self.ids { - return; + /// The pins on the selection (None: nothing selected). + fn pins(&self, cx: &ToolContext) -> Option> { + let key = Key::of(cx, self.expand).filter(|k| !k.ids.is_empty())?; + let mut cache = self.cache.lock().ok(); + if let Some(c) = cache.as_deref().and_then(Option::as_ref).filter(|c| c.key.same(&key)) { + return Some(c.clone()); } - self.ids = ids; - self.selected.clear(); - self.pins = if self.ids.is_empty() { - vec![] - } else { - 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() - }; + let set = PinSet::of(cx.doc, &key.ids, self.expand)?; + let c = Arc::new(Cached { mesh: set.mesh_path(), key, set }); + if let Some(slot) = cache.as_deref_mut() { + *slot = Some(c.clone()); + } + Some(c) } - fn pin_at(&self, cx: &ToolContext, p: Point) -> Option { + /// The last pins computed (for `options`, which has no document). + fn last_pins(&self) -> Option> { + self.cache.lock().ok().and_then(|c| c.clone()) + } + /// A new selection starts with no pin selected. + fn sync(&mut self, cx: &ToolContext) { + if cx.selection.objects != self.ids { + self.ids = cx.selection.objects.clone(); + self.selected.clear(); + } + } + fn pin_at(cx: &ToolContext, set: &PinSet, p: Point) -> Option { let tol = cx.tol(7.0); - 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) + set.pins.iter().enumerate().map(|(i, q)| (i, q.at.distance(p))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) } /// 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() { + fn turn_pin_at(&self, cx: &ToolContext, set: &PinSet, p: Point) -> Option { + if Self::pin_at(cx, set, 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_map(|&i| Some((i, set.pins.get(i)?.at.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() + /// The pins during gesture `g`. + fn targets(&self, set: &PinSet, g: Gesture) -> Vec { + let mut pins = set.pins.clone(); + for (i, q) in pins.iter_mut().enumerate() { + match g { + Gesture::Move { from, to } if self.selected.contains(&i) => q.at += to - from, + Gesture::Turn { pin, base, angle, .. } if pin == i => q.angle = Some(base + angle), + _ => {} + } + } + pins } - 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, - }) + /// Run `object.puppetWarp` with `pins`, then look at the pins it left. + fn apply(&self, set: &PinSet, pins: &[PuppetPin]) -> Vec { + vec![Action::Exec("object.puppetWarp".into(), set.params(pins, self.expand)), Action::Notify("pins".into())] } /// 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 { @@ -177,59 +344,74 @@ impl Tool for PuppetWarpTool { match ev.kind { PointerKind::Move => { self.hover = Some((p, ev.mods)); + if self.off_mesh.is_some_and(|q| q.distance(p) > cx.tol(6.0)) { + self.off_mesh = None; + } vec![] } PointerKind::Down => { self.sync(cx); - if self.ids.is_empty() { - // Click an object to select it; pins appear on the next event. + self.off_mesh = None; + let Some(c) = self.pins(cx) else { + // Click an object to select it: its pins appear at once. if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) { - return vec![Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]}))]; + return vec![ + Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]})), + Action::Notify("pins".into()), + ]; } return vec![]; - } + }; + let set = &c.set; if ev.mods.alt - && let Some(i) = self.turn_pin_at(cx, p) - && let Some(c) = self.pins.get(i).map(|q| q.p) + && let Some(i) = self.turn_pin_at(cx, set, p) + && let Some(q) = set.pins.get(i) { - self.drag = Some(Gesture::Turn { pin: i, from: (p - c).atan2(), angle: 0.0 }); + self.drag = Some(Gesture::Turn { pin: i, base: set.turn_of(i), from: (p - q.at).atan2(), angle: 0.0 }); + self.start = Some(c.clone()); return vec![Action::Begin("Puppet Warp".into())]; } - if let Some(i) = self.pin_at(cx, p) { + if let Some(i) = Self::pin_at(cx, set, p) { if !self.pick(i, ev.mods.shift) { return vec![]; } self.drag = Some(Gesture::Move { from: p, to: p }); + self.start = Some(c.clone()); 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 { - self.pins.push(PinState { p, held: false }); - self.pick(self.pins.len() - 1, ev.mods.shift); - } else if !ev.mods.shift { - self.selected.clear(); + match set.rest_of(p) { + Some(rest) => { + let mut pins = set.pins.clone(); + pins.push(PuppetPin { rest, at: p, angle: None }); + self.pick(pins.len() - 1, ev.mods.shift); + self.apply(set, &pins) + } + None => { + // Off the mesh: nothing to pin there (the feedback says so). + self.off_mesh = Some(p); + if !ev.mods.shift { + self.selected.clear(); + } + vec![] + } } - vec![] } PointerKind::Drag => { - let Some(g) = self.drag.as_mut() else { return vec![] }; - match g { + let (Some(mut g), Some(c)) = (self.drag, self.start.clone()) else { return vec![] }; + match &mut 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; + Gesture::Turn { pin, from, angle, .. } => { + let Some(q) = c.set.pins.get(*pin) else { return vec![] }; + *angle = (p - q.at).atan2() - *from; } } - let g = *g; - vec![Action::Preview("object.puppetWarp".into(), self.params(g))] + self.drag = Some(g); + vec![Action::Preview("object.puppetWarp".into(), c.set.params(&self.targets(&c.set, g), self.expand))] } PointerKind::Up => match self.drag.take() { - 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] + Some(_) => { + self.start = None; + vec![Action::Commit, Action::Notify("pins".into())] } None => vec![], }, @@ -242,68 +424,66 @@ impl Tool for PuppetWarpTool { matches!(key, ToolKey::Delete | ToolKey::Backspace) && self.drag.is_none() && cx.selection.objects == self.ids - && self.selected.iter().any(|i| *i < self.pins.len()) + && self.pins(cx).is_some_and(|c| self.selected.iter().any(|i| *i < c.set.pins.len())) } - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - 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(); + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if !self.claims_key(cx, key) { + return vec![]; } - vec![] + let Some(c) = self.pins(cx) else { return vec![] }; + let gone = std::mem::take(&mut self.selected); + let pins: Vec = c.set.pins.iter().enumerate().filter(|(i, _)| !gone.contains(i)).map(|(_, q)| *q).collect(); + self.apply(&c.set, &pins) } fn overlays(&self, cx: &ToolContext) -> Vec { let mut out = vec![]; - if self.show_mesh - && cx.selection.objects == self.ids - && let Some(m) = mesh_for(cx.doc, &self.ids, self.expand) - { - let mut bp = BezPath::new(); - for t in &m.tris { - bp.move_to(m.verts[t[0]]); - bp.line_to(m.verts[t[1]]); - bp.line_to(m.verts[t[2]]); - bp.close_path(); + if let Some(c) = self.pins(cx) { + if self.show_mesh { + out.push(Overlay::Path { path: c.mesh.clone(), color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false }); + } + let r = cx.tol(5.0); + for (i, q) in c.set.pins.iter().enumerate() { + let sel = self.selected.contains(&i) && cx.selection.objects == self.ids; + out.push(Overlay::Path { + path: super::ellipse_path(q.at, r, r, 0.0), + color: BLUE, + width: if sel { 3.0 } else { 1.5 }, + dashed: false, + }); + out.push(Overlay::Anchor { p: q.at, 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, &c.set, h), + _ => None, + }; + if let Some(q) = ring.and_then(|i| c.set.pins.get(i)) { + let rr = cx.tol(TURN_RING_PX); + out.push(Overlay::Path { path: super::ellipse_path(q.at, rr, rr, 0.0), color: BLUE, width: 1.0, dashed: true }); } - out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false }); } - let r = cx.tol(5.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 }); + if let Some(p) = self.off_mesh { + out.push(Overlay::Label { p, text: OFF_MESH.into(), color: WARN }); } 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(_) => Cursor::Move, - None => Cursor::PenAdd, + let Some(c) = self.pins(cx) else { return Cursor::Arrow }; + if mods.alt && self.turn_pin_at(cx, &c.set, p).is_some() { + Cursor::Rotate + } else if Self::pin_at(cx, &c.set, p).is_some() { + Cursor::Move + } else if c.set.rest_of(p).is_some() { + Cursor::PenAdd + } else { + // Off the mesh: no pin goes there. + Cursor::NotAllowed } } fn options(&self) -> Value { - 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, - }) + let pins = self.last_pins().map(|c| c.set.pins.iter().map(|q| json!([q.at.x, q.at.y])).collect::>()).unwrap_or_default(); + json!({"showMesh": self.show_mesh, "expand": self.expand, "pins": pins, "selected": self.selected}) } fn set_option(&mut self, key: &str, value: &Value) { match key { @@ -314,7 +494,10 @@ impl Tool for PuppetWarpTool { } } // Select All Pins (false: deselect them all). - "selectAllPins" => self.selected = if value.as_bool() == Some(false) { vec![] } else { (0..self.pins.len()).collect() }, + "selectAllPins" => { + let n = self.last_pins().map_or(0, |c| c.set.pins.len()); + self.selected = if value.as_bool() == Some(false) { vec![] } else { (0..n).collect() }; + } _ => {} } } @@ -323,12 +506,18 @@ impl Tool for PuppetWarpTool { } fn notify(&mut self, cx: &ToolContext, _what: &str) { self.sync(cx); + // Pick up the pins the command left (and drop selected pins that are gone). + let n = self.pins(cx).map_or(0, |c| c.set.pins.len()); + self.selected.retain(|i| *i < n); } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - self.ids.clear(); - self.pins.clear(); self.selected.clear(); self.hover = None; + self.off_mesh = None; + if let Ok(mut c) = self.cache.lock() { + *c = None; + } + self.start = None; if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } } } @@ -347,94 +536,171 @@ mod tests { Mods { shift: true, ..Default::default() } } + /// The tool on a document, running the `object.puppetWarp` actions it emits the way the + /// engine does (previews on the snapshot taken at Begin). + struct Rig { + doc: Document, + sel: Selection, + base: Option, + tool: PuppetWarpTool, + /// The params of the last command run. + last: Value, + } + + impl Rig { + fn new() -> Self { + let (doc, id) = doc_with_rect(); + Self { doc, sel: Selection { objects: vec![id], ..Default::default() }, base: None, tool: PuppetWarpTool::default(), last: Value::Null } + } + fn run(&mut self, p: &Value) { + let pts = |k: &str| -> Vec { + p[k].as_array().unwrap().iter().map(|q| Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() + }; + let angles = p["angles"].as_array().unwrap().iter().map(|a| a.as_f64().map(f64::to_radians)); + let pins = pts("pins").into_iter().zip(pts("moved")).zip(angles).map(|((rest, at), angle)| PuppetPin { rest, at, angle }).collect(); + let ids: Vec = p["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect(); + assert_eq!(p["rest"], json!(true)); + warp_from_rest(&mut self.doc, &ids, pins, p["expand"].as_f64().unwrap()).unwrap(); + self.last = p.clone(); + } + fn apply(&mut self, acts: Vec) -> Vec { + for a in &acts { + match a { + Action::Begin(_) => self.base = Some(self.doc.clone()), + Action::Preview(_, p) => { + self.doc = self.base.clone().unwrap(); + self.run(p); + } + Action::Commit => self.base = None, + Action::Exec(c, p) if c == "object.puppetWarp" => self.run(p), + Action::Notify(w) => { + let p = paint(); + self.tool.notify(&cx(&self.doc, &self.sel, &p), w); + } + _ => {} + } + } + acts + } + fn ev(&mut self, kind: PointerKind, x: f64, y: f64, mods: Mods) -> Vec { + let p = paint(); + let acts = self.tool.pointer(&cx(&self.doc, &self.sel, &p), &PointerEvent::new(kind, x, y).with_mods(mods)); + self.apply(acts) + } + fn click(&mut self, x: f64, y: f64, mods: Mods) -> Vec { + let mut a = self.ev(PointerKind::Down, x, y, mods); + a.extend(self.ev(PointerKind::Up, x, y, mods)); + a + } + fn drag(&mut self, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec { + let mut a = self.ev(PointerKind::Down, from.0, from.1, mods); + a.extend(self.ev(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, mods)); + a.extend(self.ev(PointerKind::Drag, to.0, to.1, mods)); + a.extend(self.ev(PointerKind::Up, to.0, to.1, mods)); + a + } + fn key(&mut self, k: ToolKey) -> Vec { + let p = paint(); + let acts = self.tool.key(&cx(&self.doc, &self.sel, &p), k, Mods::default()); + self.apply(acts) + } + fn claims(&self, k: ToolKey) -> bool { + let p = paint(); + self.tool.claims_key(&cx(&self.doc, &self.sel, &p), k) + } + fn overlays(&self) -> Vec { + let p = paint(); + self.tool.overlays(&cx(&self.doc, &self.sel, &p)) + } + fn cursor(&self, x: f64, y: f64, mods: Mods) -> Cursor { + let p = paint(); + self.tool.cursor(&cx(&self.doc, &self.sel, &p), Point::new(x, y), mods) + } + fn pins(&self) -> Vec { + self.doc.puppet.as_ref().map(|p| p.pins.clone()).unwrap_or_default() + } + fn bounds(&self) -> Rect { + self.doc.node(self.sel.objects[0]).unwrap().geometric_bounds().unwrap() + } + } + #[test] fn puppet_tool_autopins_adds_and_drags() { - 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(); - // Click inside the rect away from auto pins: adds a pin. - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)); - assert!(acts.is_empty()); - assert_eq!(t.pins.len(), 4, "3 auto pins + 1"); - t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 170.0)); + let mut r = Rig::new(); + // The automatic pins show as soon as there is art selected. + assert_eq!(r.overlays().iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count(), 3, "3 automatic pins"); + // Click inside the rect away from them: adds a pin (one command, the pins kept in the document). + let acts = r.click(130.0, 170.0, Mods::default()); + assert!(matches!(&acts[0], Action::Exec(c, _) if c == "object.puppetWarp"), "{acts:?}"); + assert_eq!(r.pins().len(), 4, "3 auto pins + 1"); + assert_eq!(r.tool.options()["selected"], json!([3])); // Drag the new pin. - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)), vec![Action::Begin("Puppet Warp".into())]); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 190.0)); - let Action::Preview(cmd, v) = &acts[0] else { panic!() }; + assert_eq!(r.ev(PointerKind::Down, 130.0, 170.0, Mods::default()), vec![Action::Begin("Puppet Warp".into())]); + let acts = r.ev(PointerKind::Drag, 120.0, 190.0, Mods::default()); + let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; assert_eq!(cmd, "object.puppetWarp"); 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 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"); + assert_eq!(r.ev(PointerKind::Up, 120.0, 190.0, Mods::default())[0], Action::Commit); + // Alt-click on a pin doesn't delete it (Alt turns the art around a pin): Delete does. + r.click(120.0, 190.0, alt()); + assert_eq!(r.pins().len(), 4); + assert!(r.claims(ToolKey::Delete)); + r.key(ToolKey::Delete); + assert_eq!(r.pins().len(), 3); + assert!(!r.claims(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); + let mut r = Rig::new(); // 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]); + r.click(110.0, 110.0, Mods::default()); + r.click(190.0, 110.0, shift()); + let (a, b) = (r.pins().len() - 2, r.pins().len() - 1); + assert_eq!(r.tool.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())); + r.ev(PointerKind::Down, 110.0, 110.0, Mods::default()); + let acts = r.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())); + r.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()); + assert!(r.ev(PointerKind::Down, 190.0, 100.0, shift()).is_empty()); + assert_eq!(r.tool.selected, vec![a]); + // Select All Pins, then delete them all: the art stays as it is. + let before = r.bounds(); + r.tool.set_option("selectAllPins", &json!(true)); + assert_eq!(r.tool.selected, (0..r.pins().len()).collect::>()); + r.key(ToolKey::Backspace); + assert!(r.pins().is_empty()); + assert_eq!(r.bounds(), before); } #[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; + let mut r = Rig::new(); + r.click(110.0, 150.0, Mods::default()); + let i = r.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"); + r.ev(PointerKind::Move, 125.0, 150.0, alt()); + assert_eq!(r.cursor(125.0, 150.0, alt()), Cursor::Rotate); + assert!(r.overlays().iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "dotted ring"); + assert_ne!(r.cursor(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())); + assert_eq!(r.ev(PointerKind::Down, 125.0, 150.0, alt()), vec![Action::Begin("Puppet Warp".into())]); + let acts = r.ev(PointerKind::Drag, 110.0, 165.0, 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())); + r.ev(PointerKind::Up, 110.0, 165.0, 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)); + let q = r.pins()[0].at; + r.drag((q.x, q.y), (q.x + 5.0, q.y), Mods::default()); + assert!((r.last["angles"][i].as_f64().unwrap() - 90.0).abs() < 1e-6, "{}", r.last); } #[test] @@ -447,4 +713,37 @@ mod tests { t.set_option("expand", &json!("x")); assert_eq!(t.options()["expand"], json!(0.0)); } + + #[test] + fn warps_start_from_the_rest_shape() { + let mut r = Rig::new(); + let rest = r.doc.node(r.sel.objects[0]).cloned().unwrap(); + r.click(190.0, 190.0, Mods::default()); + // Two drags out and back: the second starts from where the first left the pin. + r.drag((190.0, 190.0), (230.0, 230.0), Mods::default()); + assert!(r.bounds().x1 > 215.0, "{:?}", r.bounds()); + r.drag((230.0, 230.0), (190.0, 190.0), Mods::default()); + // Back where it started: the original shape, not a warp of a warp. + let back = r.doc.node(r.sel.objects[0]).unwrap(); + let (a, b) = (rest.path_data().unwrap(), back.path_data().unwrap()); + let worst = a.subpaths[0].anchors.iter().zip(&b.subpaths[0].anchors).map(|(x, y)| x.p.distance(y.p)).fold(0.0, f64::max); + assert!(worst < 0.01, "{worst}"); + } + + #[test] + fn a_click_off_the_mesh_places_no_pin_and_says_so() { + let mut r = Rig::new(); + r.click(130.0, 170.0, Mods::default()); + assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed); + assert!(r.click(400.0, 400.0, Mods::default()).is_empty()); + assert_eq!(r.pins().len(), 4); + assert!(r.tool.selected.is_empty(), "the click deselects the pins"); + assert!(r.overlays().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == OFF_MESH))); + r.ev(PointerKind::Move, 450.0, 450.0, Mods::default()); + assert!(!r.overlays().iter().any(|o| matches!(o, Overlay::Label { .. })), "gone once the pointer moves on"); + // The command refuses a pin off the mesh. + let ids = r.sel.objects.clone(); + let off = PuppetPin { rest: Point::new(400.0, 400.0), at: Point::new(400.0, 400.0), angle: None }; + assert!(warp_from_rest(&mut r.doc, &ids, vec![off], 3.0).unwrap_err().contains("off the artwork")); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0591dcb7..8ca5bfbd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -437,12 +437,17 @@ agent can call directly. Mirror & Cut's tool options (`tool.setOption`, shown in (`free` | `vertical` | `horizontal`; a constrained axis follows the pointer and a click places it) and `keep` (`left` | `right` | `top` | `bottom`); Alt on release keeps the other side. -Puppet Warp (`puppetWarp`): with art selected, `ui.pointer` clicks on the art add pins (each new pin is selected; -Shift-click adds a pin to the selection or takes it out), dragging a pin moves every selected pin and warps the art, -and Alt-dragging near (not on) a selected pin turns the art around it. `ui.key` Delete or Backspace removes the -selected pins. Each drag is one undo step running `object.puppetWarp`. Its tool options (`tool.setOption`, shown in -the Control bar) are `expand` (Expand Mesh, points, 0 allowed), `showMesh` (on by default) and `selectAllPins` -(`true` selects every pin, `false` none); `ui.inspect` → `toolOptions` lists `pins` and the `selected` ones. +Puppet Warp (`puppetWarp`): with art selected, the tool shows pins at once (automatic ones at the centre and the end +of each limb until pins are placed). `ui.pointer` clicks on the art add pins (each new pin is selected; Shift-click +adds a pin to the selection or takes it out), dragging a pin moves every selected pin and warps the art, and +Alt-dragging near (not on) a selected pin turns the art around it. A click off the mesh adds nothing: the cursor shows +not-allowed there and the canvas says so. `ui.key` Delete or Backspace removes the selected pins. Adding, deleting and +each drag are one undo step each, running `object.puppetWarp {rest: true, …}`; the pins live in the document, so +Undo/Redo take them back with the art, they stay across tool switches while the selection is unchanged, and another +selection starts afresh. `object.puppetWarp.pins` reads them. Its tool options (`tool.setOption`, shown in the +Control bar) are `expand` (Expand Mesh, points, 0 allowed), `showMesh` (on by default) and `selectAllPins` (`true` +selects every pin, `false` none); `ui.inspect` → `toolOptions` lists the `pins` the tool last showed and the +`selected` ones. Colour adjustment effects (Effect → Color Adjustments) open the `effect` dialog like the other effects (`effect.dialog {effect: "adjust.hueSaturation"}`): its fields are the effect's parameters (sliders for the amounts, diff --git a/docs/mcp.md b/docs/mcp.md index cd6035e2..8e17a611 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -879,6 +879,18 @@ on the art) lands on its `moved` point. `angles` (degrees, or null for a free pi the art takes around a pin, as Alt-dragging near a selected pin with the Puppet Warp tool does: one pin with `angles: [90]` turns the art a quarter turn around it. +The Puppet Warp tool's pins live in the document while the same art stays selected (never saved; Undo and Redo take +them back with the art). `object.puppetWarp.pins {ids?}` reads them: `pins` (where each sits on the shape the pins +started from), `moved` (where it is now), `angles`, and `auto: true` while none was placed (the tool's automatic pins: +the centre and the end of each limb). Edit `moved`, `angles` or the lists and pass the result back to +`object.puppetWarp`: with `rest: true` the warp always starts from that rest shape, so warps don't stack and putting +a pin back restores the original; every pin must be on the art's mesh, and empty lists remove the pins. + +```json +{"name":"run_command","arguments":{"command":"object.puppetWarp.pins","params":{}}} +{"name":"run_command","arguments":{"command":"object.puppetWarp","params":{"rest":true,"pins":[[250,150],[110,150],[390,150]],"moved":[[250,150],[110,150],[390,90]],"angles":[null,null,null]}}} +``` + ```json {"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.5,"left":4,"right":4}}} {"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.8,"left":12,"right":12}}}