Files
vectorcraft/crates/engine/src/tests/liquify.rs
T

352 lines
17 KiB
Rust

//! 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, Point};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use crate::*;
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);
}
// ---------- 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));
}