mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
35da7b7cff
commit
afdc31b727
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user