Files
figcraft/crates/engine/src/vector_path_set_tests.rs
T
KameronandClaude Opus 5.5 afdc31b727 Let node.set replace a vector's path
node.set took no `path`, so redrawing a vector meant deleting and recreating it and
losing its id, place in the layer list, paints, styles, bindings and step references.

`path` now takes SVG path data for vector layers and replaces the geometry through
the same reader node.create uses (path_network, shared with create). width/height in
the same call scale it as create does. The path's own box goes at the layer's current
origin, so rotation and flips stay; with x/y in the same call it goes at x/y plus the
box's offset, which is where create puts it.

On several layers, layers that aren't vectors are skipped with a warning, as auto
layout keys already are (the skip logic is shared now). A single non-vector is
refused, and so is a vector inside an instance: there is no geometry override, so
the next sync would undo it. Data that doesn't parse, draws nothing or has
non-finite bounds is refused cleanly; create now refuses non-finite bounds too.

Tests cover keeping the layer, placement and scaling against create, rotation,
undo/redo, invalid data, non-vectors, instances, boolean operands, edit mode, and a
junk-path sweep. The engine-wide junk sweep has node.set path entries.

Fixes #1001

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:21:24 -07:00

264 lines
13 KiB
Rust

//! `node.set {path}` replaces a vector's geometry and keeps the layer (#1001): its id, name, place in
//! the layer list, paints, effects, styles, bindings and constraints. The path is read as
//! `node.create` reads it (`width`/`height` scale it), its own box goes at the layer's origin (or at
//! `x`/`y` plus the box's offset, as create places it), undo brings the old path back, and data that
//! can't draw, layers that aren't vectors and layers inside an instance are refused.
use figcraft_doc::{NodeId, NodeKind, VectorNetwork};
use serde_json::{Value, json};
use crate::{EngineError, Session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
crate::params::id_of(&v["id"]).unwrap()
}
fn vector(s: &mut Session, p: Value) -> NodeId {
let mut p = p;
p["type"] = json!("vector");
id(&ex(s, "node.create", p))
}
fn net(s: &Session, v: NodeId) -> VectorNetwork {
crate::vectoredit::network(s.doc().unwrap(), v).unwrap().clone()
}
/// The layer's x, y, width and height as `get_node` reports them.
fn frame(s: &Session, v: NodeId) -> (f64, f64, f64, f64) {
let d = crate::cmd::props::describe(s.doc().unwrap(), v);
let f = |k: &str| d[k].as_f64().unwrap();
(f("x"), f("y"), f("width"), f("height"))
}
fn bad_msg(r: crate::Result<Value>) -> String {
match r {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn a_new_path_keeps_the_layer() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "T"}));
ex(&mut s, "variable.create", json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF0000"}));
let below = vector(&mut s, json!({"path": "M0 0 L5 5"}));
let v = vector(&mut s, json!({"name": "Chart line", "path": "M0 0 L10 10", "x": 40, "y": 30}));
let above = vector(&mut s, json!({"path": "M0 0 L5 5"}));
ex(
&mut s,
"node.set",
json!({"id": v, "props": {"stroke": "#00FF00", "strokeWeight": 3, "opacity": 0.5,
"effects": [{"type": "dropShadow", "color": "#00000040", "offset": [0, 2], "radius": 4}],
"constraints": {"horizontal": "right", "vertical": "bottom"}}}),
);
ex(&mut s, "variable.bind", json!({"ids": [v], "field": "fill", "variable": "brand"}));
let before = s.doc().unwrap().node(v).unwrap().clone();
let out = ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0 L20 8 Z"}}));
assert!(out.get("warnings").is_none(), "{out}");
let doc = s.doc().unwrap();
let n = doc.node(v).unwrap();
assert_eq!((n.id, n.name.as_str()), (v, "Chart line"));
assert_eq!((&n.fills, &n.strokes, &n.stroke, &n.effects), (&before.fills, &before.strokes, &before.stroke, &before.effects));
assert_eq!((n.opacity, &n.bound, &n.styles, &n.constraints), (before.opacity, &before.bound, &before.styles, &before.constraints));
assert!(n.bound.contains_key("fill"), "the fill stays bound");
let page = doc.node(n.parent.unwrap()).unwrap();
assert_eq!(page.children, [below, v, above], "same place in the layer list");
// The new geometry, at the layer's origin.
assert_eq!(frame(&s, v), (40.0, 30.0, 20.0, 8.0));
let net = net(&s, v);
assert_eq!((net.vertices.len(), net.regions.len()), (3, 1), "a closed triangle: {net:?}");
}
#[test]
fn width_height_and_x_y_follow_node_create() {
// Each row: props given with the path, and the frame node.set gives a layer at (100, 50).
let rows: [(Value, (f64, f64, f64, f64)); 6] = [
// The path's own box goes at the layer's origin: its offset (5, 5) doesn't move it.
(json!({}), (100.0, 50.0, 10.0, 20.0)),
// Both sides scale independently; one keeps the proportion.
(json!({"width": 30, "height": 10}), (100.0, 50.0, 30.0, 10.0)),
(json!({"w": 20}), (100.0, 50.0, 20.0, 40.0)),
// With x/y the path's offset is added, as create places it.
(json!({"x": 0, "y": 0}), (5.0, 5.0, 10.0, 20.0)),
(json!({"x": 10, "height": 10}), (15.0, 50.0, 5.0, 10.0)),
(json!({"x": 10, "y": 20, "width": 20, "height": 10}), (15.0, 25.0, 20.0, 10.0)),
];
for (extra, want) in rows {
let mut s = Session::new();
let v = vector(&mut s, json!({"path": "M0 0 L1 1", "x": 100, "y": 50}));
let mut props = json!({"path": "M5 5 L15 5 L15 25 Z"});
props.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
ex(&mut s, "node.set", json!({"ids": [v], "props": props}));
assert_eq!(frame(&s, v), want, "{extra}");
// With both x and y, create makes the same layer. (With one, the other axis keeps the
// layer's current place.)
if extra.get("x").is_some() && extra.get("y").is_some() {
let c = vector(&mut s, props.clone());
assert_eq!(frame(&s, c), want, "create {extra}");
}
}
}
#[test]
fn rotation_stays_and_turns_about_the_new_box() {
let mut s = Session::new();
let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 0, "y": 0}));
ex(&mut s, "node.set", json!({"ids": [v], "props": {"rotation": 90}}));
let t = s.doc().unwrap().node(v).unwrap().transform;
ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L40 0 L40 20 Z"}}));
let n = s.doc().unwrap().node(v).unwrap();
assert_eq!(n.transform, t, "the layer keeps its origin and rotation");
assert_eq!((n.width, n.height), (40.0, 20.0));
}
#[test]
fn undo_brings_the_old_path_back() {
let mut s = Session::new();
let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 3, "y": 4}));
let (old_net, old_frame) = (net(&s, v), frame(&s, v));
ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 C10 0 20 10 20 20", "width": 50}}));
let (new_net, new_frame) = (net(&s, v), frame(&s, v));
assert_ne!(new_net, old_net);
ex(&mut s, "edit.undo", json!({}));
assert_eq!((net(&s, v), frame(&s, v)), (old_net, old_frame));
ex(&mut s, "edit.redo", json!({}));
assert_eq!((net(&s, v), frame(&s, v)), (new_net, new_frame));
}
#[test]
fn data_that_cant_draw_is_refused_and_nothing_changes() {
let mut s = Session::new();
let v = vector(&mut s, json!({"path": "M0 0 L10 10", "name": "V"}));
let before = s.doc().unwrap().node(v).unwrap().clone();
for (props, says) in [
(json!({"path": "not a path"}), "not valid SVG path data"),
(json!({"path": "M0 0 L10"}), "not valid SVG path data"),
(json!({"path": ""}), "draws nothing"),
(json!({"path": "M5 5"}), "draws nothing"),
(json!({"path": "M5 5 L5 5 Z"}), "draws nothing"),
(json!({"path": "M0 0 L1e308 0 L-1e308 0"}), "too large"),
(json!({"path": 5}), "must be SVG path data"),
(json!({"path": null}), "must be SVG path data"),
(json!({"path": "M0 0 L10 10", "width": 0}), "positive"),
(json!({"path": "M0 0 L10 10", "height": -4}), "positive"),
(json!({"path": "M0 0 L10 10", "width": "wide"}), "must be a number"),
// A size on an axis the path has none of.
(json!({"path": "M0 0 L10 0", "height": 5}), "no vertical extent"),
(json!({"path": "M0 0 L10 10", "name": "renamed", "width": -1}), "positive"),
] {
let msg = bad_msg(s.execute("node.set", &json!({"ids": [v], "props": props})));
assert!(msg.contains(says), "{props}: {msg}");
assert_eq!(s.doc().unwrap().node(v).unwrap(), &before, "{props} changed nothing");
}
// A horizontal line can still be given height 0, as create allows.
ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L10 0", "height": 0}}));
assert_eq!(frame(&s, v).2, 10.0);
}
#[test]
fn layers_that_arent_vectors_are_refused_alone_and_skipped_among_vectors() {
let mut s = Session::new();
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "name": "Box"})));
let star = id(&ex(&mut s, "node.create", json!({"type": "star"})));
let msg = bad_msg(s.execute("node.set", &json!({"ids": [r], "props": {"path": "M0 0 L5 5"}})));
assert!(msg.contains("vector") && msg.contains("Box"), "{msg}");
assert!(s.execute("node.set", &json!({"ids": [r, star], "props": {"path": "M0 0 L5 5"}})).is_err(), "no vector among them");
assert!(matches!(s.doc().unwrap().node(r).unwrap().kind, NodeKind::Rectangle));
let v = vector(&mut s, json!({"path": "M0 0 L10 10"}));
let out = ex(&mut s, "node.set", json!({"ids": [r, v], "props": {"path": "M0 0 L30 0", "opacity": 0.5}}));
let w = out["warnings"][0].as_str().unwrap();
assert!(w.contains("`path` skipped on") && w.contains("Box") && w.contains("vector"), "{w}");
let doc = s.doc().unwrap();
assert_eq!((doc.node(r).unwrap().width, doc.node(r).unwrap().opacity), (100.0, 0.5), "the rectangle gets the other keys");
assert_eq!(frame(&s, v).2, 30.0);
}
#[test]
fn a_vector_inside_an_instance_is_refused_and_its_main_reaches_it() {
let mut s = Session::new();
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "width": 50, "height": 50})));
let v = vector(&mut s, json!({"path": "M0 0 L10 10", "parent": f}));
let comp = id(&ex(&mut s, "component.create", json!({"ids": [f]})));
let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 200, "y": 0})));
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
assert!(matches!(s.doc().unwrap().node(copy).unwrap().kind, NodeKind::Vector { .. }));
let msg = bad_msg(s.execute("node.set", &json!({"ids": [copy], "props": {"path": "M0 0 L30 0"}})));
assert!(msg.contains("inside an instance"), "{msg}");
// The main's vector takes it, and the instance follows.
let main_vector = s.doc().unwrap().node(comp).unwrap().children[0];
assert_eq!(main_vector, v);
ex(&mut s, "node.set", json!({"ids": [main_vector], "props": {"path": "M0 0 L30 0 L30 12 Z"}}));
assert_eq!(net(&s, copy), net(&s, main_vector));
assert_eq!((frame(&s, copy).2, frame(&s, copy).3), (30.0, 12.0));
}
#[test]
fn a_boolean_operand_takes_a_new_path_and_the_group_follows() {
let mut s = Session::new();
let a = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z"}));
let b = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z", "x": 20, "y": 20}));
let g = id(&ex(&mut s, "bool.union", json!({"ids": [a, b]})));
assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g));
ex(&mut s, "node.set", json!({"ids": [b], "props": {"path": "M0 0 L100 0 L100 100 L0 100 Z"}}));
let bounds = s.doc().unwrap().world_bounds(g).unwrap();
assert_eq!((bounds.width(), bounds.height()), (120.0, 120.0), "{bounds:?}");
}
#[test]
fn junk_paths_never_panic() {
let long = "M0 0 ".to_string() + &"L1 1 ".repeat(5000);
let junk = [
json!("M"),
json!("Z"),
json!("M0 0 Z Z Z"),
json!("M NaN 0 L 1 1"),
json!("M inf 0 L 1 1"),
json!("M0 0 L1e308 1e308"),
json!("M-1e308 -1e308 L1e308 1e308"),
json!("M0 0 A 1e308 1e308 0 1 1 10 10"),
json!("M0 0 A 0 0 0 0 0 10 10"),
json!("m0 0 l1 1 h-1e308 v1e308 z"),
json!("M0 0 Q 5 5"),
json!("M0,0L10,10\u{0}"),
json!("\u{1F4A5}"),
json!(long),
json!([1, 2]),
json!({"d": "M0 0 L1 1"}),
json!(true),
];
let sizes = [json!(null), json!(1e308), json!(-1e308), json!(1e-308), json!("NaN"), json!([1])];
let mut s = Session::new();
let v = vector(&mut s, json!({"path": "M0 0 L10 10"}));
for path in &junk {
for size in &sizes {
for props in [json!({"path": path, "width": size}), json!({"path": path, "height": size, "x": size, "rotation": size})] {
if let Err(EngineError::Internal { msg, .. }) = s.execute("node.set", &json!({"ids": [v], "props": props})) {
panic!("node.set panicked on {props}: {msg}");
}
let n = s.doc().unwrap().node(v).unwrap();
assert!(n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{props}");
}
}
}
}
#[test]
fn a_new_path_while_editing_drops_points_that_are_gone() {
let mut s = Session::new();
let v = vector(&mut s, json!({"path": "M0 0 L10 0 L10 10 L0 10 Z"}));
ex(&mut s, "vector.edit.begin", json!({"id": v}));
ex(&mut s, "vector.edit.selectAll", json!({}));
ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0"}}));
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing");
let n = net(&s, v);
assert!(ve.vertices.iter().all(|i| *i < n.vertices.len()) && ve.segments.iter().all(|i| *i < n.segments.len()), "{ve:?}");
ex(&mut s, "vector.edit.state", json!({}));
}