mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
747 lines
41 KiB
Rust
747 lines
41 KiB
Rust
//! Point-level path edits, what Photoshop's Direct Selection (A), Convert Point and Add / Delete
|
||
//! Anchor Point tools do on the canvas: move anchors, drag direction handles, bend segments,
|
||
//! convert points, and insert or remove anchors. The
|
||
//! geometry is `photocraft_vector::edit`; the canvas previews a drag with it and commits the
|
||
//! whole drag as one command (one history step).
|
||
//!
|
||
//! Every command targets a path like `path.transform`: `"name":"work"` (default), a saved path's
|
||
//! name, or `"layer"` with `"layer":id?` for a shape layer's path (re-rendered; it stops being a
|
||
//! live shape), a layer's vector mask, or type on a path's Type Path (`"typePath":true` when the
|
||
//! type layer also has a vector mask). Knots are addressed by `subpath` and `knot` index, as
|
||
//! listed by `path.info`, and the result is `{name, path}` in the compact JSON form.
|
||
|
||
use photocraft_doc::{LayerContent, Path, PathOp};
|
||
use photocraft_geom::Point;
|
||
use photocraft_vector::edit::{self, Handle};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::commands::CommandSpec;
|
||
use crate::vector_cmds::{bad, has_doc, layer_id, nums, path_json, path_mut, pt, with_shape};
|
||
use crate::{Result, Session};
|
||
|
||
/// Coordinates and offsets beyond this are mistakes (the same bound as `path.transform`).
|
||
const MAX: f64 = 1_000_000.0;
|
||
|
||
fn finite(v: [f64; 2]) -> bool {
|
||
v.iter().all(|c| c.is_finite() && c.abs() <= MAX)
|
||
}
|
||
|
||
fn offset(cmd: &str, p: &Value) -> Result<[f64; 2]> {
|
||
nums::<2>(p, "move").filter(|d| finite(*d)).ok_or_else(|| bad(cmd, "`move` must be [dx, dy] with finite numbers of magnitude at most 1000000"))
|
||
}
|
||
|
||
fn point(cmd: &str, p: &Value, key: &str) -> Result<Option<Point>> {
|
||
p.get(key)
|
||
.map(|v| {
|
||
pt(v).filter(|q| finite([q.x, q.y])).ok_or_else(|| bad(cmd, format!("`{key}` must be [x, y] with finite numbers of magnitude at most 1000000")))
|
||
})
|
||
.transpose()
|
||
}
|
||
|
||
fn index(cmd: &str, p: &Value, key: &str) -> Result<usize> {
|
||
p.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, format!("missing `{key}` index")))
|
||
}
|
||
|
||
fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> {
|
||
Ok([index(cmd, p, "subpath")?, index(cmd, p, "knot")?])
|
||
}
|
||
|
||
/// Applies `f` to the targeted path in one undoable step labelled `label`.
|
||
fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result<Value> {
|
||
// A malformed target is an error, never a silent edit of the Work Path or the active layer.
|
||
let name = match p.get("name") {
|
||
Some(v) => v.as_str().ok_or_else(|| bad(cmd, "`name` must be a path name, \"work\" or \"layer\""))?,
|
||
None => "work",
|
||
}
|
||
.to_owned();
|
||
if name == "layer" && p.get("layer").is_some_and(|v| !v.is_u64()) {
|
||
return Err(bad(cmd, "`layer` must be a layer id"));
|
||
}
|
||
let layer = if name == "layer" { Some(layer_id(s, p)?) } else { None };
|
||
// Type on a path: its own path, the text laid along it again (#9114). A vector mask on the
|
||
// layer stays what `"layer"` edits, as with `path.info`, unless `"typePath": true`.
|
||
let type_path = p.get("typePath").and_then(Value::as_bool).unwrap_or(false);
|
||
let typed = layer.filter(|id| {
|
||
s.active().and_then(|d| d.doc.layer(*id)).is_some_and(|l| {
|
||
matches!(&l.content, LayerContent::Text(t) if crate::type_extra_cmds::type_path(t).is_some()) && (type_path || l.vector_mask.is_none())
|
||
})
|
||
});
|
||
if let Some(id) = typed {
|
||
let path = crate::type_extra_cmds::with_text(s, &json!({"layer": id.0}), label, |t, _| {
|
||
let mut path = crate::type_extra_cmds::type_path(t).ok_or_else(|| bad(cmd, "the type layer is not on a path"))?;
|
||
f(&mut path).map_err(|e| bad(cmd, e))?;
|
||
crate::type_extra_cmds::set_type_path(t, &path)?;
|
||
Ok(path_json(&path))
|
||
})?;
|
||
return Ok(json!({ "name": name, "path": path }));
|
||
}
|
||
let shape = layer.filter(|id| s.active().and_then(|d| d.doc.layer(*id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))));
|
||
let path = match shape {
|
||
Some(id) => with_shape(s, id, label, |sh, _| {
|
||
f(&mut sh.path).map_err(|e| bad(cmd, e))?;
|
||
sh.live = None;
|
||
sh.psd_raw = None;
|
||
Ok(path_json(&sh.path))
|
||
})?,
|
||
None => s.edit(label, |doc, _| {
|
||
let path = path_mut(doc, &name, layer)?;
|
||
f(path).map_err(|e| bad(cmd, e))?;
|
||
Ok(path_json(path))
|
||
})?,
|
||
};
|
||
Ok(json!({ "name": name, "path": path }))
|
||
}
|
||
|
||
fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.moveAnchors";
|
||
let anchors: Vec<[usize; 2]> = p
|
||
.get("anchors")
|
||
.and_then(Value::as_array)
|
||
.filter(|a| !a.is_empty())
|
||
.ok_or_else(|| bad(CMD, "`anchors` must list [subpath, knot] pairs"))?
|
||
.iter()
|
||
.map(|a| {
|
||
let n = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
|
||
n(0).zip(n(1)).map(|(s, k)| [s, k]).ok_or_else(|| bad(CMD, "each anchor is [subpath, knot]"))
|
||
})
|
||
.collect::<Result<_>>()?;
|
||
let d = offset(CMD, p)?;
|
||
if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) {
|
||
return edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors_curvature(path, &anchors, d));
|
||
}
|
||
edit_path(s, CMD, p, "Drag Anchor Points", |path| edit::move_anchors(path, &anchors, d))
|
||
}
|
||
|
||
fn move_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.moveHandle";
|
||
let k = knot(CMD, p)?;
|
||
let h = p.get("handle").and_then(Value::as_str).and_then(Handle::parse).ok_or_else(|| bad(CMD, "`handle` must be \"in\" or \"out\""))?;
|
||
let to = point(CMD, p, "to")?.ok_or_else(|| bad(CMD, "missing `to`"))?;
|
||
let independent = p.get("independent").and_then(Value::as_bool).unwrap_or(false);
|
||
edit_path(s, CMD, p, "Drag Direction Point", |path| edit::move_handle(path, k, h, to, independent))
|
||
}
|
||
|
||
fn bend_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.bendSegment";
|
||
let k = knot(CMD, p)?;
|
||
let t = match p.get("t") {
|
||
Some(v) => v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad(CMD, "`t` must be between 0 and 1"))?,
|
||
None => 0.5,
|
||
};
|
||
let d = offset(CMD, p)?;
|
||
edit_path(s, CMD, p, "Drag Segment", |path| edit::bend_segment(path, k, t, d))
|
||
}
|
||
|
||
fn convert_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.convertPoint";
|
||
let k = knot(CMD, p)?;
|
||
if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) {
|
||
return edit_path(s, CMD, p, "Convert Point", |path| edit::toggle_curvature_point(path, k));
|
||
}
|
||
let out = point(CMD, p, "out")?;
|
||
edit_path(s, CMD, p, "Convert Point", |path| edit::convert_point(path, k, out))
|
||
}
|
||
|
||
fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.addAnchor";
|
||
let k = knot(CMD, p)?;
|
||
let t = match p.get("t") {
|
||
Some(v) => v.as_f64().filter(|t| *t > 0.0 && *t < 1.0).ok_or_else(|| bad(CMD, "`t` must be strictly between 0 and 1"))?,
|
||
None => 0.5,
|
||
};
|
||
let mut added = None;
|
||
let mut r = edit_path(s, CMD, p, "Add Anchor Point", |path| {
|
||
added = Some(edit::add_anchor(path, k, t)?);
|
||
Ok(())
|
||
})?;
|
||
if let (Some(o), Some([sp, kn])) = (r.as_object_mut(), added) {
|
||
o.insert("anchor".into(), json!({"subpath": sp, "knot": kn}));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.deleteAnchor";
|
||
let k = knot(CMD, p)?;
|
||
let mut removed = false;
|
||
let mut r = edit_path(s, CMD, p, "Delete Anchor Point", |path| {
|
||
removed = edit::delete_anchor(path, k)?;
|
||
Ok(())
|
||
})?;
|
||
if let Some(o) = r.as_object_mut() {
|
||
o.insert("subpathRemoved".into(), json!(removed));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Merge Shape Components on a path (the Pen's and Path Selection's Path Operations menu): the
|
||
/// components' operations are baked into plain combined outlines, as for a shape layer.
|
||
fn merge_components(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.mergeShapeComponents";
|
||
let tol = match p.get("tolerance") {
|
||
Some(v) => v.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad(CMD, "`tolerance` must be a number"))?.clamp(0.05, 10.0),
|
||
None => 0.1,
|
||
};
|
||
let mask = p.get("name").and_then(Value::as_str) == Some("layer")
|
||
&& layer_id(s, p).ok().and_then(|id| s.active()?.doc.layer(id).map(|l| !matches!(l.content, LayerContent::Shape(_)))).unwrap_or(false);
|
||
edit_path(s, CMD, p, "Merge Shape Components", |path| {
|
||
let mut merged = crate::vector_cmds::bake_path(path, tol).map_err(|e| e.to_string())?;
|
||
// Components that cancel out cover nothing, but an empty vector mask reveals the whole
|
||
// layer (hides it when inverted): flip it so the mask still shows what it did.
|
||
if mask && merged.is_empty() && !path.is_empty() {
|
||
merged.inverted = !path.inverted;
|
||
}
|
||
*path = merged;
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// The Path Operations menu (or + / −) with path components selected: the components holding
|
||
/// `subpaths` take `op`, the way Photoshop changes the operation of the selected components. A
|
||
/// component's operation lives on its first subpath; the subpaths joined to it keep theirs. The
|
||
/// first component is what the others combine with and always draws as Combine
|
||
/// (`Path::effective_op`), so it keeps its operation; naming only it is refused.
|
||
fn set_operation(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "path.setOperation";
|
||
let op = p
|
||
.get("op")
|
||
.and_then(Value::as_str)
|
||
.and_then(crate::vector_cmds::op_from)
|
||
.filter(|op| *op != PathOp::Join)
|
||
.ok_or_else(|| bad(CMD, "`op` must be \"combine\", \"subtract\", \"intersect\" or \"exclude\""))?;
|
||
let subpaths: Vec<usize> = p
|
||
.get("subpaths")
|
||
.and_then(Value::as_array)
|
||
.filter(|a| !a.is_empty())
|
||
.ok_or_else(|| bad(CMD, "`subpaths` must list subpath indices"))?
|
||
.iter()
|
||
.map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "each subpath is an index")))
|
||
.collect::<Result<_>>()?;
|
||
edit_path(s, CMD, p, "Path Operation", |path| {
|
||
let components = path.components();
|
||
let mut heads = Vec::with_capacity(subpaths.len());
|
||
for i in &subpaths {
|
||
let c = components.iter().find(|c| c.contains(i)).ok_or_else(|| format!("no subpath {i} (the path has {})", path.subpaths.len()))?;
|
||
if c.start != 0 {
|
||
heads.push(c.start);
|
||
}
|
||
}
|
||
if heads.is_empty() {
|
||
return Err("the first component always combines: it is the shape the others act on".into());
|
||
}
|
||
for h in heads {
|
||
if let Some(sp) = path.subpaths.get_mut(h) {
|
||
sp.op = op;
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path, a vector mask, or else type on a path's Type Path),"typePath":bool=false (with "layer": the Type Path even when the type layer has a vector mask)"#;
|
||
let spec = |id: &'static str, label: &'static str, params: String, run: fn(&mut Session, &Value) -> Result<Value>| CommandSpec {
|
||
id,
|
||
label,
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: crate::vector_cmds::leak(format!("{{{TARGET},{params}}} → {{name,path}}")),
|
||
enabled: has_doc,
|
||
run,
|
||
journal: true,
|
||
};
|
||
vec![
|
||
spec("path.moveAnchors", "Drag Anchor Points", r#""anchors":[[subpath,knot],…],"move":[dx,dy],"curvature":bool=false (anchors move with their handles; Direct Selection drag. curvature = the Curvature Pen's drag: every smooth knot of the touched subpaths gets its handles recomputed so the curve flows through the anchors)"#.into(), move_anchors),
|
||
spec(
|
||
"path.moveHandle",
|
||
"Drag Direction Point",
|
||
r#""subpath":i,"knot":j,"handle":"in|out","to":[x,y],"independent":bool=false (a smooth point turns its other handle to stay collinear; independent = ⌥ / Convert Point, makes a corner)"#.into(),
|
||
move_handle,
|
||
),
|
||
spec(
|
||
"path.bendSegment",
|
||
"Drag Segment",
|
||
r#""subpath":i,"knot":j (the segment leaving it),"t":0..1=0.5 (where it was grabbed),"move":[dx,dy] (a straight segment moves with its anchors; a curve reshapes through the dragged point)"#.into(),
|
||
bend_segment,
|
||
),
|
||
spec(
|
||
"path.convertPoint",
|
||
"Convert Point",
|
||
r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror),"curvature":bool=false (the Curvature Pen's double-click: toggles smooth/corner and reflows the smooth knots of the subpath; out ignored)"#.into(),
|
||
convert_point,
|
||
),
|
||
spec(
|
||
"path.addAnchor",
|
||
"Add Anchor Point",
|
||
r#""subpath":i,"knot":j (the segment leaving it),"t":0<t<1=0.5 (where on the segment) (a curve is split there without changing its shape and gets a smooth anchor; a straight segment gets a corner) (the result also has anchor:{subpath,knot})"#.into(),
|
||
add_anchor,
|
||
),
|
||
spec(
|
||
"path.deleteAnchor",
|
||
"Delete Anchor Point",
|
||
r#""subpath":i,"knot":j (its neighbours are joined, keeping their handles; a subpath's last anchor removes the subpath; the path's only anchor is refused) (the result also has subpathRemoved:bool)"#.into(),
|
||
delete_anchor,
|
||
),
|
||
spec(
|
||
"path.mergeShapeComponents",
|
||
"Merge Shape Components",
|
||
r#""tolerance":px=0.1 (bakes the components' path operations into plain combined outlines, traced from the exact coverage)"#.into(),
|
||
merge_components,
|
||
),
|
||
spec(
|
||
"path.setOperation",
|
||
"Path Operation",
|
||
r#""subpaths":[i,…],"op":"combine|subtract|intersect|exclude" (the shape components holding these subpaths take the operation; the first component always combines and keeps its own; the Path Operations menu with components selected)"#.into(),
|
||
set_operation,
|
||
),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use photocraft_doc::{LayerId, PathOp, ShapeLayer};
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 120, "height": 100, "background": "white"})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn square() -> serde_json::Value {
|
||
json!({"subpaths": [{"closed": true, "knots": [[10, 10], [60, 10], [60, 60], [10, 60]]}]})
|
||
}
|
||
|
||
fn anchor(v: &serde_json::Value, k: usize) -> [f64; 2] {
|
||
serde_json::from_value(v["path"]["subpaths"][0]["knots"][k]["anchor"].clone()).unwrap()
|
||
}
|
||
|
||
fn work(s: &Session) -> photocraft_doc::Path {
|
||
s.active().unwrap().doc.work_path.clone().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn anchors_move_in_one_undoable_step() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let before = work(&s);
|
||
let r = s.execute("path.moveAnchors", json!({"anchors": [[0, 1], [0, 2]], "move": [5, -3]})).unwrap();
|
||
assert_eq!((anchor(&r, 1), anchor(&r, 2), anchor(&r, 0)), ([65.0, 7.0], [65.0, 57.0], [10.0, 10.0]));
|
||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Drag Anchor Points"));
|
||
assert!(s.undo());
|
||
assert_eq!(work(&s), before);
|
||
}
|
||
|
||
#[test]
|
||
fn a_curvature_anchor_drag_reflows_the_smooth_knots() {
|
||
let mut s = session();
|
||
let smooth = |a: [f64; 2]| json!({"anchor": a, "in": a, "out": a, "smooth": true});
|
||
let path = json!({"subpaths": [{"closed": true, "knots": [smooth([0.0, 50.0]), smooth([100.0, 0.0]), smooth([200.0, 50.0]), smooth([100.0, 100.0])]}]});
|
||
s.execute("path.set", json!({"path": path})).unwrap();
|
||
s.execute("path.moveAnchors", json!({"anchors": [[0, 1]], "move": [0, 60], "curvature": true})).unwrap();
|
||
let p = work(&s);
|
||
let pts = [[0.0, 50.0], [100.0, 60.0], [200.0, 50.0], [100.0, 100.0]];
|
||
let want = photocraft_vector::edit::curvature_knots(&pts, &[false; 4], true);
|
||
for (k, w) in p.subpaths[0].knots.iter().zip(want) {
|
||
assert_eq!([[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]], w);
|
||
}
|
||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Drag Anchor Points"));
|
||
assert!(s.execute("path.moveAnchors", json!({"anchors": [[0, 9]], "move": [1, 0], "curvature": true})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn a_curvature_convert_toggles_the_point_and_reflows() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let before = work(&s);
|
||
s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap();
|
||
let k = work(&s).subpaths[0].knots[1];
|
||
assert!(k.smooth && k.in_ctrl != k.anchor && k.out_ctrl != k.anchor, "{k:?}");
|
||
s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "curvature": true})).unwrap();
|
||
assert_eq!(work(&s), before);
|
||
assert!(s.execute("path.convertPoint", json!({"subpath": 0, "knot": 9, "curvature": true})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn a_coalesced_drag_is_one_history_step() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let steps = s.active().unwrap().history.entries().len();
|
||
for _ in 0..3 {
|
||
s.execute("path.moveAnchors", json!({"anchors": [[0, 0]], "move": [1, 0], "coalesce": "drag-1"})).unwrap();
|
||
}
|
||
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1);
|
||
assert_eq!(work(&s).subpaths[0].knots[0].anchor.x, 13.0);
|
||
}
|
||
|
||
#[test]
|
||
fn handles_segments_and_convert_point() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
// Pull smooth handles out of the corner at (60, 10), then turn one: the other follows.
|
||
s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1, "out": [70, 10]})).unwrap();
|
||
let r = s.execute("path.moveHandle", json!({"subpath": 0, "knot": 1, "handle": "out", "to": [60, 20]})).unwrap();
|
||
let k = &r["path"]["subpaths"][0]["knots"][1];
|
||
assert_eq!((k["in"].clone(), k["smooth"].clone()), (json!([60.0, 0.0]), json!(true)));
|
||
let r = s.execute("path.moveHandle", json!({"subpath": 0, "knot": 1, "handle": "in", "to": [50, 0], "independent": true})).unwrap();
|
||
let k = &r["path"]["subpaths"][0]["knots"][1];
|
||
assert_eq!((k["out"].clone(), k["smooth"].clone()), (json!([60.0, 20.0]), json!(false)));
|
||
// The straight bottom edge (2 → 3) drags with its anchors.
|
||
let r = s.execute("path.bendSegment", json!({"subpath": 0, "knot": 2, "move": [0, 8]})).unwrap();
|
||
assert_eq!((anchor(&r, 2), anchor(&r, 3)), ([60.0, 68.0], [10.0, 68.0]));
|
||
let r = s.execute("path.convertPoint", json!({"subpath": 0, "knot": 1})).unwrap();
|
||
assert_eq!(r["path"]["subpaths"][0]["knots"][1]["out"], json!([60.0, 10.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn shape_paths_and_vector_masks_are_edited_and_rerendered() {
|
||
for depth in [8, 16, 32] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 120, "height": 100, "depth": depth})).unwrap();
|
||
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||
let r = s.execute("path.moveAnchors", json!({"name": "layer", "layer": id, "anchors": [[0, 1], [0, 2]], "move": [30, 0]})).unwrap();
|
||
assert_eq!(anchor(&r, 1), [80.0, 10.0]);
|
||
let st = s.active().unwrap();
|
||
let photocraft_doc::LayerContent::Shape(ShapeLayer { live, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
|
||
assert!(live.is_none(), "an edited rectangle is a plain path now");
|
||
assert_eq!(cache.as_ref().unwrap().content_bounds().x1, 80, "depth {depth}: re-rendered");
|
||
}
|
||
let mut s = session();
|
||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
|
||
s.execute("path.moveAnchors", json!({"name": "layer", "layer": id, "anchors": [[0, 0]], "move": [-5, -5]})).unwrap();
|
||
let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
|
||
assert_eq!(vm.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(5.0, 5.0));
|
||
}
|
||
|
||
fn curve() -> serde_json::Value {
|
||
json!({"subpaths": [{"closed": false, "knots": [
|
||
{"anchor": [10, 80], "in": [10, 80], "out": [10, 20]},
|
||
{"anchor": [60, 50], "in": [40, 20], "out": [80, 80], "smooth": true},
|
||
{"anchor": [110, 80], "in": [110, 20], "out": [110, 80]}
|
||
]}]})
|
||
}
|
||
|
||
fn knots(s: &Session) -> usize {
|
||
work(s).subpaths.iter().map(|sp| sp.knots.len()).sum()
|
||
}
|
||
|
||
#[test]
|
||
fn anchors_are_added_and_deleted_in_one_undoable_step_each() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": curve()})).unwrap();
|
||
let before = work(&s);
|
||
let steps = s.active().unwrap().history.entries().len();
|
||
let r = s.execute("path.addAnchor", json!({"subpath": 0, "knot": 0, "t": 0.5})).unwrap();
|
||
assert_eq!(r["anchor"], json!({"subpath": 0, "knot": 1}));
|
||
assert_eq!((knots(&s), s.active().unwrap().history.entries().len()), (4, steps + 1));
|
||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Add Anchor Point"));
|
||
assert!(work(&s).subpaths[0].knots[1].smooth);
|
||
let added = work(&s);
|
||
assert!(s.undo());
|
||
assert_eq!(work(&s), before, "one undo restores the path exactly");
|
||
assert!(s.redo());
|
||
assert_eq!(work(&s), added);
|
||
let r = s.execute("path.deleteAnchor", json!({"subpath": 0, "knot": 1})).unwrap();
|
||
assert_eq!(r["subpathRemoved"], json!(false));
|
||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Delete Anchor Point"));
|
||
assert_eq!(knots(&s), 3);
|
||
// Deleting the middle anchor of the original curve joins the ends with their own handles.
|
||
s.execute("path.set", json!({"path": curve()})).unwrap();
|
||
s.execute("path.deleteAnchor", json!({"subpath": 0, "knot": 1})).unwrap();
|
||
let k = &work(&s).subpaths[0].knots;
|
||
assert_eq!((k.len(), k[0].out_ctrl, k[1].in_ctrl), (2, before.subpaths[0].knots[0].out_ctrl, before.subpaths[0].knots[2].in_ctrl));
|
||
}
|
||
|
||
#[test]
|
||
fn anchor_tools_edit_saved_paths_shapes_and_vector_masks() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"name": "Outline", "path": square()})).unwrap();
|
||
let r = s.execute("path.deleteAnchor", json!({"name": "Outline", "subpath": 0, "knot": 0})).unwrap();
|
||
assert_eq!((r["path"]["subpaths"][0]["knots"].as_array().unwrap().len(), r["path"]["subpaths"][0]["closed"].clone()), (3, json!(true)));
|
||
for depth in [8, 16, 32] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 120, "height": 100, "depth": depth})).unwrap();
|
||
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||
// Top edge, then pull the new midpoint up: the layer re-renders from the edited path.
|
||
let r = s.execute("path.addAnchor", json!({"name": "layer", "layer": id, "subpath": 0, "knot": 0, "t": 0.5})).unwrap();
|
||
assert_eq!(anchor(&r, 1), [30.0, 10.0]);
|
||
s.execute("path.moveAnchors", json!({"name": "layer", "layer": id, "anchors": [[0, 1]], "move": [0, -8]})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let photocraft_doc::LayerContent::Shape(ShapeLayer { live, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
|
||
assert!(live.is_none());
|
||
assert_eq!(cache.as_ref().unwrap().content_bounds().y0, 2, "depth {depth}: re-rendered");
|
||
s.execute("path.deleteAnchor", json!({"name": "layer", "layer": id, "subpath": 0, "knot": 1})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let photocraft_doc::LayerContent::Shape(ShapeLayer { cache, path, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
|
||
assert_eq!((path.subpaths[0].knots.len(), cache.as_ref().unwrap().content_bounds().y0), (4, 10), "depth {depth}");
|
||
}
|
||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
|
||
s.execute("path.addAnchor", json!({"name": "layer", "layer": id, "subpath": 0, "knot": 3, "t": 0.5})).unwrap();
|
||
let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
|
||
assert_eq!(vm.path.subpaths[0].knots[4].anchor, photocraft_geom::Point::new(10.0, 35.0), "the closing segment's midpoint");
|
||
}
|
||
|
||
#[test]
|
||
fn the_last_anchor_of_a_shape_is_refused() {
|
||
let mut s = session();
|
||
let one = json!({"subpaths": [{"closed": false, "knots": [[10, 10], [50, 10]]}]});
|
||
let id = s.execute("shape.create", json!({"kind": "path", "path": one, "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("path.deleteAnchor", json!({"name": "layer", "layer": id, "subpath": 0, "knot": 0})).unwrap();
|
||
let steps = s.active().unwrap().history.entries().len();
|
||
let e = s.execute("path.deleteAnchor", json!({"name": "layer", "layer": id, "subpath": 0, "knot": 0})).unwrap_err();
|
||
assert!(e.to_string().contains("only anchor"), "{e}");
|
||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||
}
|
||
|
||
/// Coverage of `path` at the pixel `(x, y)`.
|
||
fn covered(path: &photocraft_doc::Path, x: i32, y: i32) -> f32 {
|
||
photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0]
|
||
}
|
||
|
||
/// A 10..60 square with a front 25..45 square cut out of it (Subtract Front Shape), then
|
||
/// Merge Shape Components: plain combined outlines covering the same area, in one step.
|
||
#[test]
|
||
fn merge_shape_components_bakes_a_paths_operations() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
|
||
s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap();
|
||
let before = work(&s);
|
||
let r = s.execute("path.mergeShapeComponents", json!({})).unwrap();
|
||
assert_eq!(r["name"], json!("work"));
|
||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Merge Shape Components"));
|
||
let merged = work(&s);
|
||
assert_eq!(merged.subpaths.len(), 2, "the outline and its hole");
|
||
for (x, y) in [(15, 15), (35, 35), (55, 50), (5, 5)] {
|
||
assert!((covered(&merged, x, y) - covered(&before, x, y)).abs() < 0.01, "({x}, {y})");
|
||
}
|
||
assert_eq!((covered(&merged, 35, 35), covered(&merged, 15, 15)), (0.0, 1.0), "the hole stays a hole");
|
||
assert!(s.undo());
|
||
assert_eq!(work(&s), before);
|
||
// A saved path and a vector mask merge the same way; an inverted mask stays inverted.
|
||
s.execute("path.set", json!({"name": "Mug", "path": square()})).unwrap();
|
||
s.execute("path.mergeShapeComponents", json!({"name": "Mug"})).unwrap();
|
||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
|
||
s.execute("layer.vectorMask.edit", json!({"layer": id, "path": {"subpaths": square()["subpaths"], "inverted": true}})).unwrap();
|
||
s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap();
|
||
let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
|
||
assert!(vm.path.inverted && !vm.path.subpaths.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn merge_shape_components_on_a_shape_layer_rerenders_it() {
|
||
let mut s = session();
|
||
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "subtract"})).unwrap();
|
||
s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
|
||
assert_eq!(covered(path, 35, 35), 0.0);
|
||
assert!(cache.is_some());
|
||
}
|
||
|
||
/// Mask components that cancel out (a square minus itself) cover nothing. Merged, the mask
|
||
/// must still hide (or, inverted, reveal) what it did, not turn into an empty revealing mask.
|
||
#[test]
|
||
fn merging_a_vector_mask_to_nothing_keeps_what_it_shows() {
|
||
for inverted in [false, true] {
|
||
let mut s = session();
|
||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||
let mut cancelled = square();
|
||
cancelled["subpaths"].as_array_mut().unwrap().push(json!({"closed": true, "op": "subtract", "knots": [[10, 10], [60, 10], [60, 60], [10, 60]]}));
|
||
cancelled["inverted"] = json!(inverted);
|
||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": cancelled})).unwrap();
|
||
let mask = |s: &Session| s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
|
||
let shown = |s: &Session| photocraft_vector::vector_mask_values(&mask(s), photocraft_geom::Rect::from_xywh(0, 0, 120, 100));
|
||
let before = shown(&s);
|
||
assert!(before.iter().all(|v| *v == if inverted { 1.0 } else { 0.0 }), "inverted {inverted}");
|
||
s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap();
|
||
assert!(mask(&s).path.is_empty());
|
||
assert_eq!(shown(&s), before, "inverted {inverted}");
|
||
}
|
||
}
|
||
|
||
/// A hole cut with Combine by mistake becomes a hole: the component takes Subtract Front Shape,
|
||
/// in one step, on the Work Path, a shape layer (re-rendered) and a vector mask alike.
|
||
#[test]
|
||
fn set_operation_changes_existing_components() {
|
||
let mut s = session();
|
||
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
s.execute("path.set", json!({"path": hole, "op": "combine"})).unwrap();
|
||
assert_eq!(covered(&work(&s), 35, 35), 1.0);
|
||
s.execute("path.setOperation", json!({"subpaths": [1], "op": "subtract"})).unwrap();
|
||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Path Operation"));
|
||
assert_eq!(covered(&work(&s), 35, 35), 0.0);
|
||
assert_eq!(work(&s).subpaths[1].op, PathOp::Subtract);
|
||
assert!(s.undo());
|
||
assert_eq!(work(&s).subpaths[1].op, PathOp::Combine);
|
||
|
||
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "combine"})).unwrap();
|
||
s.execute("path.setOperation", json!({"name": "layer", "layer": id, "subpaths": [1], "op": "subtract"})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
|
||
assert_eq!(covered(path, 35, 35), 0.0);
|
||
assert!(cache.is_some());
|
||
}
|
||
|
||
/// A component's operation is its first subpath's: naming a joined subpath changes the head,
|
||
/// and the joined subpaths stay joined.
|
||
#[test]
|
||
fn set_operation_on_a_joined_subpath_changes_its_component() {
|
||
let mut s = session();
|
||
let mut p = square();
|
||
let subs = p["subpaths"].as_array_mut().unwrap();
|
||
subs.push(json!({"closed": true, "op": "combine", "knots": [[70, 10], [110, 10], [110, 50], [70, 50]]}));
|
||
subs.push(json!({"closed": true, "op": "join", "knots": [[80, 20], [80, 40], [100, 40], [100, 20]]}));
|
||
s.execute("path.set", json!({"path": p})).unwrap();
|
||
s.execute("path.setOperation", json!({"subpaths": [2], "op": "exclude"})).unwrap();
|
||
let ops: Vec<_> = work(&s).subpaths.iter().map(|sp| sp.op).collect();
|
||
assert_eq!(ops, [PathOp::Combine, PathOp::Exclude, PathOp::Join]);
|
||
}
|
||
|
||
/// The first component always draws as Combine: naming it with others changes only the
|
||
/// others, and naming only it is refused without a History step (feedback on #7648).
|
||
#[test]
|
||
fn set_operation_leaves_the_first_component_combining() {
|
||
let mut s = session();
|
||
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
s.execute("path.set", json!({"path": hole, "op": "combine"})).unwrap();
|
||
let steps = s.active().unwrap().history.entries().len();
|
||
let e = s.execute("path.setOperation", json!({"subpaths": [0], "op": "subtract"})).unwrap_err().to_string();
|
||
assert!(e.contains("first component"), "{e}");
|
||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||
s.execute("path.setOperation", json!({"subpaths": [0, 1], "op": "subtract"})).unwrap();
|
||
let ops: Vec<_> = work(&s).subpaths.iter().map(|sp| sp.op).collect();
|
||
assert_eq!(ops, [PathOp::Combine, PathOp::Subtract]);
|
||
assert_eq!((covered(&work(&s), 35, 35), covered(&work(&s), 15, 15)), (0.0, 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn bad_set_operation_params_change_nothing() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let steps = s.active().unwrap().history.entries().len();
|
||
for (p, msg) in [
|
||
(json!({"subpaths": [0]}), "`op`"),
|
||
(json!({"subpaths": [0], "op": "join"}), "`op`"),
|
||
(json!({"subpaths": [0], "op": 3}), "`op`"),
|
||
(json!({"op": "subtract"}), "`subpaths`"),
|
||
(json!({"subpaths": [], "op": "subtract"}), "`subpaths`"),
|
||
(json!({"subpaths": [-1], "op": "subtract"}), "index"),
|
||
(json!({"subpaths": [7], "op": "subtract"}), "no subpath 7"),
|
||
(json!({"subpaths": [0], "op": "subtract", "name": "Nope"}), ""),
|
||
(json!({"subpaths": [0], "op": "subtract", "name": "layer", "layer": "x"}), "`layer`"),
|
||
] {
|
||
let e = s.execute("path.setOperation", p.clone()).unwrap_err().to_string();
|
||
assert!(e.contains(msg), "{p}: {e}");
|
||
}
|
||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||
assert_eq!(work(&s).subpaths[0].op, PathOp::Combine);
|
||
}
|
||
|
||
/// Rejected merges name the problem and leave every path as it was, including the one a
|
||
/// malformed target would otherwise have fallen back to (the Work Path, the active layer).
|
||
#[test]
|
||
fn bad_merge_params_change_nothing() {
|
||
let mut s = session();
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
|
||
s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap();
|
||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
|
||
let state = |s: &Session| {
|
||
let d = s.active().unwrap();
|
||
(d.doc.work_path.clone(), d.doc.paths.clone(), d.doc.layer(LayerId(id)).unwrap().vector_mask.clone(), d.history.entries().len())
|
||
};
|
||
let before = state(&s);
|
||
for (p, msg) in [
|
||
(json!({"tolerance": "fine"}), "tolerance"),
|
||
(json!({"name": 5}), "`name`"),
|
||
(json!({"name": "nope"}), "no path named"),
|
||
(json!({"name": "layer", "layer": "x"}), "`layer`"),
|
||
(json!({"name": "layer", "layer": 999}), "999"),
|
||
] {
|
||
let e = s.execute("path.mergeShapeComponents", p.clone()).unwrap_err().to_string();
|
||
assert!(e.contains(msg), "{p}: {e}");
|
||
assert!(state(&s) == before, "{p}: nothing changed");
|
||
}
|
||
}
|
||
|
||
/// Tracing an open subpath's fill would close it, or drop a straight one or a lone
|
||
/// anchor (a dot under a round or square cap) entirely: the merge
|
||
/// is refused and the path, its stroked layer and the history stay as they were.
|
||
#[test]
|
||
fn merge_refuses_open_subpaths() {
|
||
for open in [json!({"closed": false, "knots": [[10, 10], [60, 60]]}), json!({"closed": false, "knots": [[70, 70]]})] {
|
||
let mut s = session();
|
||
let mut mixed = square();
|
||
mixed["subpaths"].as_array_mut().unwrap().push(open);
|
||
s.execute("path.set", json!({"path": mixed})).unwrap();
|
||
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50]})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("shape.edit", json!({"layer": id, "path": mixed})).unwrap();
|
||
let state = |s: &Session| {
|
||
let d = s.active().unwrap();
|
||
let photocraft_doc::LayerContent::Shape(sh) = &d.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
|
||
(d.doc.work_path.clone(), sh.path.clone(), d.history.entries().len())
|
||
};
|
||
let before = state(&s);
|
||
for (cmd, p) in [
|
||
("path.mergeShapeComponents", json!({})),
|
||
("path.mergeShapeComponents", json!({"name": "layer", "layer": id})),
|
||
("layer.combineShapes.mergeShapeComponents", json!({"layer": id})),
|
||
] {
|
||
let e = s.execute(cmd, p.clone()).unwrap_err().to_string();
|
||
assert!(e.contains("open subpaths"), "{cmd} {p}: {e}");
|
||
assert!(state(&s) == before, "{cmd} {p}: nothing changed");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bad_params_fail_without_an_edit() {
|
||
let mut s = session();
|
||
assert!(s.execute("path.moveAnchors", json!({"anchors": [[0, 0]], "move": [1, 1]})).is_err(), "no work path");
|
||
s.execute("path.set", json!({"path": square()})).unwrap();
|
||
let steps = s.active().unwrap().history.entries().len();
|
||
for (id, p) in [
|
||
("path.moveAnchors", json!({"anchors": [[0, 9]], "move": [1, 1]})),
|
||
("path.moveAnchors", json!({"anchors": [], "move": [1, 1]})),
|
||
("path.moveAnchors", json!({"anchors": [[0]], "move": [1, 1]})),
|
||
("path.moveAnchors", json!({"anchors": [[0, 0]], "move": [1e300, 1]})),
|
||
("path.moveAnchors", json!({"anchors": [[0, 0]]})),
|
||
("path.moveHandle", json!({"subpath": 0, "knot": 0, "handle": "up", "to": [1, 1]})),
|
||
("path.moveHandle", json!({"subpath": 0, "knot": 0, "handle": "in"})),
|
||
("path.moveHandle", json!({"subpath": -1, "knot": 0, "handle": "in", "to": [1, 1]})),
|
||
("path.bendSegment", json!({"subpath": 0, "knot": 0, "t": 2, "move": [1, 1]})),
|
||
("path.bendSegment", json!({"subpath": 5, "knot": 0, "move": [1, 1]})),
|
||
("path.convertPoint", json!({"subpath": 0, "knot": 4})),
|
||
("path.convertPoint", json!({"subpath": 0, "knot": 0, "out": "x"})),
|
||
("path.convertPoint", json!({"name": "nope", "subpath": 0, "knot": 0})),
|
||
("path.convertPoint", json!({"name": "layer", "subpath": 0, "knot": 0})),
|
||
("path.addAnchor", json!({"subpath": 0, "knot": 0, "t": 0})),
|
||
("path.addAnchor", json!({"subpath": 0, "knot": 0, "t": 1})),
|
||
("path.addAnchor", json!({"subpath": 0, "knot": 0, "t": -0.5})),
|
||
("path.addAnchor", json!({"subpath": 0, "knot": 0, "t": "half"})),
|
||
("path.addAnchor", json!({"subpath": 0, "knot": 4})),
|
||
("path.addAnchor", json!({"subpath": 1, "knot": 0})),
|
||
("path.addAnchor", json!({"knot": 0})),
|
||
("path.addAnchor", json!({"name": "nope", "subpath": 0, "knot": 0})),
|
||
("path.deleteAnchor", json!({"subpath": 0, "knot": 4})),
|
||
("path.deleteAnchor", json!({"subpath": 0, "knot": -1})),
|
||
("path.deleteAnchor", json!({"subpath": 0})),
|
||
("path.deleteAnchor", json!({"name": "layer", "subpath": 0, "knot": 0})),
|
||
] {
|
||
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
|
||
}
|
||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||
}
|
||
}
|