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>
This commit is contained in:
Kameron
2026-10-21 11:21:24 -07:00
co-authored by Claude Opus 5.5
parent 35da7b7cff
commit afdc31b727
7 changed files with 391 additions and 42 deletions
+39 -18
View File
@@ -166,6 +166,42 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
pub(crate) struct PathShape {
/// The network, with the path's own box moved to the origin and scaled to the size asked for.
pub network: VectorNetwork,
pub width: f64,
pub height: f64,
/// Where the path's own box starts in its coordinates (unscaled).
pub offset: (f64, f64),
}
/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves
/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse
/// or draws nothing is refused.
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
}
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(empty());
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
let network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(empty());
}
Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) })
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
@@ -228,27 +264,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
let shape = path_network(cmd, &d, asked.0, asked.1)?;
*network = shape.network;
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
(w, h, offset) = (shape.width, shape.height, shape.offset);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
if pts.len() < 2 {
+35 -22
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
@@ -1159,6 +1159,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"matches": hits}))
}
/// Keys only some layer types take, the test for those layers, and why the others skip them.
type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str);
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("node.set", s, p)?;
let props = match get(p, "props") {
@@ -1189,34 +1192,44 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
None => Vec::new(),
};
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
// the first refuses them (`props::layout_mut`).
// Type-specific keys go to the layers that take them and are skipped, with a warning, on the
// others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets
// that take them, the first refuses them (`props::layout_mut`, `props::apply`).
let doc = &s.ds()?.doc;
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
let mut without_layout = props.clone();
let mut skipped = BTreeSet::new();
if !layout_keys.is_empty() {
let doc = &s.ds()?.doc;
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
if ids.iter().any(frame_like) {
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect();
let frame_like = |n: &Node| n.kind.is_frame_like();
let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. });
let typed: [TypedKeys; 2] = [
(&layout_keys, &frame_like, "only frames, components and instances have auto layout"),
(&path_keys, &vector, "only vector layers have a path"),
];
let mut skipped: BTreeMap<NodeId, Map<String, Value>> = BTreeMap::new();
for (keys, takes, why) in typed {
let takes = |i: &NodeId| doc.node(*i).is_some_and(takes);
if keys.is_empty() || !ids.iter().any(takes) {
continue;
}
if !skipped.is_empty() {
let names: Vec<String> =
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: only frames, components and instances have auto layout",
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
let others: Vec<NodeId> = ids.iter().copied().filter(|i| !takes(i)).collect();
if others.is_empty() {
continue;
}
for i in &others {
skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str()));
}
let names: Vec<String> =
others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: {why}",
keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
}
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
+37 -2
View File
@@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
@@ -376,7 +378,7 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"boundVariables" => 4,
@@ -389,11 +391,17 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
// Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it).
let mut path_offset = (0.0, 0.0);
for (k, v) in keys {
let k = k.as_str();
match k {
"path" => path_offset = set_path(cmd, doc, id, v, props)?,
// A new path takes the size asked for as its scale (`set_path`).
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
// X/Y are the node's own origin (its rotated top-left for rotated nodes), in the
// frame it is in (groups have no space of their own).
let n = doc.get(id)?;
@@ -920,6 +928,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
Ok(())
}
/// Replace vector `id`'s geometry with SVG path data `v`, as `node.create` reads it: `width`/`height`
/// in the same call scale it, and the path's own box goes at the layer's origin, which keeps its
/// place, rotation and everything else. Returns where the path's own box was, which `x`/`y` in the
/// same call add (so `{path, x: 0, y: 0}` places it as `node.create` does). Layers inside an
/// instance are refused: their geometry comes from the main component, and a sync would undo it.
fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map<String, Value>) -> Result<(f64, f64)> {
let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?;
let n = doc.get(id)?;
if !matches!(n.kind, NodeKind::Vector { .. }) {
return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name())));
}
if crate::components::inside_instance(doc, id) {
return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name)));
}
let size = |long: &str, short: &str| -> Result<Option<f64>> {
props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose()
};
let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?;
let n = doc.get_mut(id)?;
if let NodeKind::Vector { network } = &mut n.kind {
*network = shape.network;
}
n.width = shape.width;
n.height = shape.height;
Ok(shape.offset)
}
/// The override group a property key belongs to.
pub fn override_group(k: &str) -> &'static str {
match k {
+2
View File
@@ -1891,6 +1891,8 @@ mod vector_multi_behaviour_tests;
#[cfg(test)]
mod vector_offset_tests;
#[cfg(test)]
mod vector_path_set_tests;
#[cfg(test)]
mod version_preview_tests;
#[cfg(test)]
mod version_tests;
+4
View File
@@ -425,6 +425,10 @@ fn every_command_survives_junk_params() {
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
// Vector paths (`node.set {path}`) as junk. (Not a string `path` at the top: file commands
// would write to it.)
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
];
for spec in all_commands() {
if matches!(
+263
View File
@@ -0,0 +1,263 @@
//! `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!({}));
}
+11
View File
@@ -448,6 +448,17 @@ its proportion). A path or point set that draws nothing is refused instead of ma
0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's
height) and a size of 0 or less on an axis it has, which would flatten it.
`node.set {path}` (or `set_properties`) replaces a vector's geometry with new SVG path data and
keeps the layer: its id, name, place in the layer list, paints, effects, styles, variable bindings
and constraints. The path is read as `vector.create` reads it, and `width`/`height` in the same call
scale it the same way. The path's own box goes at the layer's current x/y (rotation and flips
stay); with `x`/`y` in the same call it goes at `x` + its left edge and `y` + its top, as
`vector.create` places it. Points' own settings (per-point corner radius, caps, joins) go with the
old geometry. On several layers, layers that aren't vectors are skipped with a warning; on a
single one, or when none is a vector, the call is refused (convert shapes with
`vector.edit.begin` or Flatten first). A layer inside an instance is refused too: its geometry
comes from its main component, so edit that or detach the instance. Undo brings the old path back.
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
`vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers
are edited at once, the first active; `key` Enter with shapes selected does the same),