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:
parent
4950cc17cf
commit
92cadd6083
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()})))
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
18
docs/mcp.md
18
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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue