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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-06 10:02:11 +07:00
parent 4950cc17cf
commit 92cadd6083
20 changed files with 802 additions and 101 deletions

View File

@ -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<CommandSpec> {
"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<Vec<Point>> {
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<Vec<Sample>> {
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<NodeId> {
let mut out = vec![];
@ -344,11 +353,16 @@ fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
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<Point> = 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<Value> {
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)

View File

@ -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;

View File

@ -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);
}

View File

@ -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 {

View File

@ -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)?;
}

View File

@ -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;

View File

@ -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);
}

View File

@ -127,6 +127,7 @@ pub fn tool_definitions() -> Vec<Value> {
"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(),

View File

@ -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<Point> {
let mut out: Vec<Point> = 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<f64>,
/// A repeated sample is one more dab there (the brush held still).
holds: bool,
samples: Vec<Sample>,
/// Distance from the last dab to the last sample.
carry: f64,
/// The dabs on the spacing so far.
pub dabs: Vec<Dab>,
}
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<f64>, 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<Dab> {
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<Rect> = 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> = 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<Rect>,
}
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<Dab>, prm: &LiquifyParams) -> Option<PathData> {
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<Rect> {
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<Rect> {
// ---------- 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<Point>,
/// Show Brush Size: the brush outline follows the pointer.
pub show_brush: bool,
/// The stroke's samples (position, pen pressure).
points: Vec<Sample>,
active: bool,
/// Alt-drag resizes the brush from this centre.
sizing: Option<Point>,
sizing: Option<Sizing>,
hover: Option<Point>,
}
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<Action> {
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<Overlay> {
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 {

View File

@ -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.

View File

@ -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"]),

View File

@ -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::<f32>(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 })

View File

@ -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,
})
}

View File

@ -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()})))

View File

@ -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<Value, String> {
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<String, Value> = 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<f64>, 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<Value, String> {
let mut values: Map<String, Value> = 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}");
}
}
}

View File

@ -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).

View File

@ -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",

View File

@ -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<serde_json::Value, String> {
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<serde_json::
}
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
"printTiling" => 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")),
}

View File

@ -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.

View File

@ -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}}}
```