diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 1ac4b860..2cae046b 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -7,9 +7,9 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId, NodeKind, PuppetPin, WidthProfile}; -use vectorcraft_geom::{Affine, Homography, Point}; -use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile}; +use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect}; +use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds}; use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, warp_from_rest, warp_node_with}; @@ -379,20 +379,179 @@ fn samples_of(p: &Value, key: &str) -> Option> { .collect() } -/// Leaf paths under `roots` that can be edited. -fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec { - let mut out = vec![]; - for r in roots { - if let Some(n) = d.node(*r) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Path { .. }) && !out.contains(&c.id) { - out.push(c.id); - } - }); +/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside +/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images, +/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds +/// and what it is. Hidden and locked objects, and guides, are neither. +#[derive(Default)] +struct Targets { + paths: Vec<(NodeId, Rect)>, + blocked: Vec<(NodeId, Rect, &'static str)>, + /// Every object listed (selected roots may hold each other). + seen: std::collections::HashSet, +} + +/// What `n` is when Liquify can't distort it (and doesn't look inside it). +fn not_liquified(n: &Node) -> Option<&'static str> { + Some(match &n.kind { + NodeKind::Group { .. } if n.graph.is_some() => "graphs", + NodeKind::Text(_) => "type", + NodeKind::SymbolInstance { .. } => "symbols", + NodeKind::Image(_) => "images", + NodeKind::Mesh(_) => "meshes", + NodeKind::Envelope { .. } => "envelopes", + NodeKind::Repeat(_) => "repeats", + NodeKind::Blend { .. } => "blends", + _ => return None, + }) +} + +impl Targets { + /// The targets under `roots` (the selection), or with none every visible object. + fn of(d: &Document, roots: &[NodeId]) -> Self { + let mut t = Self::default(); + if roots.is_empty() { + d.layers.iter().for_each(|l| t.visit(l)); + return t; + } + for r in roots { + let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue }; + if !d.is_editable(*r) { + continue; + } + // Inside a graph, an envelope, a repeat or a blend: that object is what is left alone. + match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) { + Some((a, what)) => t.block(a, what), + None => t.visit(n), + } + } + t + } + + fn block(&mut self, n: &Node, what: &'static str) { + if let Some(b) = n.geometric_bounds() + && self.seen.insert(n.id) + { + self.blocked.push((n.id, b, what)); } } - out.retain(|id| d.is_editable(*id)); - out + + fn visit(&mut self, n: &Node) { + if !n.visible || n.locked { + return; + } + if let Some(what) = not_liquified(n) { + return self.block(n, what); + } + match &n.kind { + NodeKind::Path { guide: false, path, .. } => { + if let Some(b) = reach_bounds(path) + && self.seen.insert(n.id) + { + self.paths.push((n.id, b)); + } + } + _ => n.children().into_iter().flatten().for_each(|c| self.visit(c)), + } + } +} + +/// Does a dab with box `bb` reach `b`? +fn reaches(bb: Rect, b: Rect) -> bool { + b.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0 +} + +/// A liquify stroke being applied: its dabs and the paths they reached so far, so a longer stroke +/// (the next sample of a drag) applies only the dabs it adds. It belongs to one document snapshot +/// (`base`, held so the pointer can't be reused) and one set of other parameters (`key`). +pub(crate) struct LiquifyStroke { + base: Arc, + key: Value, + prm: LiquifyParams, + dabs: Dabber, + targets: Targets, + /// The paths reached, by their index in `targets.paths`. + paths: Vec<(usize, PathStroke)>, + /// The objects left alone that the brush passed over, by their index in `targets.blocked`. + skipped: Vec, + /// Which of `targets.paths` and `targets.blocked` the brush reached. + reached: (Vec, Vec), +} + +impl LiquifyStroke { + fn new(base: Arc, key: Value, prm: LiquifyParams, roots: &[NodeId]) -> Self { + let targets = Targets::of(&base, roots); + let reached = (vec![false; targets.paths.len()], vec![false; targets.blocked.len()]); + Self { base, key, prm, dabs: Dabber::new(&prm), targets, paths: vec![], skipped: vec![], reached } + } + + /// Start the targets dab box `bb` reaches. + fn reach(&mut self, bb: Rect) { + for (i, (id, b)) in self.targets.paths.iter().enumerate() { + if let Some(r) = self.reached.0.get_mut(i).filter(|r| !**r && reaches(bb, *b)) { + *r = true; + let Some(path) = self.base.node(*id).and_then(|n| n.path_data()) else { continue }; + self.paths.push((i, PathStroke::new(path.clone(), id.0))); + } + } + for (i, (_, b, _)) in self.targets.blocked.iter().enumerate() { + if let Some(r) = self.reached.1.get_mut(i).filter(|r| !**r && reaches(bb, *b)) { + *r = true; + self.skipped.push(i); + } + } + } + + /// Add `samples` to the stroke and apply the dabs they make. + fn extend(&mut self, samples: &[Sample]) { + let from = self.dabs.dabs.len(); + for s in samples { + self.dabs.push(*s); + } + for i in from..self.dabs.dabs.len() { + if let Some(c) = self.dabs.dabs.get(i).map(|d| d.c) { + self.reach(self.prm.brush_bounds(c)); + } + } + if let Some(t) = self.dabs.tail() { + self.reach(self.prm.brush_bounds(t.c)); + } + for (_, ps) in &mut self.paths { + ps.advance(&self.dabs.dabs, &self.prm); + } + } + + /// The paths the stroke changed (in document order) and their new geometry. + fn results(&self) -> Vec<(NodeId, PathData)> { + let tail = self.dabs.tail(); + let mut out: Vec<(usize, NodeId, PathData)> = + self.paths.iter().filter_map(|(i, ps)| Some((*i, self.targets.paths.get(*i)?.0, ps.finish(&self.dabs.dabs, tail, &self.prm)?))).collect(); + out.sort_by_key(|(i, ..)| *i); + out.into_iter().map(|(_, id, p)| (id, p)).collect() + } + + /// The objects under the brush left as they are, and the status message about them. + fn skipped(&self) -> (Vec, Option) { + let mut ids = vec![]; + let mut kinds: Vec<&str> = vec![]; + for (id, _, what) in self.skipped.iter().filter_map(|i| self.targets.blocked.get(*i)) { + ids.push(id.0); + if !kinds.contains(what) { + kinds.push(what); + } + } + let msg = (!ids.is_empty()).then(|| { + let n = ids.len(); + format!( + "{} left {n} object{} under the brush as {} ({}): it reshapes paths only", + self.prm.kind.label(), + if n == 1 { "" } else { "s" }, + if n == 1 { "it was" } else { "they were" }, + kinds.join(", ") + ) + }); + (ids, msg) + } } fn liquify(s: &mut Session, p: &Value) -> Result { @@ -402,51 +561,45 @@ fn liquify(s: &mut Session, p: &Value) -> Result { if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) { return Err(bad(C, "points must be finite")); } - let mut stroke = Dabber::new(&prm); - for s in pts { - stroke.push(s); - } - let tail = stroke.tail(); - let dab_pts: Vec = stroke.dabs.iter().chain(tail.as_ref()).map(|d| d.c).collect(); let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { Some(v) => v, None => s.doc()?.selection.objects.clone(), }; - let doc = &s.doc()?.doc; - let leaves = if roots.is_empty() { - // Nothing selected: everything the brush sweeps over. - // No dabs sweep nothing. - let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap_or(vectorcraft_geom::Rect::ZERO); - let mut all = vec![]; - doc.walk(|n| { - if matches!(n.kind, NodeKind::Path { guide: false, .. }) - && n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0) - { - all.push(n.id); - } - }); - all.retain(|id| doc.is_editable(*id) && doc.is_visible(*id)); - all - } else { - leaf_paths(doc, &roots) + let base = s.doc()?.doc.clone(); + let mut key = p.clone(); + if let Some(o) = key.as_object_mut() { + o.remove("points"); + o.insert("__roots".into(), json!(roots.iter().map(|r| r.0).collect::>())); + } + // A live drag: the same stroke as the last preview with more samples goes on from there. + let mut stroke = match s.liquify_stroke.take() { + Some(st) if Arc::ptr_eq(&st.base, &base) && st.key == key && pts.starts_with(st.dabs.samples()) => st, + _ => Box::new(LiquifyStroke::new(base, key, prm, &roots)), }; + let done = stroke.dabs.samples().len(); + stroke.extend(pts.get(done..).unwrap_or_default()); + let results = stroke.results(); + let (skipped, warning) = stroke.skipped(); let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| { let mut changed = vec![]; - for id in &leaves { - let Some(n) = d.node_mut(*id) else { continue }; + for (id, new) in results { + let Some(n) = d.node_mut(id) else { continue }; if let NodeKind::Path { path, live, .. } = &mut n.kind { - let mut ps = PathStroke::new(path.clone(), id.0); - ps.advance(&stroke.dabs, &prm); - if let Some(done) = ps.finish(&stroke.dabs, tail, &prm) { - *path = done; - *live = None; - changed.push(id.0); - } + *path = new; + *live = None; + changed.push(id.0); } } Ok(changed) })?; - Ok(json!({ "ids": changed })) + if s.in_interaction() { + s.liquify_stroke = Some(stroke); + } + let mut out = json!({ "ids": changed, "skipped": skipped }); + if let Some(w) = warning { + out["warning"] = json!(w); + } + Ok(out) } // ---------- puppet warp ---------- diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc7225e3..a39b093f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -706,6 +706,9 @@ pub struct Session { /// Envelope Options with no envelope selected: the options and fidelity new envelopes get /// (`None`: the reference app's defaults, fidelity 50); not saved. pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, + /// The Liquify stroke the last live preview applied, which the next sample of the drag goes on + /// from ([`cmd::distortcmds::LiquifyStroke`]). + pub(crate) liquify_stroke: Option>, } impl Default for Session { @@ -748,6 +751,7 @@ impl Session { recovery: Default::default(), in_tool_actions: false, envelope_defaults: None, + liquify_stroke: None, } } diff --git a/crates/engine/src/tests_liquify.rs b/crates/engine/src/tests_liquify.rs index bde692ea..d868d61c 100644 --- a/crates/engine/src/tests_liquify.rs +++ b/crates/engine/src/tests_liquify.rs @@ -2,7 +2,7 @@ //! Alt-drag brush sizing, holding still, what they leave alone and incremental strokes. use serde_json::{Value, json}; -use vectorcraft_geom::PathData; +use vectorcraft_geom::{PathData, Point}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::*; @@ -154,3 +154,198 @@ fn show_brush_size_hides_the_outline() { s.pointer(&ev(PointerKind::Down, 200.0, 200.0).with_mods(Mods { alt: true, ..Mods::default() }), v).unwrap(); assert_eq!(s.overlays(v).len(), 1); } + +// ---------- holding still ---------- + +/// Twirl pressed at (300, 200) on the square's right edge, held for `holds` (seconds per tick), +/// released. +fn held_twirl(holds: &[f64]) -> (Session, NodeId) { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let v = ViewInfo::default(); + s.select_tool("twirl", v).unwrap(); + s.pointer(&ev(PointerKind::Down, 300.0, 200.0), v).unwrap(); + for dt in holds { + assert!(s.tool_wants_ticks()); + s.tool_tick(*dt, v).unwrap(); + } + s.pointer(&ev(PointerKind::Up, 300.0, 200.0), v).unwrap(); + (s, a) +} + +#[test] +fn holding_still_keeps_twirling_scaled_by_time() { + let (once, a) = held_twirl(&[]); + let (held, _) = held_twirl(&[0.35]); + let (ticked, _) = held_twirl(&[0.1, 0.1, 0.1, 0.05]); + // A tenth of a second held is one more dab where the brush is: 3 after 0.35 s. + let (_, p) = last_journal(&held); + assert_eq!(p["points"], json!([[300.0, 200.0], [300.0, 200.0], [300.0, 200.0], [300.0, 200.0]])); + assert_eq!(path(&held, a), path(&ticked, a), "the same time held in other ticks"); + // Longer holds twirl further: the edge's anchors swing further off x = 300. + let c = Point::new(300.0, 200.0); + let turned = + |s: &Session| path(s, a).anchors().filter(|(_, _, an)| an.p.distance(c) < 50.0).map(|(_, _, an)| (an.p.x - 300.0).abs()).fold(0.0, f64::max); + let (longer, _) = held_twirl(&[1.0]); + assert!(turned(&longer) > turned(&held) && turned(&held) > turned(&once), "{} {} {}", turned(&longer), turned(&held), turned(&once)); + // One undo step whose journal entry replays the same. + let mut t = session(); + rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("object.liquify", &p).unwrap(); + assert_eq!(path(&t, a), path(&held, a)); +} + +#[test] +fn only_twirl_pucker_and_bloat_keep_going() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let v = ViewInfo::default(); + for (tool, holds) in [("warp", false), ("scallop", false), ("crystallize", false), ("wrinkle", false), ("pucker", true), ("bloat", true)] { + s.select_tool(tool, v).unwrap(); + assert!(!s.tool_wants_ticks(), "{tool} idle"); + s.pointer(&ev(PointerKind::Down, 300.0, 200.0), v).unwrap(); + assert_eq!(s.tool_wants_ticks(), holds, "{tool}"); + s.tool_tick(0.5, v).unwrap(); + s.pointer(&ev(PointerKind::Up, 300.0, 200.0), v).unwrap(); + let n = last_journal(&s).1["points"].as_array().unwrap().len(); + assert_eq!(n, if holds { 6 } else { 1 }, "{tool}"); + } + // Repeated points are no extra dabs for the tools that don't keep going. + let warp = |pts: Value| { + let mut t = session(); + let a = rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("object.liquify", &json!({"tool": "warp", "points": pts})).unwrap(); + path(&t, a) + }; + assert_eq!(warp(json!([[300, 200], [300, 200], [320, 200]])), warp(json!([[300, 200], [320, 200]]))); +} + +// ---------- what Liquify leaves alone ---------- + +fn node_json(s: &Session, id: u64) -> Value { + serde_json::to_value(s.doc().unwrap().doc.node(NodeId(id)).unwrap()).unwrap() +} + +#[test] +fn type_symbols_graphs_and_live_contents_are_left_alone_and_reported() { + let mut s = session(); + let id = |v: Value| v["id"].as_u64().unwrap(); + let text = id(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Liquid"})).unwrap()); + let graph = id(s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 60, "width": 120, "height": 80, "rows": [[1, 2]]})).unwrap()); + let sq = rect(&mut s, 450.0, 60.0, 60.0, 60.0); + s.execute("select.set", &json!({"ids": [sq.0]})).unwrap(); + let inst = id(s.execute("symbol.new", &json!({"name": "Dot"})).unwrap()); + let a = rect(&mut s, 100.0, 300.0, 60.0, 60.0); + let b = rect(&mut s, 200.0, 300.0, 60.0, 60.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let blend = id(s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); + let c = rect(&mut s, 400.0, 300.0, 80.0, 60.0); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + let env = id(s.execute("object.envelope.makeWithWarp", &json!({})).unwrap()); + let r = rect(&mut s, 560.0, 300.0, 40.0, 40.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let rep = id(s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 60})).unwrap()); + let plain = rect(&mut s, 100.0, 450.0, 600.0, 60.0); + let all = [text, graph, inst, blend, env, rep]; + let before: Vec = all.iter().map(|i| node_json(&s, *i)).collect(); + // Nothing selected: the brush sweeps the whole drawing. + s.execute("select.set", &json!({"ids": []})).unwrap(); + let pts: Vec = (0..=16).flat_map(|i| [json!([80 + i * 40, 100]), json!([80 + i * 40, 330]), json!([80 + i * 40, 480])]).collect(); + let out = s.execute("object.liquify", &json!({"tool": "bloat", "points": pts, "width": 160, "height": 160, "intensity": 1})).unwrap(); + for (i, id) in all.iter().enumerate() { + assert_eq!(node_json(&s, *id), before[i], "object {id} unchanged"); + } + assert!(out["ids"].as_array().unwrap().contains(&json!(plain.0))); + let mut skipped: Vec = out["skipped"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + skipped.sort(); + let mut want = all.to_vec(); + want.sort(); + assert_eq!(skipped, want); + let w = out["warning"].as_str().unwrap(); + for kind in ["type", "symbols", "graphs", "blends", "envelopes", "repeats"] { + assert!(w.contains(kind), "{kind} in {w}"); + } + // Selected, they are left alone too (and a path inside a blend is the blend's). + s.execute("select.set", &json!({"ids": [text, blend, plain.0]})).unwrap(); + let out = + s.execute("object.liquify", &json!({"tool": "pucker", "points": [[130, 100], [130, 330], [130, 480]], "width": 200, "height": 200})).unwrap(); + assert_eq!(out["ids"], json!([plain.0])); + assert_eq!(node_json(&s, blend), before[3]); + let inside = s.execute("object.liquify", &json!({"tool": "pucker", "ids": [a.0], "points": [[130, 330]]})).unwrap(); + assert_eq!((&inside["ids"], &inside["skipped"]), (&json!([]), &json!([blend]))); +} + +#[test] +fn guides_are_left_alone_in_selection_mode_and_the_tool_reports_skips() { + let mut s = session(); + let g = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let guide = path(&s, g); + let a = rect(&mut s, 120.0, 120.0, 160.0, 160.0); + let t = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [g.0, a.0, t]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("bloat", v).unwrap(); + let ui = s.pointer(&ev(PointerKind::Down, 290.0, 200.0), v).unwrap(); + assert!(matches!(ui.first(), Some(UiRequest::Status(m)) if m.contains("type")), "{ui:?}"); + s.pointer(&ev(PointerKind::Up, 290.0, 200.0), v).unwrap(); + assert_eq!(path(&s, g), guide); + assert_ne!(path(&s, a).bounds().unwrap(), vectorcraft_geom::Rect::new(120.0, 120.0, 280.0, 280.0)); +} + +// ---------- incremental strokes ---------- + +/// Every prefix of `pts` previewed in one interaction gives what the whole prefix gives at once. +fn previews_match_whole_strokes(params: Value, pts: &[Value], select: bool) { + let setup = || { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let b = rect(&mut s, 330.0, 120.0, 120.0, 160.0); + if select { + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + } else { + s.execute("select.set", &json!({"ids": []})).unwrap(); + } + (s, a, b) + }; + let (mut live, a, b) = setup(); + live.begin_interaction("Liquify").unwrap(); + for k in 1..=pts.len() { + let mut p = params.clone(); + p["points"] = json!(pts[..k]); + let r = live.preview("object.liquify", &p).unwrap(); + let (mut whole, ..) = setup(); + let w = whole.execute("object.liquify", &p).unwrap(); + assert_eq!(r["ids"], w["ids"], "prefix {k}"); + assert_eq!((path(&live, a), path(&live, b)), (path(&whole, a), path(&whole, b)), "prefix {k} of {params}"); + } + assert!(live.liquify_stroke.is_some(), "the drag goes on from the last preview"); + live.commit_interaction().unwrap(); +} + +#[test] +fn a_stroke_applied_as_it_grows_equals_the_whole_stroke() { + let path_pts: Vec = (0..14).map(|i| json!([250 + i * 12, 130 + (i * 37) % 90])).collect(); + previews_match_whole_strokes(json!({"tool": "warp", "width": 70, "height": 50, "angle": 20, "intensity": 0.8}), &path_pts, false); + previews_match_whole_strokes(json!({"tool": "crystallize", "width": 60, "height": 60, "complexity": 4}), &path_pts, true); + // Holds (repeated points) and pressure. + let mut held: Vec = path_pts.iter().take(5).cloned().collect(); + held.extend([json!([298, 200, 0.4]), json!([298, 200, 0.4]), json!([298, 200, 0.9]), json!([310, 210, 1])]); + previews_match_whole_strokes(json!({"tool": "pucker", "usePressure": true}), &held, false); + previews_match_whole_strokes(json!({"tool": "twirl", "rate": 120}), &held, true); +} + +#[test] +fn a_changed_brush_or_another_stroke_starts_afresh() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.begin_interaction("Liquify").unwrap(); + s.preview("object.liquify", &json!({"tool": "bloat", "points": [[300, 150], [300, 200]]})).unwrap(); + // Not a continuation (other points, another size): applied to the snapshot, as a fresh call is. + s.preview("object.liquify", &json!({"tool": "bloat", "points": [[300, 250]], "width": 60})).unwrap(); + let mut t = session(); + rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("object.liquify", &json!({"tool": "bloat", "points": [[300, 250]], "width": 60})).unwrap(); + assert_eq!(path(&s, a), path(&t, a)); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 172b22bd..e4e90ad6 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -42,6 +42,9 @@ impl Default for ViewInfo { pub enum UiRequest { Dialog(String, Value), SwitchTool(String), + /// A message for the status bar: the `warning` of a command the tool ran (Liquify skipping + /// type under the brush). + Status(String), } impl Session { @@ -163,6 +166,22 @@ impl Session { self.apply_actions(acts) } + /// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the + /// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event. + pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result> { + if !self.tool.wants_ticks() { + return Ok(vec![]); + } + let acts = self.with_tool_cx(view, |t, cx| t.tick(cx, dt)); + self.take_tool_panic()?; + self.apply_actions(acts) + } + + /// Does the active tool want [`Session::tool_tick`]s now? + pub fn tool_wants_ticks(&self) -> bool { + self.tool.wants_ticks() + } + pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); self.take_tool_panic()?; @@ -275,13 +294,25 @@ impl Session { for a in acts { match a { Action::Begin(label) => self.begin_interaction(&label)?, - Action::Preview(cmd, p) => { - if let Err(e) = self.preview(&cmd, &p) { - log::warn!("preview {cmd}: {e}"); + Action::Preview(cmd, p) => match self.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) + && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) + { + ui.push(UiRequest::Status(w.to_string())); + } } + Err(e) => log::warn!("preview {cmd}: {e}"), + }, + // The drag is over: the dabs of a Liquify stroke have no further use. + Action::Commit => { + self.liquify_stroke = None; + self.commit_interaction()? + } + Action::Cancel => { + self.liquify_stroke = None; + self.cancel_interaction()? } - Action::Commit => self.commit_interaction()?, - Action::Cancel => self.cancel_interaction()?, Action::Exec(cmd, p) => { self.execute(&cmd, &p)?; } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index a881f1e3..9750d13d 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -148,6 +148,7 @@ impl Headless { self.session.select_tool(&t, self.view).map_err(|e| e.to_string())?; } UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})), + UiRequest::Status(msg) => out.push(json!({"status": msg})), } } Ok(()) @@ -170,6 +171,11 @@ impl Headless { let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; + let hold = PointerEvent::json_hold(e); + if hold > 0.0 { + let reqs = self.session.tool_tick(hold, self.view).map_err(|e| e.to_string())?; + self.apply_ui_requests(reqs, &mut requests)?; + } } let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::>()).unwrap_or_default(); Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests})) diff --git a/crates/mcp/src/tests_liquify.rs b/crates/mcp/src/tests_liquify.rs index 5f510c0d..4760380b 100644 --- a/crates/mcp/src/tests_liquify.rs +++ b/crates/mcp/src/tests_liquify.rs @@ -41,6 +41,24 @@ fn pointer_gestures_carry_pen_pressure() { command(&mut h, "document.node", json!({"id": id})) }; let (none, full) = (bloat(0.0), bloat(1.0)); - assert_eq!(none["path"], bloat(0.0)["path"]); + assert_eq!(none, bloat(0.0)); assert_ne!(none, full); } + +#[test] +fn holding_still_through_mcp_and_skips_are_reported() { + let pucker = |hold: u64| { + let mut h = Headless::with_document(); + let id = command(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 200}))["id"].clone(); + command(&mut h, "text.create", json!({"x": 290, "y": 200, "text": "Hi"})); + command(&mut h, "select.set", json!({"ids": []})); + let ev = |kind: &str| json!({"kind": kind, "x": 290, "y": 200, "holdMs": hold}); + let r = ok(&mut h, "pointer_gesture", json!({"tool": "pucker", "events": [ev("down"), ev("up")]})); + (r, command(&mut h, "document.node", json!({"id": id}))) + }; + let (r, still) = pucker(0); + assert!(r["requests"].as_array().unwrap().iter().any(|q| q["status"].as_str().is_some_and(|m| m.contains("type"))), "{r}"); + let (_, held) = pucker(500); + assert_ne!(still, held); + assert_eq!(held, pucker(500).1, "the same hold, the same result"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index a12b26b6..f12cb198 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,6 +128,7 @@ pub fn tool_definitions() -> Vec { "y": num("Document y (pt)"), "mods": mods_schema(), "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"), + "holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"), }), &["kind", "x", "y"]), }, "mods": mods_schema(), diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index 96b12767..4606be58 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -3,7 +3,9 @@ //! A drag previews `object.liquify {tool, points, width, height, angle, intensity, detail, //! simplify, rate?, complexity?, horizontal?, vertical?, affect…?, usePressure?, ids?}` with every //! pointer sample so far (and its pen pressure while Use Pressure Pen is on), so the result is a -//! pure function of the parameters (replay reproduces it exactly). +//! pure function of the parameters (replay reproduces it exactly). Holding the brush still with +//! Twirl, Pucker or Bloat repeats the last sample every [`HOLD_EVERY`] seconds of [`Tool::tick`] +//! time: one more dab there each time. //! //! The kernel resamples the stroke into dabs spaced a fraction of the brush apart ([`Dabber`]: //! more samples only add dabs, so a growing stroke can be applied as it grows). For each dab it @@ -622,6 +624,11 @@ impl PathStroke { } } +/// The box around every anchor and handle of `path`: no dab outside it changes the path. +pub fn reach_bounds(path: &PathData) -> Option { + path.subpaths.iter().filter_map(sp_bounds).reduce(|a, b| a.union(b)) +} + fn sp_bounds(sp: &SubPath) -> Option { let mut it = sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]); let f = it.next()?; @@ -630,6 +637,13 @@ fn sp_bounds(sp: &SubPath) -> Option { // ---------- the tool ---------- +/// Holding the brush still repeats the stroke's last sample this often (seconds): Twirl, Pucker +/// and Bloat keep applying, scaled by the time held. +pub const HOLD_EVERY: f64 = 0.1; + +/// Most repeats one tick adds (a minute held). +const MAX_HOLDS_PER_TICK: u32 = 600; + /// An Alt-drag sizing the brush: where it started and the brush size then. #[derive(Clone, Copy, Debug)] struct Sizing { @@ -647,12 +661,14 @@ pub struct LiquifyTool { active: bool, sizing: Option, hover: Option, + /// Time held still since the last sample or repeat (seconds). + held: f64, } impl LiquifyTool { pub fn new(id: &str) -> Self { let kind = LiquifyKind::parse(id).unwrap_or(LiquifyKind::Warp); - Self { params: LiquifyParams::new(kind), show_brush: true, points: vec![], active: false, sizing: None, hover: None } + Self { params: LiquifyParams::new(kind), show_brush: true, points: vec![], active: false, sizing: None, hover: None, held: 0.0 } } /// The command + params for the stroke so far (with each sample's pressure while Use Pressure @@ -699,6 +715,7 @@ impl Tool for LiquifyTool { PointerKind::Down => { self.points = vec![sample]; self.active = true; + self.held = 0.0; let (c, v) = self.command(cx); vec![Action::Begin(self.params.kind.label().into()), Action::Preview(c, v)] } @@ -712,6 +729,7 @@ impl Tool for LiquifyTool { return vec![]; } self.points.push(sample); + self.held = 0.0; let (c, v) = self.command(cx); vec![Action::Preview(c, v)] } @@ -773,6 +791,27 @@ impl Tool for LiquifyTool { self.sizing = None; if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] } } + /// Held still: every [`HOLD_EVERY`] the last sample repeats (one more dab there). + fn tick(&mut self, cx: &ToolContext, dt: f64) -> Vec { + if !self.wants_ticks() || !dt.is_finite() || dt <= 0.0 { + return vec![]; + } + self.held += dt; + let mut n = 0; + // A hair under the period, so a sum of ticks that is one in exact arithmetic counts. + while self.held >= HOLD_EVERY - 1e-9 && n < MAX_HOLDS_PER_TICK { + self.held -= HOLD_EVERY; + n += 1; + } + self.held = self.held.clamp(0.0, HOLD_EVERY); + let Some(&last) = self.points.last().filter(|_| n > 0) else { return vec![] }; + self.points.extend(std::iter::repeat_n(last, n as usize)); + let (c, v) = self.command(cx); + vec![Action::Preview(c, v)] + } + fn wants_ticks(&self) -> bool { + self.active && self.params.kind.holds() + } } #[cfg(test)] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1fbfbcf3..f95fc3fc 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -96,6 +96,11 @@ impl PointerEvent { pub fn json_pressure(e: &Value) -> f32 { e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32) } + /// How long the pointer then holds still, in seconds, from a JSON pointer event's `holdMs` + /// (0..60000; default 0): the time [`Tool::tick`] gets. + pub fn json_hold(e: &Value) -> f64 { + e.get("holdMs").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |ms| ms.clamp(0.0, 60_000.0) / 1000.0) + } } /// Keys tools care about. @@ -359,6 +364,16 @@ pub trait Tool: Send { fn after_command(&mut self, _cx: &ToolContext) -> Vec { vec![] } + /// Time passes while the pointer button is held (`dt` seconds since the last tick, whether or + /// not the pointer moved): tools that keep working while the brush holds still (Twirl, Pucker, + /// Bloat) act on it. The host supplies the time, so tests and agents drive it exactly. + fn tick(&mut self, _cx: &ToolContext, _dt: f64) -> Vec { + vec![] + } + /// Does the tool want [`Tool::tick`]s now (the host keeps time only then)? + fn wants_ticks(&self) -> bool { + false + } } /// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c9f8ecf..5366a926 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -493,6 +493,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } + // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts + // a quarter second at most. + if app.session.tool_wants_ticks() { + let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); + let r = app.session.tool_tick(dt, view); + apply_requests(app, r); + } } } } else { @@ -579,6 +586,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result crate::dialogs::open_tool_dialog(app, &kind, p), vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t), + vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), } } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 857cca15..3dc3952f 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -512,7 +512,7 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { ], "twirl" | "pucker" | "bloat" => &[ ("Click or drag", true), - (" over paths to reshape them | ", false), + (" over paths, and hold still to keep going | ", false), ("Alt+Drag", true), (" to size the brush (", false), ("Shift", true), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e1d2a54d..ddb46dbb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -145,6 +145,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); + let hold = PointerEvent::json_hold(e); + if hold > 0.0 { + let r = app.session.tool_tick(hold, view); + crate::canvas::apply_requests(app, r); + } } ctx.request_repaint(); wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()}))) diff --git a/crates/ui-egui/src/dialogs/liquify.rs b/crates/ui-egui/src/dialogs/liquify.rs index ec3aacac..630ae81c 100644 --- a/crates/ui-egui/src/dialogs/liquify.rs +++ b/crates/ui-egui/src/dialogs/liquify.rs @@ -213,4 +213,15 @@ mod tests { assert_eq!(app.session.tool_options_of(tool)["affectIn"], json!(false), "{tool}"); } } + + #[test] + fn type_under_the_brush_is_reported_in_the_status_bar() { + let mut app = app(); + app.run("text.create", json!({"x": 100, "y": 100, "text": "Hi"})).unwrap(); + app.run("select.set", json!({"ids": []})).unwrap(); + app.select_tool("bloat"); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 105.0, 95.0), view); + assert!(app.ui.status.contains("Bloat left 1 object under the brush"), "{}", app.ui.status); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f3d4f356..c4b2ac35 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -489,6 +489,11 @@ then the tool's options `detail`, `simplify` and `simplifyOn` (Warp, Twirl, Puck `vertical` (Wrinkle, %), and `showBrush`. `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events take `pressure` (0..1, default 1): it is the Liquify intensity while Use Pressure Pen is on. +`ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the +next event. Twirl, Pucker and Bloat keep applying while held (a repeat of the last point every 0.1 s), exactly as +for the same time held with the mouse. Liquify leaves type, symbols, images, graphs, meshes, envelopes, repeats and +blends as they are and says so in the status bar. + Perspective grid presets: in the `perspectiveGrid` dialog, `name` is the preset whose fields are loaded (setting the fields of a preset, as the Preset menu does, keeps its name; other changes clear it, which reads [Custom]). Save Preset… sets `__mode: "save"` and `__from: "define"` with `name` the new preset's name: `ui.dialog.confirm` then saves diff --git a/docs/mcp.md b/docs/mcp.md index a8ee233a..98f81bca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1853,6 +1853,19 @@ its current size (with Shift too it keeps its proportions). With Use Pressure Pe {"name":"run_command","arguments":{"command":"object.liquify","params":{"tool":"scallop","points":[[300,150],[300,250]],"complexity":3,"affectAnchors":false}}} ``` +Holding the brush still keeps Twirl, Pucker and Bloat working, scaled by the time held: every tenth of a second the +stroke's last point repeats in `object.liquify`'s `points`, one more dab there (other tools ignore repeated points). +Give a `pointer_gesture` event `holdMs` (0..60000) to hold the pointer that long after it, button down; the same +time gives the same result. Liquify reshapes paths only: type, symbols, images, graphs, meshes, and envelopes, +repeats and blends with their contents stay as they are, and guides are never touched. `object.liquify` returns +those under the brush in `skipped` with a `warning`, which the gesture's `requests` carry as `{"status": …}` (the +status bar in the app). A drag applies only the dabs each new sample adds; the result equals applying the whole +stroke at once, so the journal entry replays it exactly. + +```json +{"name":"pointer_gesture","arguments":{"tool":"twirl","events":[{"kind":"down","x":300,"y":200,"holdMs":800},{"kind":"up","x":300,"y":200}]}} +``` + ## Perspective Selection Objects in perspective keep their attachment themselves (`perspective` on the object: `plane` and `depth`, the