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