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