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:
parent
5a93e78ad8
commit
67b2213957
|
|
@ -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, _| {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue