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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-06 09:40:18 +07:00
parent 5a93e78ad8
commit 67b2213957
12 changed files with 489 additions and 74 deletions

View File

@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
}
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<arap::Pin> = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect();
let angles: Vec<Option<f64>> = 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<arap::Pin> = 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, _| {

View File

@ -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;

View File

@ -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:?}");
}

View File

@ -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 {

View File

@ -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<f64>,
}
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<Item = (Point, Point)> + 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<Point> {
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<Pin> =
[(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<Pin> = [(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<Pin> =
[(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();

View File

@ -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<Mesh> {
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<NodeId>,
pins: Vec<Point>,
selected: Option<usize>,
drag: Option<(usize, Point)>,
pins: Vec<PinState>,
/// Selected pins (indices into `pins`).
selected: Vec<usize>,
drag: Option<Gesture>,
/// 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<usize> {
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<usize> {
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<f64>)> {
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<Item = Point>| 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::<Vec<_>>(),
"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<Action> {
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<Action> {
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<Point> = 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::<Vec<_>>()})
json!({
"showMesh": self.show_mesh,
"expand": self.expand,
"pins": self.pins.iter().map(|q| json!([q.p.x, q.p.y])).collect::<Vec<_>>(),
"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<Action> {
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::<Vec<_>>());
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));
}
}

View File

@ -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,
})
}

View File

@ -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;

View File

@ -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}");
}

View File

@ -217,8 +217,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
}
}
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps (set
/// through `tool.setOption`).
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps, Puppet
/// Warp's mesh and pins (set through `tool.setOption`).
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
/// (value, label) of each choice.
type Choices = &'static [(&'static str, &'static str)];
@ -226,6 +226,9 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
("axis", "Axis:", &[("free", "Free"), ("vertical", "Vertical"), ("horizontal", "Horizontal")]),
("keep", "Keep:", &[("left", "Left"), ("right", "Right"), ("top", "Top"), ("bottom", "Bottom")]),
];
if app.session.tool_id() == "puppetWarp" {
return puppet_warp_options(app, ui);
}
if app.session.tool_id() != "mirrorCut" {
return;
}
@ -357,6 +360,26 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
let _ = theme::semibold;
}
/// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh
/// and Select All Pins.
fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let opts = app.session.tool_options();
ui.label(egui::RichText::new("Expand:").size(12.0).color(t.text));
let unit = app.session.general_unit();
if let Some(v) = widgets::num_field(ui, ("cb-tool", "expand"), opts["expand"].as_f64(), unit, 64.0) {
app.run("tool.setOption", json!({ "key": "expand", "value": v })).ok();
}
let show = opts["showMesh"].as_bool().unwrap_or(true);
if widgets::check(ui, "Show Mesh", show, true) {
app.run("tool.setOption", json!({ "key": "showMesh", "value": !show })).ok();
}
if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() {
app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok();
}
ui.separator();
}
#[cfg(test)]
pub(crate) mod tests {
use egui::{Event, PointerButton, Pos2};

View File

@ -437,6 +437,13 @@ 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.
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,
`channel` for Curves and Levels, `points` for Curves, `color` for Shift to Color) and they preview live; set them with

View File

@ -874,6 +874,11 @@ With the Width tool, `press_key` Delete or Backspace removes the selected width
selected the key clears the selected objects as usual (`edit.clear`). The Puppet Warp tool's selected pins go the
same way.
`object.puppetWarp {ids?, pins, moved, angles?, expand?}` warps art as rigidly as possible so that each pin (a point
on the art) lands on its `moved` point. `angles` (degrees, or null for a free pin; as long as `pins`) holds the turn
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.
```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}}}