From 92cadd60831266ab84cfa4f4d770656e437f3129 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:02:11 +0700 Subject: [PATCH 1/2] 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}}} +``` From aa5b544fca30c9c7bf35a79e244bb44475f2c8af Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:50:23 +0700 Subject: [PATCH 2/2] ROADMAP: M8.3 landed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a30a6b42..905ac228 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar) | 8–12 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects) | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |