From 92cadd60831266ab84cfa4f4d770656e437f3129 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:02:11 +0700 Subject: [PATCH] M8.3: Liquify Tool Options dialogs, pen pressure and Brush Affects Double-clicking a Liquify tool opens its Tool Options (dialog liquifyOptions): the shared Global Brush Dimensions (Width, Height, Angle, Intensity, Use Pressure Pen), the tool's options (Detail, Simplify with its checkbox, Twirl Rate, Complexity, Brush Affects, Horizontal/Vertical) and Show Brush Size. The canvas captures pen pressure from pen/touch force, ui.pointer and MCP pointer events take `pressure`, and object.liquify takes [x, y, pressure]. Fixes: LiquifyParams::to_json now sends affectAnchors/In/Out; Alt-drag sizes the brush from its current size (Shift keeps proportions) instead of collapsing it. Hint-bar text for the seven tools. Registers the MCP Liquify tests file added in M8.2. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/distortcmds.rs | 37 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_liquify.rs | 156 +++++++++++ crates/engine/tests/command_sweep.rs | 3 + crates/mcp/src/headless.rs | 2 +- crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_liquify.rs | 17 ++ crates/mcp/src/tools.rs | 1 + crates/tools/src/distort/liquify.rs | 376 ++++++++++++++++++++------ crates/tools/src/lib.rs | 4 + crates/tools/src/settings.rs | 4 +- crates/ui-egui/src/canvas.rs | 22 +- crates/ui-egui/src/chrome.rs | 24 ++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs/liquify.rs | 216 +++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/toolbar.rs | 6 +- docs/control-protocol.md | 7 + docs/mcp.md | 18 ++ 20 files changed, 802 insertions(+), 101 deletions(-) create mode 100644 crates/engine/src/tests_liquify.rs create mode 100644 crates/ui-egui/src/dialogs/liquify.rs diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 697b662d..2963649b 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile}; use vectorcraft_geom::Point; -use vectorcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs}; +use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with}; @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "Liquify", [], None, - "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y]…] (the brush stroke), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), detail? (1..10), simplify? (0..100), rate? (twirl °), complexity?, horizontal?, vertical?, affectAnchors?, affectIn?, affectOut?, ids? (default: selection, else every path the brush touches)}", + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids? (default: selection, else every path the brush touches)}", has_doc, liquify ), @@ -325,6 +325,15 @@ fn points_of(p: &Value, key: &str) -> Option> { p.get(key)?.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect() } +/// A stroke's `[[x, y, pressure?]…]` samples (pressure 0..1, default 1). +fn samples_of(p: &Value, key: &str) -> Option> { + p.get(key)? + .as_array()? + .iter() + .map(|q| Some((Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?), q.get(2).map_or(Some(1.0), Value::as_f64)?))) + .collect() +} + /// Leaf paths under `roots` that can be edited. fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec { let mut out = vec![]; @@ -344,11 +353,16 @@ fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec { fn liquify(s: &mut Session, p: &Value) -> Result { const C: &str = "object.liquify"; let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?; - let pts = points_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y]…] list"))?; - if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?; + 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 dab_pts = dabs(&pts, prm.dab_spacing()); + 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(), @@ -375,11 +389,14 @@ fn liquify(s: &mut Session, p: &Value) -> Result { let mut changed = vec![]; for id in &leaves { let Some(n) = d.node_mut(*id) else { continue }; - if let NodeKind::Path { path, live, .. } = &mut n.kind - && apply_stroke(path, &dab_pts, &prm, id.0) - { - *live = None; - changed.push(id.0); + 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); + } } } Ok(changed) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3a085d4..b321c760 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1210,6 +1210,8 @@ mod tests_links; #[cfg(test)] mod tests_linkspanel; #[cfg(test)] +mod tests_liquify; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_maskview; diff --git a/crates/engine/src/tests_liquify.rs b/crates/engine/src/tests_liquify.rs new file mode 100644 index 00000000..bde692ea --- /dev/null +++ b/crates/engine/src/tests_liquify.rs @@ -0,0 +1,156 @@ +//! The Liquify tools: their options (Brush Affects, Simplify, Use Pressure Pen, Show Brush Size), +//! Alt-drag brush sizing, holding still, what they leave alone and incremental strokes. + +use serde_json::{Value, json}; +use vectorcraft_geom::PathData; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +/// Down at the first point, drags through the middle ones, up at the last. +fn stroke(s: &mut Session, pts: &[(f64, f64)], pressure: f32) { + let v = ViewInfo::default(); + for (i, &(x, y)) in pts.iter().enumerate() { + let kind = match i { + 0 => PointerKind::Down, + _ if i + 1 == pts.len() => PointerKind::Up, + _ => PointerKind::Drag, + }; + s.pointer(&PointerEvent { pressure, ..ev(kind, x, y) }, v).unwrap(); + } +} + +fn last_journal(s: &Session) -> (String, Value) { + s.journal.last().unwrap().clone() +} + +#[test] +fn brush_affects_reach_the_command() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.select_tool("scallop", ViewInfo::default()).unwrap(); + s.set_tool_option("affectAnchors", &json!(false)); + s.set_tool_option("affectOut", &json!(false)); + stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 250.0)], 1.0); + let (cmd, p) = last_journal(&s); + assert_eq!((cmd.as_str(), &p["affectAnchors"], &p["affectIn"], &p["affectOut"]), ("object.liquify", &json!(false), &json!(true), &json!(false))); + // Replaying it gives the same path, and the boxes change the result. + let mut t = session(); + let b = rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + t.execute(&cmd, &p).unwrap(); + assert_eq!(path(&t, b), path(&s, a)); + let mut all = p.clone(); + for k in ["affectAnchors", "affectIn", "affectOut"] { + all[k] = json!(true); + } + t.execute("edit.undo", &json!({})).unwrap(); + t.execute(&cmd, &all).unwrap(); + assert_ne!(path(&t, b), path(&s, a)); +} + +#[test] +fn simplify_can_be_turned_off() { + let run = |on: bool| { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + // Pucker centred on an edge pulls its new anchors along the edge: they stay flat. + let p = json!({"tool": "pucker", "points": [[300, 200]], "intensity": 1, "detail": 10, "simplify": 100, "simplifyOn": on}); + s.execute("object.liquify", &p).unwrap(); + path(&s, a).anchor_count() + }; + assert!(run(false) > run(true), "{} vs {}", run(false), run(true)); +} + +#[test] +fn pen_pressure_is_the_intensity_with_use_pressure_pen() { + let run = |pressures: [f64; 2], use_pressure: bool| { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let pts = json!([[260, 150, pressures[0]], [260, 250, pressures[1]]]); + s.execute("object.liquify", &json!({"tool": "pucker", "points": pts, "width": 120, "height": 120, "usePressure": use_pressure})).unwrap(); + path(&s, a) + }; + let square = { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + path(&s, a) + }; + // No pressure: nothing moves; more pressure moves more; pressure is ignored without the option. + assert_eq!(run([0.0, 0.0], true), square); + let x = |p: &PathData| p.anchors().map(|(_, _, an)| an.p.x).fold(f64::MAX, |m, v| if v > 200.0 { m.min(v) } else { m }); + assert!(x(&run([1.0, 1.0], true)) < x(&run([0.3, 0.3], true))); + assert_eq!(run([0.0, 0.0], false), run([1.0, 1.0], false)); + + // The tool sends each sample's pressure while the option is on. + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.select_tool("bloat", ViewInfo::default()).unwrap(); + stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 210.0)], 0.4); + assert_eq!(last_journal(&s).1["points"][0].as_array().unwrap().len(), 2); + s.set_tool_option("usePressure", &json!(true)); + stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 210.0)], 0.4); + let (_, p) = last_journal(&s); + assert_eq!((p["usePressure"].as_bool(), p["points"][1][2].as_f64().map(|f| (f * 1e6).round() / 1e6)), (Some(true), Some(0.4))); +} + +#[test] +fn alt_drag_sizes_the_brush_from_its_size() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("warp", v).unwrap(); + s.set_tool_option("height", &json!(60)); + let alt = Mods { alt: true, ..Mods::default() }; + let shift_alt = Mods { shift: true, ..alt }; + let size = |s: &Session| (s.tool_options()["width"].as_f64().unwrap(), s.tool_options()["height"].as_f64().unwrap()); + s.pointer(&ev(PointerKind::Down, 50.0, 50.0).with_mods(alt), v).unwrap(); + // The first sample barely moves: the brush stays about as it was (it used to collapse to 1 pt). + s.pointer(&ev(PointerKind::Drag, 51.0, 50.0).with_mods(alt), v).unwrap(); + assert_eq!(size(&s), (102.0, 60.0)); + s.pointer(&ev(PointerKind::Drag, 60.0, 45.0).with_mods(alt), v).unwrap(); + assert_eq!(size(&s), (120.0, 50.0)); + s.pointer(&ev(PointerKind::Up, 60.0, 45.0).with_mods(alt), v).unwrap(); + assert_eq!(s.journal.iter().filter(|(c, _)| c == "object.liquify").count(), 0, "sizing doesn't liquify"); + // Shift keeps the proportions. + s.pointer(&ev(PointerKind::Down, 0.0, 0.0).with_mods(shift_alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, 60.0, 5.0).with_mods(shift_alt), v).unwrap(); + assert_eq!(size(&s), (240.0, 100.0)); + s.pointer(&ev(PointerKind::Up, 60.0, 5.0).with_mods(shift_alt), v).unwrap(); + // The size is kept: the other Liquify tools share it. + s.select_tool("pucker", v).unwrap(); + assert_eq!(size(&s), (240.0, 100.0)); +} + +#[test] +fn show_brush_size_hides_the_outline() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("twirl", v).unwrap(); + s.pointer(&ev(PointerKind::Move, 200.0, 200.0), v).unwrap(); + assert_eq!(s.overlays(v).len(), 1); + s.set_tool_option("showBrush", &json!(false)); + assert!(s.overlays(v).is_empty()); + assert_eq!(s.tool_options()["showBrush"], json!(false)); + // Sizing the brush shows it anyway. + 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); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index def2abe5..811de578 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -264,6 +264,9 @@ fn structured_junk() { ("command.batch", json!({"commands": "nope"})), ("object.puppetWarp", json!({"pins": [[0, 0]], "moved": [[0, 0]], "angles": [1e308]})), ("object.puppetWarp", json!({"pins": [[0, 0], [5, 5]], "moved": [[0, 0], [1e9, 1e9]], "angles": [-1e308, "x"]})), + ("object.liquify", json!({"tool": "pucker", "points": [[0, 0, 1e308], [1, 1, -5], [2, 2, "x"]], "usePressure": true})), + ("object.liquify", json!({"tool": "twirl", "points": vec![json!([50, 50, 0.5]); 400], "usePressure": "yes", "simplifyOn": 3})), + ("object.liquify", json!({"tool": "wrinkle", "points": [[0, 0], [1e4, 1e4]], "width": 1e308, "complexity": -1, "affectIn": null})), ]; let mut failures = vec![]; for fx in Fixture::ALL { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eb906d43..9644d688 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -166,7 +166,7 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: 1.0 }; + 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)?; } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index db68aaf2..b04d76b3 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -42,6 +42,8 @@ mod tests_gradient; #[cfg(test)] mod tests_links; #[cfg(test)] +mod tests_liquify; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_svg; diff --git a/crates/mcp/src/tests_liquify.rs b/crates/mcp/src/tests_liquify.rs index 572dc92f..5f510c0d 100644 --- a/crates/mcp/src/tests_liquify.rs +++ b/crates/mcp/src/tests_liquify.rs @@ -27,3 +27,20 @@ fn tool_options_are_set_headless_and_kept_across_tool_switches() { command(&mut h, "tool.select", json!({"tool": "warp"})); assert_eq!(command(&mut h, "tool.setOption", json!({}))["detail"], json!(6.0)); } + +#[test] +fn pointer_gestures_carry_pen_pressure() { + let bloat = |pressure: f64| { + 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, "select.set", json!({"ids": []})); + command(&mut h, "tool.select", json!({"tool": "bloat"})); + command(&mut h, "tool.setOption", json!({"key": "usePressure", "value": true})); + let ev = |kind: &str, y: f64| json!({"kind": kind, "x": 280, "y": y, "pressure": pressure}); + ok(&mut h, "pointer_gesture", json!({"events": [ev("down", 150.0), ev("drag", 200.0), ev("up", 250.0)]})); + 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_ne!(none, full); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f974efe5..a12b26b6 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -127,6 +127,7 @@ pub fn tool_definitions() -> Vec { "x": num("Document x (pt)"), "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"), }), &["kind", "x", "y"]), }, "mods": mods_schema(), diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index ff747367..96b12767 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -1,11 +1,12 @@ //! Liquify tools: Warp (Shift+R), Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle. //! //! A drag previews `object.liquify {tool, points, width, height, angle, intensity, detail, -//! simplify, rate?, complexity?, horizontal?, vertical?, ids?}` with every pointer sample so far; -//! the engine re-applies the whole stroke to the interaction snapshot with [`apply_stroke`], so the -//! result is a pure function of the parameters (replay reproduces it exactly). +//! 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). //! -//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart. For each dab it +//! 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 //! first subdivides the segments the brush touches (Detail: more anchors where the brush passes, //! lines become curves so the result stays smooth), then moves anchors and handles by the tool's //! displacement field weighted by a smooth falloff `(1 − r²)²` inside the (elliptical, rotated) @@ -64,6 +65,18 @@ impl LiquifyKind { Self::Wrinkle => "Wrinkle", } } + /// Holding the brush still keeps applying the tool (a repeated stroke sample is one more dab). + pub fn holds(self) -> bool { + matches!(self, Self::Twirl | Self::Pucker | Self::Bloat) + } + /// Has the Simplify option (Scallop, Crystallize and Wrinkle have Brush Affects instead). + pub fn simplifies(self) -> bool { + matches!(self, Self::Warp | Self::Twirl | Self::Pucker | Self::Bloat) + } + /// Has the Brush Affects options (anchor points, in and out tangent handles). + pub fn has_affects(self) -> bool { + !self.simplifies() + } } /// Brush and tool options (Warp Tool Options and friends). @@ -76,10 +89,14 @@ pub struct LiquifyParams { pub angle: f64, /// 0..1. pub intensity: f64, + /// Use Pressure Pen: each stroke sample's pen pressure is the intensity there. + pub use_pressure: bool, /// 1..10: anchor density added where the brush passes. pub detail: f64, - /// 0..100: removal of redundant flat anchors afterwards. + /// 0..100: removal of redundant flat anchors afterwards (Warp, Twirl, Pucker, Bloat). pub simplify: f64, + /// The Simplify checkbox. + pub simplify_on: bool, /// Twirl rate, degrees (−180..180). pub rate: f64, /// Scallop/Crystallize/Wrinkle complexity (0..15). @@ -100,8 +117,10 @@ impl LiquifyParams { height: 100.0, angle: 0.0, intensity: 0.5, + use_pressure: false, detail: 2.0, simplify: 50.0, + simplify_on: true, rate: 40.0, complexity: 1.0, horizontal: 0.0, @@ -132,23 +151,36 @@ impl LiquifyParams { s.complexity = f("complexity").unwrap_or(1.0).clamp(0.0, 15.0); s.horizontal = pct(f("horizontal").unwrap_or(0.0)).clamp(0.0, 1.0); s.vertical = pct(f("vertical").unwrap_or(1.0)).clamp(0.0, 1.0); - let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true); - s.affect_anchors = b("affectAnchors"); - s.affect_in = b("affectIn"); - s.affect_out = b("affectOut"); + let b = |k: &str, d: bool| p.get(k).and_then(Value::as_bool).unwrap_or(d); + s.affect_anchors = b("affectAnchors", true); + s.affect_in = b("affectIn", true); + s.affect_out = b("affectOut", true); + s.use_pressure = b("usePressure", false); + s.simplify_on = b("simplifyOn", true); Some(s) } + /// The command parameters (those the kind uses). pub fn to_json(&self) -> Value { let mut v = json!({ "tool": self.kind.id(), "width": self.width, "height": self.height, "angle": self.angle, - "intensity": self.intensity, "detail": self.detail, "simplify": self.simplify, + "intensity": self.intensity, "detail": self.detail, }); + if self.use_pressure { + v["usePressure"] = json!(true); + } + if self.kind.simplifies() { + v["simplify"] = json!(self.simplify); + v["simplifyOn"] = json!(self.simplify_on); + } else { + v["complexity"] = json!(self.complexity); + v["affectAnchors"] = json!(self.affect_anchors); + v["affectIn"] = json!(self.affect_in); + v["affectOut"] = json!(self.affect_out); + } match self.kind { LiquifyKind::Twirl => v["rate"] = json!(self.rate), - LiquifyKind::Scallop | LiquifyKind::Crystallize => v["complexity"] = json!(self.complexity), LiquifyKind::Wrinkle => { - v["complexity"] = json!(self.complexity); v["horizontal"] = json!(self.horizontal); v["vertical"] = json!(self.vertical); } @@ -161,12 +193,32 @@ impl LiquifyParams { /// the parameters its kind uses). pub fn options_json(&self) -> Value { let mut v = self.to_json(); - for (k, x) in [("rate", self.rate), ("complexity", self.complexity), ("horizontal", self.horizontal), ("vertical", self.vertical)] { + for (k, x) in [ + ("simplify", self.simplify), + ("rate", self.rate), + ("complexity", self.complexity), + ("horizontal", self.horizontal), + ("vertical", self.vertical), + ] { + v[k] = json!(x); + } + for (k, x) in [ + ("usePressure", self.use_pressure), + ("simplifyOn", self.simplify_on), + ("affectAnchors", self.affect_anchors), + ("affectIn", self.affect_in), + ("affectOut", self.affect_out), + ] { v[k] = json!(x); } v } + /// How far off the chord Simplify removes anchors (0: Simplify is off or not an option). + fn simplify_tolerance(&self) -> f64 { + if self.kind.simplifies() && self.simplify_on { self.simplify / 100.0 * self.detail_spacing() * 0.02 } else { 0.0 } + } + fn radii(&self) -> (f64, f64) { (self.width / 2.0, self.height / 2.0) } @@ -206,34 +258,96 @@ impl LiquifyParams { /// Resample a stroke polyline into dabs spaced `spacing` apart (the first point is always a dab). pub fn dabs(points: &[Point], spacing: f64) -> Vec { - let mut out: Vec = vec![]; - let Some(&first) = points.first() else { return out }; - out.push(first); - let mut last = first; - let mut carry = 0.0; - for w in points.windows(2) { - let (a, b) = (w[0], w[1]); + let mut d = Dabber::with(spacing, Some(1.0), false); + for p in points { + d.push((*p, 1.0)); + } + d.dabs.iter().chain(d.tail().as_ref()).map(|d| d.c).collect() +} + +/// One brush dab: its centre and intensity (0..1). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dab { + pub c: Point, + pub intensity: f64, +} + +/// A stroke sample: the pointer position and its pen pressure (0..1). +pub type Sample = (Point, f64); + +/// Most dabs a stroke makes (later samples add none). +const MAX_DABS: usize = 20_000; + +/// Resamples a growing stroke into dabs `spacing` apart along it, the first sample always a dab. +/// More samples only add dabs ([`Self::dabs`] is a prefix of what it becomes), so a stroke can be +/// applied as it grows; the stroke's end, when it is off the spacing, is the [`Self::tail`]. +#[derive(Clone, Debug, Default)] +pub struct Dabber { + spacing: f64, + /// Every dab's intensity; None: each sample's pressure. + intensity: Option, + /// A repeated sample is one more dab there (the brush held still). + holds: bool, + samples: Vec, + /// Distance from the last dab to the last sample. + carry: f64, + /// The dabs on the spacing so far. + pub dabs: Vec, +} + +impl Dabber { + /// The dabs of a stroke with brush `prm`. + pub fn new(prm: &LiquifyParams) -> Self { + Self::with(prm.dab_spacing(), (!prm.use_pressure).then_some(prm.intensity), prm.kind.holds()) + } + + fn with(spacing: f64, intensity: Option, holds: bool) -> Self { + Self { spacing, intensity, holds, ..Self::default() } + } + + /// The samples so far. + pub fn samples(&self) -> &[Sample] { + &self.samples + } + + fn dab(&self, c: Point, pressure: f64) -> Dab { + Dab { c, intensity: self.intensity.unwrap_or(pressure.clamp(0.0, 1.0)) } + } + + /// Add a sample, and the dabs up to it. + pub fn push(&mut self, (b, pb): Sample) { + let prev = self.samples.last().copied(); + self.samples.push((b, pb)); + let Some((a, pa)) = prev else { + self.dabs.push(self.dab(b, pb)); + return; + }; + if self.dabs.len() > MAX_DABS { + return; + } let len = a.distance(b); if len < 1e-12 { - continue; + if self.holds { + self.dabs.push(self.dab(b, pb)); + self.carry = 0.0; + } + return; } - let mut s = spacing - carry; + let mut s = self.spacing - self.carry; while s <= len { - last = a.lerp(b, s / len); - out.push(last); - s += spacing; - } - carry = len - (s - spacing); - if out.len() > 20_000 { - break; + let t = s / len; + self.dabs.push(self.dab(a.lerp(b, t), pa + (pb - pa) * t)); + s += self.spacing; } + self.carry = len - (s - self.spacing); } - if let Some(&end) = points.last() - && end.distance(last) > spacing * 0.25 - { - out.push(end); + + /// The dab at the last sample when it is off the spacing (the stroke ends there). + pub fn tail(&self) -> Option { + let &(end, p) = self.samples.last()?; + let last = self.dabs.last()?.c; + (end.distance(last) > self.spacing * 0.25).then(|| self.dab(end, p)) } - out } fn hash(mut x: u64) -> u64 { @@ -414,37 +528,100 @@ fn simplify(sp: &mut SubPath, tol: f64, region: Rect) { } } -/// Apply a liquify stroke to `path`. `salt` decorrelates noise between paths. Returns whether -/// anything moved. +/// Apply a liquify stroke (its dabs, at the brush's intensity) to `path`. `salt` decorrelates +/// noise between paths. Returns whether anything moved. pub fn apply_stroke(path: &mut PathData, dab_pts: &[Point], prm: &LiquifyParams, salt: u64) -> bool { - let spacing = prm.detail_spacing(); - let mut changed = false; - let mut region: Option = None; - for (si, sp) in path.subpaths.iter_mut().enumerate() { - let salt = hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1)); - for (di, c) in dab_pts.iter().enumerate() { - let prev = if di == 0 { *c } else { dab_pts[di - 1] }; - let bb = prm.brush_bounds(*c); - let Some(spb) = sp_bounds(sp) else { continue }; - if spb.intersect(bb).area() <= 0.0 && !(spb.width() == 0.0 || spb.height() == 0.0) { - continue; - } - subdivide(sp, prm, *c, spacing); - if apply_dab(sp, prm, *c, prev, di as u64, salt) { - changed = true; - region = Some(region.map_or(bb, |r| r.union(bb))); - } + let dabs: Vec = dab_pts.iter().map(|c| Dab { c: *c, intensity: prm.intensity }).collect(); + let mut ps = PathStroke::new(std::mem::take(path), salt); + ps.advance(&dabs, prm); + match ps.finish(&dabs, None, prm) { + Some(done) => { + *path = done; + true + } + None => { + *path = ps.path; + false } } - if changed && let Some(r) = region { - let tol = prm.simplify / 100.0 * spacing * 0.02; - for sp in &mut path.subpaths { - simplify(sp, tol, r); +} + +/// Does the brush box `bb` reach subpath `sp`? +fn touches(sp: &SubPath, bb: Rect) -> bool { + sp_bounds(sp).is_some_and(|spb| spb.intersect(bb).area() > 0.0 || spb.width() == 0.0 || spb.height() == 0.0) +} + +/// Apply dab `d` (number `di`, after the dab at `prev`) to every subpath of `path` it reaches. +fn dab_on(path: &mut PathData, di: usize, d: Dab, prev: Point, prm: &LiquifyParams, salt: u64) -> bool { + let prm = LiquifyParams { intensity: d.intensity, ..*prm }; + let (bb, spacing) = (prm.brush_bounds(d.c), prm.detail_spacing()); + let mut changed = false; + for (si, sp) in path.subpaths.iter_mut().enumerate() { + if !touches(sp, bb) { + continue; } + subdivide(sp, &prm, d.c, spacing); + changed |= apply_dab(sp, &prm, d.c, prev, di as u64, hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1))); } changed } +/// A path under a growing stroke: the dabs applied so far (Simplify comes at [`Self::finish`]). +/// Applying a stroke's dabs in any number of steps gives the same path as applying them at once. +#[derive(Clone, Debug)] +pub struct PathStroke { + path: PathData, + /// Decorrelates noise between paths. + salt: u64, + /// Dabs applied. + applied: usize, + /// Where dabs moved something (Simplify works there), None while nothing moved. + region: Option, +} + +impl PathStroke { + pub fn new(path: PathData, salt: u64) -> Self { + Self { path, salt, applied: 0, region: None } + } + + /// Apply the stroke's dabs not applied yet. + pub fn advance(&mut self, dabs: &[Dab], prm: &LiquifyParams) { + for di in self.applied..dabs.len() { + let Some(&d) = dabs.get(di) else { break }; + let prev = di.checked_sub(1).and_then(|i| dabs.get(i)).map_or(d.c, |p| p.c); + if dab_on(&mut self.path, di, d, prev, prm, self.salt) { + let bb = prm.brush_bounds(d.c); + self.region = Some(self.region.map_or(bb, |r| r.union(bb))); + } + } + self.applied = self.applied.max(dabs.len()); + } + + /// The path after the stroke (`dabs`, all applied, then `tail`), simplified; None when + /// nothing moved. + pub fn finish(&self, dabs: &[Dab], tail: Option, prm: &LiquifyParams) -> Option { + let mut region = self.region; + let mut out = None; + if let Some(t) = tail.filter(|t| self.path.subpaths.iter().any(|sp| touches(sp, prm.brush_bounds(t.c)))) { + let mut p = self.path.clone(); + if dab_on(&mut p, dabs.len(), t, dabs.last().map_or(t.c, |d| d.c), prm, self.salt) { + let bb = prm.brush_bounds(t.c); + region = Some(region.map_or(bb, |r| r.union(bb))); + } + out = Some(p); + } + let region = region?; + let mut path = out.unwrap_or_else(|| self.path.clone()); + let tol = prm.simplify_tolerance(); + if tol > 0.0 { + for sp in &mut path.subpaths { + simplify(sp, tol, region); + } + } + Some(path) + } +} + 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()?; @@ -453,30 +630,57 @@ fn sp_bounds(sp: &SubPath) -> Option { // ---------- the tool ---------- +/// An Alt-drag sizing the brush: where it started and the brush size then. +#[derive(Clone, Copy, Debug)] +struct Sizing { + start: Point, + width: f64, + height: f64, +} + pub struct LiquifyTool { pub params: LiquifyParams, - points: Vec, + /// Show Brush Size: the brush outline follows the pointer. + pub show_brush: bool, + /// The stroke's samples (position, pen pressure). + points: Vec, active: bool, - /// Alt-drag resizes the brush from this centre. - sizing: Option, + sizing: Option, hover: Option, } impl LiquifyTool { pub fn new(id: &str) -> Self { let kind = LiquifyKind::parse(id).unwrap_or(LiquifyKind::Warp); - Self { params: LiquifyParams::new(kind), points: vec![], active: false, sizing: None, hover: None } + Self { params: LiquifyParams::new(kind), show_brush: true, points: vec![], active: false, sizing: None, hover: None } } - /// The command + params for the stroke so far. + /// The command + params for the stroke so far (with each sample's pressure while Use Pressure + /// Pen is on). pub fn command(&self, cx: &ToolContext) -> (String, Value) { let mut v = self.params.to_json(); - v["points"] = Value::Array(self.points.iter().map(|p| json!([p.x, p.y])).collect()); + let pressure = self.params.use_pressure; + v["points"] = Value::Array(self.points.iter().map(|(p, f)| if pressure { json!([p.x, p.y, f]) } else { json!([p.x, p.y]) }).collect()); if !cx.selection.is_empty() { v["ids"] = crate::json_ids(&cx.selection.objects); } ("object.liquify".into(), v) } + + /// Alt-drag from `s` to `p`: the brush grows or shrinks from its size at the press (its edge + /// follows the pointer); with Shift it keeps its proportions. + fn resize(&mut self, s: Sizing, p: Point, proportional: bool) { + let (w, h) = (s.width + 2.0 * (p.x - s.start.x), s.height + 2.0 * (p.y - s.start.y)); + let (w, h) = if !proportional { + (w, h) + } else if (p.x - s.start.x).abs() >= (p.y - s.start.y).abs() { + (w, s.height * w / s.width) + } else { + (s.width * h / s.height, h) + }; + self.params.width = w.clamp(1.0, 10_000.0); + self.params.height = h.clamp(1.0, 10_000.0); + } } impl Tool for LiquifyTool { @@ -486,28 +690,28 @@ impl Tool for LiquifyTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; self.hover = Some(p); + let sample = (p, f64::from(ev.pressure).clamp(0.0, 1.0)); match ev.kind { PointerKind::Down if ev.mods.alt => { - self.sizing = Some(p); + self.sizing = Some(Sizing { start: p, width: self.params.width, height: self.params.height }); vec![] } PointerKind::Down => { - self.points = vec![p]; + self.points = vec![sample]; self.active = true; let (c, v) = self.command(cx); vec![Action::Begin(self.params.kind.label().into()), Action::Preview(c, v)] } PointerKind::Drag => { - if let Some(c) = self.sizing { - self.params.width = ((p.x - c.x).abs() * 2.0).max(1.0); - self.params.height = if ev.mods.shift { self.params.width } else { ((p.y - c.y).abs() * 2.0).max(1.0) }; - self.hover = Some(c); + if let Some(s) = self.sizing { + self.resize(s, p, ev.mods.shift); + self.hover = Some(s.start); return vec![]; } - if !self.active || self.points.last().is_some_and(|l| l.distance(p) < cx.tol(2.0)) { + if !self.active || self.points.last().is_some_and(|(l, _)| l.distance(p) < cx.tol(2.0)) { return vec![]; } - self.points.push(p); + self.points.push(sample); let (c, v) = self.command(cx); vec![Action::Preview(c, v)] } @@ -526,7 +730,7 @@ impl Tool for LiquifyTool { } } fn overlays(&self, _cx: &ToolContext) -> Vec { - let Some(c) = self.hover else { return vec![] }; + let Some(c) = self.hover.filter(|_| self.show_brush || self.sizing.is_some()) else { return vec![] }; vec![Overlay::Path { path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle), color: [0x80, 0x80, 0x80], @@ -537,18 +741,28 @@ impl Tool for LiquifyTool { fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { Cursor::Crosshair } + /// Every option ([`LiquifyParams::options_json`]) and `showBrush`. fn options(&self) -> Value { - self.params.options_json() + let mut v = self.params.options_json(); + v["showBrush"] = json!(self.show_brush); + v } fn set_option(&mut self, key: &str, value: &Value) { - let mut v = self.params.to_json(); - v[key] = value.clone(); - if key == "diameter" { - v["width"] = value.clone(); - v["height"] = value.clone(); - } - if let Some(p) = LiquifyParams::from_json(&v) { - self.params = p; + match key { + // The kind is the tool's: another tool is another tool. + "tool" => {} + "showBrush" => self.show_brush = value.as_bool().unwrap_or(self.show_brush), + _ => { + let mut v = self.params.options_json(); + v[key] = value.clone(); + if key == "diameter" { + v["width"] = value.clone(); + v["height"] = value.clone(); + } + if let Some(p) = LiquifyParams::from_json(&v) { + self.params = p; + } + } } } fn busy(&self) -> bool { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 6030bfa2..b8545bb0 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -92,6 +92,10 @@ impl PointerEvent { self.mods = m; self } + /// The pen `pressure` of a pointer event given as JSON (control channel, MCP): 0..1, default 1. + 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) + } } /// Keys tools care about. diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 38c92b3e..ef6a1ccf 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -19,8 +19,8 @@ const SYMBOLISM: &[&str] = const ROWS: &[Row] = &[ // Global Brush Dimensions. - (LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity"]), - (LIQUIFY, None, &["detail", "simplify", "rate", "complexity", "horizontal", "vertical"]), + (LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity", "usePressure", "showBrush"]), + (LIQUIFY, None, &["detail", "simplify", "simplifyOn", "rate", "complexity", "horizontal", "vertical", "affectAnchors", "affectIn", "affectOut"]), (SYMBOLISM, Some("symbolism"), &["diameter", "intensity", "density"]), (&["mirrorCut"], None, &["axis", "keep"]), (&["puppetWarp"], None, &["showMesh", "expand"]), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6766c5bb..b855523f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -92,6 +92,22 @@ fn temp_tool_id() -> egui::Id { egui::Id::new("canvas-temp-tool") } +/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch +/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none +/// until a pen reports it). A mouse presses fully (1). +fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { + let id = egui::Id::new("canvas-pressure"); + let force = ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) + }); + let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::(id)) }).unwrap_or(1.0); + ui.data_mut(|d| d.insert_temp(id, p)); + p +} + pub fn mods(m: egui::Modifiers, space: bool) -> Mods { Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space } } @@ -436,7 +452,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: app.select_tool("selection"); kind = Drag::TempSelect; } - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; dispatch(app, &ev, view); kind }; @@ -473,7 +489,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::Tool | Drag::TempSelect => { if pointer.delta() != egui::Vec2::ZERO { - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } } @@ -499,7 +515,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::Tool | Drag::TempSelect | Drag::Art { .. } => { if !matches!(d, Drag::Art { .. }) { - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } if matches!(d, Drag::TempSelect | Drag::Art { temp: true }) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c793b14e..c4e6f51e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -502,6 +502,30 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { ("Delete", true), (" to remove pins", false), ], + "warp" => &[ + ("Drag", true), + (" across paths to push them along | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "twirl" | "pucker" | "bloat" => &[ + ("Click or drag", true), + (" over paths to reshape them | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "scallop" | "crystallize" | "wrinkle" => &[ + ("Click or drag", true), + (" over paths to roughen their outlines | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], _ => return None, }) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c227a8c2..e1d2a54d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -144,7 +144,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques vectorcraft_geom::Point::new(x, y) }; 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: 1.0 }, view); + crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); } 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 new file mode 100644 index 00000000..ec3aacac --- /dev/null +++ b/crates/ui-egui/src/dialogs/liquify.rs @@ -0,0 +1,216 @@ +//! Liquify Tool Options (double-click a Liquify tool: Warp, Twirl, Pucker, Bloat, Scallop, +//! Crystallize, Wrinkle): the Global Brush Dimensions the seven tools share, the tool's own options +//! and Show Brush Size. OK sets them with `tool.setOption {tool, values}` (they last across tool +//! switches and are saved with the preferences). +//! +//! Fields: `tool`; `width`, `height` (points), `angle` (degrees), `intensity` (%), `usePressure`; +//! `detail` (1–10); `simplify` (1–100) and `simplifyOn` (Warp, Twirl, Pucker, Bloat); `rate` +//! (Twirl, −180–180°); `complexity` (0–15), `affectAnchors`, `affectIn`, `affectOut` (Scallop, +//! Crystallize, Wrinkle); `horizontal`, `vertical` (Wrinkle, %); `showBrush`. + +use serde_json::{Map, Value, json}; +use vectorcraft_tools::distort::liquify::LiquifyKind; + +use super::document_setup::check; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the Liquify Tool Options. +pub const KIND: &str = "liquifyOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// Width of the label column. +const LABEL_W: f32 = 104.0; + +/// The options shown as percentages (the tool keeps them as 0..1). +const PERCENT: [&str; 3] = ["intensity", "horizontal", "vertical"]; + +/// The options the dialog edits (the tool's options; `tool` names it). +const KEYS: [&str; 16] = [ + "width", + "height", + "angle", + "intensity", + "usePressure", + "detail", + "simplify", + "simplifyOn", + "rate", + "complexity", + "horizontal", + "vertical", + "affectAnchors", + "affectIn", + "affectOut", + "showBrush", +]; + +fn kind(d: &Dialog) -> LiquifyKind { + LiquifyKind::parse(&d.str("tool")).unwrap_or(LiquifyKind::Warp) +} + +fn heading(d: &Dialog) -> String { + format!("{} Tool Options", kind(d).label()) +} + +/// Open the options of Liquify tool `tool` with its current values. +pub fn open(app: &mut VectorcraftApp, tool: &str) -> Result { + let k = LiquifyKind::parse(tool).ok_or_else(|| format!("`{tool}` isn't a Liquify tool"))?; + let o = app.session.tool_options_of(k.id()); + let mut fields: Map = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect(); + for key in PERCENT { + let v = fields.get(key).and_then(Value::as_f64).unwrap_or(0.0); + fields.insert(key.into(), json!((v * 100.0).round())); + } + fields.insert("tool".into(), json!(k.id())); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(json!({ "dialog": KIND })) +} + +/// A slider row for the whole number `d.fields[key]`. +fn slider(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), range: std::ops::RangeInclusive, suffix: &str) { + let rail = Tokens::get(ui.ctx()).input_border; + form::slider_w(ui, d, (key, label, LABEL_W), range, suffix, &|_| rail); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let k = kind(d); + let unit = app.session.general_unit(); + widgets::subheader(ui, "Global Brush Dimensions"); + ui.add_space(4.0); + for (key, label) in [("width", "Width:"), ("height", "Height:")] { + widgets::label_row(ui, label, LABEL_W, |ui| { + form::length(ui, d, key, unit, 110.0); + }); + } + widgets::label_row(ui, "Angle:", LABEL_W, |ui| { + if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) { + d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0))); + } + }); + let pressure = d.bool("usePressure"); + widgets::label_row(ui, "Intensity:", LABEL_W, |ui| { + ui.add_enabled_ui(!pressure, |ui| { + if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) { + d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0))); + } + }); + }); + widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "usePressure", "Use Pressure Pen")); + ui.add_space(10.0); + widgets::subheader(ui, &format!("{} Options", k.label())); + ui.add_space(4.0); + if k == LiquifyKind::Twirl { + slider(ui, d, ("rate", "Twirl Rate:"), -180.0..=180.0, "°"); + } + if k == LiquifyKind::Wrinkle { + slider(ui, d, ("horizontal", "Horizontal:"), 0.0..=100.0, "%"); + slider(ui, d, ("vertical", "Vertical:"), 0.0..=100.0, "%"); + } + if k.has_affects() { + slider(ui, d, ("complexity", "Complexity:"), 0.0..=15.0, ""); + } + slider(ui, d, ("detail", "Detail:"), 1.0..=10.0, ""); + if k.simplifies() { + widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "simplifyOn", "Simplify")); + let on = d.bool("simplifyOn"); + ui.add_enabled_ui(on, |ui| slider(ui, d, ("simplify", "Simplify:"), 1.0..=100.0, "")); + } else { + ui.add_space(4.0); + widgets::label_row(ui, "Brush Affects:", LABEL_W, |ui| { + ui.vertical(|ui| { + for (key, label) in [("affectAnchors", "Anchor Points"), ("affectIn", "In Tangent Handles"), ("affectOut", "Out Tangent Handles")] { + check(ui, d, key, label); + } + }); + }); + } + ui.add_space(10.0); + check(ui, d, "showBrush", "Show Brush Size"); + let hint = format!("{} with the tool sizes the brush", crate::menus::pretty_shortcut("Alt+Drag")); + ui.label(egui::RichText::new(hint).size(11.5).color(Tokens::get(ui.ctx()).text_dim)); + false +} + +/// OK: set the tool's options (percentages back to 0..1). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut values: Map = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + for key in PERCENT { + if values.contains_key(key) { + values.insert(key.into(), json!(d.f64(key, 0.0) / 100.0)); + } + } + run_and_close(app, "tool.setOption", json!({ "tool": kind(d).id(), "values": values })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app + } + + #[test] + fn each_tool_shows_its_options_and_ok_sets_them() { + let mut app = app(); + app.select_tool("twirl"); + app.run("tool.options", json!({"tool": "twirl"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + for label in [ + "Twirl Tool Options", + "Global Brush Dimensions", + "Width:", + "Height:", + "Angle:", + "Intensity:", + "Use Pressure Pen", + "Twirl Options", + "Twirl Rate:", + "Detail:", + "Simplify", + "Show Brush Size", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + assert!(!text.contains("Brush Affects:") && !text.contains("Complexity:"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.f64("intensity", 0.0), d.f64("rate", 0.0), d.bool("showBrush")), (50.0, 40.0, true)); + for (k, v) in + [("width", json!(80)), ("intensity", json!(30)), ("rate", json!(-120)), ("simplifyOn", json!(false)), ("showBrush", json!(false))] + { + d.fields.insert(k.into(), v); + } + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let o = app.session.tool_options(); + assert_eq!((o["width"].as_f64(), o["intensity"].as_f64(), o["rate"].as_f64()), (Some(80.0), Some(0.3), Some(-120.0))); + assert_eq!((o["simplifyOn"].as_bool(), o["showBrush"].as_bool()), (Some(false), Some(false))); + // The brush is shared: the Warp tool has it too, though it is not the active tool. + app.run("tool.options", json!({"tool": "warp"})).unwrap(); + assert_eq!((app.ui.dialog.as_ref().unwrap().f64("width", 0.0), app.ui.dialog.as_ref().unwrap().f64("intensity", 0.0)), (80.0, 30.0)); + } + + #[test] + fn scallop_crystallize_and_wrinkle_have_brush_affects_and_no_simplify() { + let mut app = app(); + for (tool, extra) in [("scallop", None), ("crystallize", None), ("wrinkle", Some("Horizontal:"))] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + for label in ["Complexity:", "Detail:", "Brush Affects:", "Anchor Points", "In Tangent Handles", "Out Tangent Handles"] { + assert!(text.contains(label), "{tool}: {label} in {text}"); + } + assert!(!text.contains("Simplify"), "{tool}: {text}"); + assert_eq!(text.contains("Horizontal:"), extra.is_some(), "{tool}"); + app.ui.dialog.as_mut().unwrap().fields.insert("affectIn".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.tool_options_of(tool)["affectIn"], json!(false), "{tool}"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index c80d0d2c..3d4adef6 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -32,6 +32,7 @@ mod gradient_stop; pub mod graphic_style_options; pub mod halftone; pub mod import_pdf; +pub mod liquify; pub mod missing_links; pub mod new_color_group; mod new_document; @@ -253,6 +254,7 @@ registry! { VectorHalftone: [halftone::KIND] => halftone::SPEC, PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, + LiquifyOptions: [liquify::KIND] => liquify::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4372693e..e1ae915d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -233,7 +233,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true})", + "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values})", ), ( "ui.colorGuideOptions", diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b302aaeb..44a2ca21 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -200,8 +200,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are -/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog; the Print Tiling tool's -/// resets the print tiling. +/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool +/// Options dialog; the Print Tiling tool's resets the print tiling. pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result { match tool { "gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })), @@ -212,6 +212,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result app.run("print.tiling.set", json!({ "reset": true })), + // The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own). + _ if vectorcraft_tools::settings::LIQUIFY.contains(&tool) => crate::dialogs::liquify::open(app, tool), _ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")), _ => Err(format!("the {tool} tool has no options")), } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a6ce8dca..7592b9ef 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -472,3 +472,10 @@ Mesh… open `envelopeMesh` (`rows`, `cols`, `reset`, and `maintainShape` when r `distortLinearGradients`, `distortPatternFills`) with the selected envelope's values, or with nothing selected the defaults for new envelopes. They preview live while an envelope is involved, and `ui.dialog.confirm` keeps the result as one undo step. + +Liquify Tool Options: double-clicking a Liquify tool (`tool.options {tool: "twirl"}`) opens a `liquifyOptions` dialog. +Its fields are `tool`, the Global Brush Dimensions `width`, `height` (pt), `angle`, `intensity` (%) and `usePressure`, +then the tool's options `detail`, `simplify` and `simplifyOn` (Warp, Twirl, Pucker, Bloat), `rate` (Twirl), +`complexity`, `affectAnchors`, `affectIn` and `affectOut` (Scallop, Crystallize, Wrinkle), `horizontal` and +`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. diff --git a/docs/mcp.md b/docs/mcp.md index d20db141..f7eb3f25 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1799,3 +1799,21 @@ export (SVG, PDF, EPS, EMF/WMF, DXF). {"name":"run_command","arguments":{"command":"object.envelope.options","params":{"fidelity":80,"distortAppearance":true,"distortLinearGradients":true}}} {"name":"run_command","arguments":{"command":"object.envelope.info","params":{}}} ``` + +## Liquify tools + +The Liquify tools (`warp`, `twirl`, `pucker`, `bloat`, `scallop`, `crystallize`, `wrinkle`) drag a brush over paths: +the selected ones, or with nothing selected every path the brush passes over. A stroke is one undo step and one +`object.liquify` journal entry, which an agent can also run directly. Their options (`tool.setOption`) are the Global +Brush Dimensions shared by the seven tools (`width`, `height` in pt, `angle`, `intensity` 0..1, `usePressure`, +`showBrush`) and the tool's own: `detail` (1..10); `simplify` (0..100) with `simplifyOn` (Warp, Twirl, Pucker, +Bloat); `rate` (Twirl, -180..180°); `complexity` (0..15) and `affectAnchors`, `affectIn`, `affectOut` (Scallop, +Crystallize, Wrinkle: what the brush moves); `horizontal`, `vertical` (Wrinkle, 0..1). Alt-drag sizes the brush from +its current size (with Shift too it keeps its proportions). With Use Pressure Pen on, each `pointer_gesture` event's +`pressure` (0..1) is the intensity there, and `object.liquify` takes points as `[x, y, pressure]`. + +```json +{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"bloat","values":{"width":80,"height":80,"usePressure":true}}}} +{"name":"pointer_gesture","arguments":{"tool":"bloat","events":[{"kind":"down","x":280,"y":150,"pressure":0.3},{"kind":"drag","x":280,"y":200,"pressure":0.8},{"kind":"up","x":280,"y":250}]}} +{"name":"run_command","arguments":{"command":"object.liquify","params":{"tool":"scallop","points":[[300,150],[300,250]],"complexity":3,"affectAnchors":false}}} +```