Merge pull request #185 from storytold/m8-puppet-pins
M8.5: Puppet Warp pins rotate, multi-select and Select All; Control bar and hint
This commit is contained in:
commit
4950cc17cf
|
|
@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted) | 8–12 |
|
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar) | 8–12 |
|
||||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Puppet Warp",
|
"Puppet Warp",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_doc,
|
||||||
puppet_warp
|
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 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 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 deformed = arap::deform(&mesh, &pins);
|
||||||
let f = |q: Point| mesh.map(&deformed, q);
|
let f = |q: Point| mesh.map(&deformed, q);
|
||||||
s.edit("Puppet Warp", |d, _| {
|
s.edit("Puppet Warp", |d, _| {
|
||||||
|
|
|
||||||
|
|
@ -1272,6 +1272,8 @@ mod tests_printtiling;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_proxyitems;
|
mod tests_proxyitems;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_puppetwarp;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_rastersettings;
|
mod tests_rastersettings;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_recolor;
|
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})),
|
("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})),
|
||||||
("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})),
|
("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})),
|
||||||
("command.batch", json!({"commands": "nope"})),
|
("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![];
|
let mut failures = vec![];
|
||||||
for fx in Fixture::ALL {
|
for fx in Fixture::ALL {
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,26 @@ const REG: f64 = 1.0e-7;
|
||||||
pub struct Pin {
|
pub struct Pin {
|
||||||
pub rest: Point,
|
pub rest: Point,
|
||||||
pub target: 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
|
/// 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() {
|
if n == 0 || pins.is_empty() {
|
||||||
return mesh.verts.clone();
|
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();
|
let vb = mesh.vertex_band();
|
||||||
// ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...).
|
// ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...).
|
||||||
let mut g = BandMatrix::new(2 * n, 2 * vb + 1);
|
let mut g = BandMatrix::new(2 * n, 2 * vb + 1);
|
||||||
|
|
@ -528,7 +549,7 @@ mod tests {
|
||||||
fn pins_at_rest_leave_the_mesh_unchanged() {
|
fn pins_at_rest_leave_the_mesh_unchanged() {
|
||||||
let m = rect_mesh();
|
let m = rect_mesh();
|
||||||
let pins: Vec<Pin> =
|
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 d = deform(&m, &pins);
|
||||||
let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max);
|
let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max);
|
||||||
assert!(err < 1e-4, "{err}");
|
assert!(err < 1e-4, "{err}");
|
||||||
|
|
@ -541,10 +562,8 @@ mod tests {
|
||||||
let (s, c) = 0.5f64.sin_cos();
|
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)
|
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)]
|
let pins: Vec<Pin> =
|
||||||
.iter()
|
[(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();
|
||||||
.map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) })
|
|
||||||
.collect();
|
|
||||||
let d = deform(&m, &pins);
|
let d = deform(&m, &pins);
|
||||||
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
|
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
|
||||||
assert!(err < 1e-2, "{err}");
|
assert!(err < 1e-2, "{err}");
|
||||||
|
|
@ -555,9 +574,9 @@ mod tests {
|
||||||
let m = rect_mesh();
|
let m = rect_mesh();
|
||||||
// Hold the left end, lift the right end.
|
// Hold the left end, lift the right end.
|
||||||
let pins = vec![
|
let pins = vec![
|
||||||
Pin { rest: Point::new(10.0, 30.0), target: Point::new(10.0, 30.0) },
|
Pin::new(Point::new(10.0, 30.0), Point::new(10.0, 30.0)),
|
||||||
Pin { rest: Point::new(10.0, 70.0), target: Point::new(10.0, 70.0) },
|
Pin::new(Point::new(10.0, 70.0), 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(190.0, 50.0), Point::new(180.0, -30.0)),
|
||||||
];
|
];
|
||||||
let d = deform(&m, &pins);
|
let d = deform(&m, &pins);
|
||||||
for p in &pins {
|
for p in &pins {
|
||||||
|
|
@ -579,6 +598,31 @@ mod tests {
|
||||||
assert!(worst < 0.3, "edge stretch {worst}");
|
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]
|
#[test]
|
||||||
fn auto_pins_are_spread_inside_the_shape() {
|
fn auto_pins_are_spread_inside_the_shape() {
|
||||||
let m = rect_mesh();
|
let m = rect_mesh();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
//! Puppet Warp tool.
|
//! Puppet Warp tool.
|
||||||
//!
|
//!
|
||||||
//! With artwork selected the tool places three pins automatically (spread over the mesh). Click
|
//! With artwork selected the tool places three pins automatically (spread over the mesh). A click
|
||||||
//! on the artwork adds a pin, Alt-click deletes one, Delete/Backspace removes the selected pin,
|
//! on the artwork adds a pin and selects it; Shift-click adds pins to the selection (or takes them
|
||||||
//! and dragging a pin warps the art: every drag previews
|
//! out); dragging a pin moves every selected pin and warps the art; Alt-dragging near (not on) a
|
||||||
//! `object.puppetWarp {ids, pins, moved}` (pins = current pin positions, moved = the same list
|
//! selected pin turns the art around it, and that pin keeps the turn through later warps;
|
||||||
//! with the dragged pin at the pointer), committed on release as one undo step. The mesh follows
|
//! Delete/Backspace remove the selected pins. Every drag previews
|
||||||
//! the current shape, so successive drags compose.
|
//! `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 serde_json::{Value, json};
|
||||||
use vectorcraft_doc::{Document, NodeId};
|
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() }))
|
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 {
|
pub struct PuppetWarpTool {
|
||||||
ids: Vec<NodeId>,
|
ids: Vec<NodeId>,
|
||||||
pins: Vec<Point>,
|
pins: Vec<PinState>,
|
||||||
selected: Option<usize>,
|
/// Selected pins (indices into `pins`).
|
||||||
drag: Option<(usize, Point)>,
|
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.
|
/// Control bar: Show Mesh.
|
||||||
show_mesh: bool,
|
show_mesh: bool,
|
||||||
/// Control bar: Expand Mesh (points).
|
/// Control bar: Expand Mesh (points, 0 allowed).
|
||||||
expand: f64,
|
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 {
|
impl PuppetWarpTool {
|
||||||
/// Re-seed pins when the selection changed.
|
/// Re-seed pins when the selection changed.
|
||||||
fn sync(&mut self, cx: &ToolContext) {
|
fn sync(&mut self, cx: &ToolContext) {
|
||||||
|
|
@ -59,25 +94,77 @@ impl PuppetWarpTool {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.ids = ids;
|
self.ids = ids;
|
||||||
self.selected = None;
|
self.selected.clear();
|
||||||
self.pins = if self.ids.is_empty() {
|
self.pins = if self.ids.is_empty() {
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} 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> {
|
fn pin_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
|
||||||
let tol = cx.tol(7.0);
|
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 {
|
/// The selected pin the art would turn around with Alt held at `p`: near it, not on a pin.
|
||||||
let mut moved = self.pins.clone();
|
fn turn_pin_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
|
||||||
moved[i] = to;
|
if self.pin_at(cx, p).is_some() {
|
||||||
let pj = |v: &[Point]| Value::Array(v.iter().map(|p| json!([p.x, p.y])).collect());
|
return None;
|
||||||
json!({"ids": crate::json_ids(&self.ids), "pins": pj(&self.pins), "moved": pj(&moved), "expand": self.expand_or_default()})
|
}
|
||||||
|
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> {
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||||
let p = ev.pos;
|
let p = ev.pos;
|
||||||
match ev.kind {
|
match ev.kind {
|
||||||
|
PointerKind::Move => {
|
||||||
|
self.hover = Some((p, ev.mods));
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
PointerKind::Down => {
|
PointerKind::Down => {
|
||||||
self.sync(cx);
|
self.sync(cx);
|
||||||
if self.ids.is_empty() {
|
if self.ids.is_empty() {
|
||||||
|
|
@ -97,54 +188,66 @@ impl Tool for PuppetWarpTool {
|
||||||
}
|
}
|
||||||
return vec![];
|
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 let Some(i) = self.pin_at(cx, p) {
|
||||||
if ev.mods.alt {
|
if !self.pick(i, ev.mods.shift) {
|
||||||
self.pins.remove(i);
|
|
||||||
self.selected = None;
|
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
self.selected = Some(i);
|
self.drag = Some(Gesture::Move { from: p, to: p });
|
||||||
self.drag = Some((i, p));
|
|
||||||
return vec![Action::Begin("Puppet Warp".into())];
|
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));
|
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 {
|
if inside {
|
||||||
self.pins.push(p);
|
self.pins.push(PinState { p, held: false });
|
||||||
self.selected = Some(self.pins.len() - 1);
|
self.pick(self.pins.len() - 1, ev.mods.shift);
|
||||||
|
} else if !ev.mods.shift {
|
||||||
|
self.selected.clear();
|
||||||
}
|
}
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
PointerKind::Drag => match self.drag {
|
PointerKind::Drag => {
|
||||||
Some((i, _)) => {
|
let Some(g) = self.drag.as_mut() else { return vec![] };
|
||||||
self.drag = Some((i, p));
|
match g {
|
||||||
vec![Action::Preview("object.puppetWarp".into(), self.params(i, p))]
|
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() {
|
PointerKind::Up => match self.drag.take() {
|
||||||
Some((i, _)) => {
|
Some(g) => {
|
||||||
self.pins[i] = p;
|
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]
|
vec![Action::Commit]
|
||||||
}
|
}
|
||||||
None => vec![],
|
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).
|
/// (Clear deletes the selected objects).
|
||||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||||
matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
||||||
&& self.drag.is_none()
|
&& self.drag.is_none()
|
||||||
&& cx.selection.objects == self.ids
|
&& 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> {
|
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||||
if matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
if matches!(key, ToolKey::Delete | ToolKey::Backspace) && self.drag.is_none() {
|
||||||
&& let Some(i) = self.selected.take()
|
let gone = std::mem::take(&mut self.selected);
|
||||||
&& i < self.pins.len()
|
self.pins = std::mem::take(&mut self.pins).into_iter().enumerate().filter(|(i, _)| !gone.contains(i)).map(|(_, q)| q).collect();
|
||||||
{
|
|
||||||
self.pins.remove(i);
|
|
||||||
}
|
}
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +255,7 @@ impl Tool for PuppetWarpTool {
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
if self.show_mesh
|
if self.show_mesh
|
||||||
&& cx.selection.objects == self.ids
|
&& 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();
|
let mut bp = BezPath::new();
|
||||||
for t in &m.tris {
|
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 });
|
out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false });
|
||||||
}
|
}
|
||||||
let r = cx.tol(5.0);
|
let r = cx.tol(5.0);
|
||||||
for (i, q) in self.pins.iter().enumerate() {
|
let shown: Vec<Point> = match self.drag {
|
||||||
let q = match self.drag {
|
Some(g) => self.targets(g).into_iter().map(|t| t.0).collect(),
|
||||||
Some((d, to)) if d == i => to,
|
None => self.pins.iter().map(|q| q.p).collect(),
|
||||||
_ => *q,
|
};
|
||||||
};
|
for (i, q) in shown.iter().enumerate() {
|
||||||
let circle = super::ellipse_path(q, r, r, 0.0);
|
let sel = self.selected.contains(&i);
|
||||||
out.push(Overlay::Path { path: circle, color: BLUE, width: if self.selected == Some(i) { 3.0 } else { 1.5 }, dashed: false });
|
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: self.selected == Some(i), size: 3.0 });
|
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
|
out
|
||||||
}
|
}
|
||||||
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
|
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) {
|
match self.pin_at(cx, p) {
|
||||||
Some(_) if mods.alt => Cursor::PenDelete,
|
|
||||||
Some(_) => Cursor::Move,
|
Some(_) => Cursor::Move,
|
||||||
None => Cursor::PenAdd,
|
None => Cursor::PenAdd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn options(&self) -> Value {
|
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) {
|
fn set_option(&mut self, key: &str, value: &Value) {
|
||||||
match key {
|
match key {
|
||||||
"showMesh" => self.show_mesh = value.as_bool().unwrap_or(self.show_mesh),
|
"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),
|
"expand" => {
|
||||||
"selectAllPins" => self.selected = None,
|
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> {
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||||
self.ids.clear();
|
self.ids.clear();
|
||||||
self.pins.clear();
|
self.pins.clear();
|
||||||
|
self.selected.clear();
|
||||||
|
self.hover = None;
|
||||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +339,14 @@ mod tests {
|
||||||
use crate::testutil::*;
|
use crate::testutil::*;
|
||||||
use vectorcraft_doc::Selection;
|
use vectorcraft_doc::Selection;
|
||||||
|
|
||||||
|
fn alt() -> Mods {
|
||||||
|
Mods { alt: true, ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift() -> Mods {
|
||||||
|
Mods { shift: true, ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn puppet_tool_autopins_adds_and_drags() {
|
fn puppet_tool_autopins_adds_and_drags() {
|
||||||
let (d, id) = doc_with_rect();
|
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["moved"][3], json!([120.0, 190.0]));
|
||||||
assert_eq!(v["pins"][3], json!([130.0, 170.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]);
|
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0)), vec![Action::Commit]);
|
||||||
// Alt-click deletes it.
|
// 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(Mods { alt: true, ..Default::default() }));
|
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_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)],
|
"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)],
|
"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,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ mod tests_printps;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_printtiling;
|
mod tests_printtiling;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_puppetwarp;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_recolor;
|
mod tests_recolor;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_recovery;
|
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
|
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps, Puppet
|
||||||
/// through `tool.setOption`).
|
/// Warp's mesh and pins (set through `tool.setOption`).
|
||||||
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
/// (value, label) of each choice.
|
/// (value, label) of each choice.
|
||||||
type Choices = &'static [(&'static str, &'static str)];
|
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")]),
|
("axis", "Axis:", &[("free", "Free"), ("vertical", "Vertical"), ("horizontal", "Horizontal")]),
|
||||||
("keep", "Keep:", &[("left", "Left"), ("right", "Right"), ("top", "Top"), ("bottom", "Bottom")]),
|
("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" {
|
if app.session.tool_id() != "mirrorCut" {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -357,6 +360,26 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||||
let _ = theme::semibold;
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use egui::{Event, PointerButton, Pos2};
|
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`
|
(`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.
|
(`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
|
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,
|
(`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
|
`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
|
selected the key clears the selected objects as usual (`edit.clear`). The Puppet Warp tool's selected pins go the
|
||||||
same way.
|
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
|
```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.5,"left":4,"right":4}}}
|
||||||
{"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.8,"left":12,"right":12}}}
|
{"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.8,"left":12,"right":12}}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue