From be47f99534a9e900f68c7a6385ad11611fcd283e Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:23:44 +0800 Subject: [PATCH 1/8] M1: component sets keep the dashed canvas outline out of the document Node::new gave every component set a purple dashed stroke, so export and screenshot drew the editor frame into the image. Sets now start with no paint; the canvas draws the dashed frame itself, and files written by earlier versions lose exactly that stored decoration when opened. A stroke someone chose is kept. --- crates/doc/src/node.rs | 4 ++-- crates/engine/src/export_behaviour_tests.rs | 14 +++++++++++++ crates/format/src/large_tests.rs | 23 +++++++++++++++++++++ crates/format/src/lib.rs | 19 ++++++++++++++++- crates/ui-egui/src/canvas.rs | 7 +++++++ crates/ui-egui/src/canvas_fixes_tests.rs | 14 +++++++++++++ 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 00f7d5599..582cdb757 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -565,14 +565,14 @@ impl Node { NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)], _ => vec![], }; + // A component set's purple dashed frame is editor chrome: the canvas draws it, and it is + // never a stroke in the document, so exports don't show it. let strokes = match &kind { NodeKind::Line => vec![Paint::solid(Color::BLACK)], - NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))], _ => vec![], }; let stroke = match &kind { NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() }, - NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() }, _ => StrokeProps::default(), }; let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal }; diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 6896ce3e0..e3652a19c 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -317,6 +317,20 @@ fn svg_export_options() { assert!(svg.contains("stroke-width=\"4\""), "an inside stroke on a rectangle simplifies too (the rectangle inset by half the weight)"); } +#[test] +fn a_component_sets_editor_outline_is_not_exported() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40}))); + let b = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 300, "y": 0, "width": 100, "height": 40}))); + let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the purple dashed frame is editor chrome, not a stroke"); + let svg = text(&export(&mut s, json!({"ids": [set], "format": "svg"}))); + assert!(!svg.to_ascii_lowercase().contains("9747ff") && !svg.contains("stroke-dasharray"), "{svg}"); + let png = pixels(&export(&mut s, json!({"ids": [set]}))); + let purple = png.pixels().filter(|p| p[3] > 0 && p[0].abs_diff(0x97) < 24 && p[1].abs_diff(0x47) < 24 && p[2].abs_diff(0xFF) < 24).count(); + assert_eq!(purple, 0, "the exported set has no purple outline pixels"); +} + #[test] fn copy_as_png_svg_and_code() { let mut s = Session::new(); diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 889074f18..45eaf27c9 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -186,6 +186,29 @@ fn files_with_every_field_spelled_out_still_open() { assert_same_doc(&d, &again); } +#[test] +fn a_component_sets_old_editor_outline_is_dropped_on_open() { + let mut d = Document::new("Sets"); + let page = d.first_page().unwrap(); + let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); + let custom = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Chip", 0.0, 80.0, 100.0, 40.0).unwrap(); + // As 0.6.0 wrote every set: the canvas's purple dashed outline stored as a real stroke. + let n = d.get_mut(old).unwrap(); + n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))]; + n.stroke.dashes = vec![10.0, 5.0]; + // A stroke someone chose is design content and stays. + let n = d.get_mut(custom).unwrap(); + n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; + n.stroke.dashes = vec![1.0, 2.0]; + let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); + let (back, _) = from_json(&text).unwrap(); + let old = back.node(old).unwrap(); + assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); + let custom = back.node(custom).unwrap(); + assert_eq!(custom.strokes.len(), 1, "a real stroke is not the decoration"); + assert_eq!(custom.stroke.dashes, vec![1.0, 2.0]); +} + #[test] fn the_header_is_read_on_its_own() { // A newer file is refused before its document is looked at, whatever the version's size. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc4..93e67e5bb 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -28,7 +28,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Seek, Write}; use std::sync::Arc; -use figcraft_doc::{Document, ImageData}; +use figcraft_doc::color::{Color, Paint}; +use figcraft_doc::{Document, ImageData, NodeKind}; use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Value, json}; @@ -309,6 +310,21 @@ impl<'de> Deserialize<'de> for Header { } } +/// Earlier versions gave every component set the purple dashed outline of the canvas as a real +/// stroke, so it exported with the set. New sets carry no paint; files written back then lose +/// exactly that decoration when opened, and the canvas draws the outline itself. +fn drop_component_set_decoration(doc: &mut Document) { + let decoration = Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF)); + let ids: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).map(|n| n.id).collect(); + for id in ids { + let Some(n) = doc.node_mut(id) else { continue }; + if n.strokes.len() == 1 && n.strokes.first() == Some(&decoration) && n.stroke.dashes.as_slice() == [10.0, 5.0] { + n.strokes.clear(); + n.stroke.dashes.clear(); + } + } +} + /// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first /// (a newer file is refused before its document is read), then the document is deserialized /// straight from the text. @@ -321,6 +337,7 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; + drop_component_set_decoration(&mut doc); let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d797..627fae565 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -936,6 +936,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } + // Component sets keep Figma's purple dashed frame on the canvas: editor chrome, not a + // document paint, so it never shows in an export. + if matches!(n.kind, NodeKind::ComponentSet { .. }) + && let Some(q) = node_quad(app, &doc, *c) + { + painter.add(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); + } } // Prototype connections. crate::proto_canvas::draw(app, painter); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 4cd32d565..bca1f4fbc 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -51,6 +51,20 @@ fn frame_outlines_and_slices_draw_when_shown() { assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again"); } +#[test] +fn component_sets_draw_their_editor_outline() { + let mut s = Session::new(); + let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); + let b = id(&s.execute("node.create", &json!({"type": "component", "x": 200, "y": 0, "width": 100, "height": 40})).unwrap()); + let set = id(&s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap()); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the outline is not a document stroke"); + let mut u = Ui::new(s); + let q = crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), set).unwrap(); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(dashes_along(&shapes, q[0], q[1]) > 3, "the set shows its dashed outline"); +} + /// A job a few hundred milliseconds of rendering big. fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job { crate::render_worker::Job { From 911831fb89b0e380b513b0033fa5a37a6b0f573c Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:23:49 +0800 Subject: [PATCH 2/8] M1: vector.create scales its path to width/height A path's intrinsic box replaced the width and height asked for, so an icon imported at 24 stayed on its source grid. Scale the geometry to the requested size; with one side the other keeps its proportion. A path or point set that draws nothing is an error now, not an invisible 0x0 layer. --- crates/engine/src/cmd/create.rs | 59 +++++++++++++++++---- crates/engine/src/vector_behaviour_tests.rs | 25 +++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9b3f7067f..dedd1e493 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -73,9 +73,13 @@ pub fn specs() -> Vec { spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| { create_kind(s, p, "text") }), - spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind( - s, p, "vector" - )), + spec!( + "vector.create", + "Vector", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}", + true, + |s, p| create_kind(s, p, "vector") + ), spec!( "image.place", "Place image", @@ -162,6 +166,27 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// 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. +fn vector_scale(width: Option, height: Option, bw: f64, bh: f64) -> (f64, f64) { + let fit = |asked: Option, have: f64| match asked { + Some(a) if have > 1e-9 => a.max(0.0) / have, + _ => 1.0, + }; + match (width, height) { + (Some(_), Some(_)) => (fit(width, bw), fit(height, bh)), + (Some(_), None) => { + let s = fit(width, bw); + (s, s) + } + (None, Some(_)) => { + let s = fit(height, bh); + (s, s) + } + (None, None) => (1.0, 1.0), + } +} + pub fn create(s: &mut Session, p: &Value) -> Result { let cmd = "node.create"; let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; @@ -187,20 +212,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result { "Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)), _ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)), }; - // Vectors: path data or points define the shape and its box. + // Vectors: path data or points define the shape and its box. `width`/`height`, when asked + // for, scale the geometry: both sides independently, one side proportionally. let mut offset = (0.0, 0.0); + 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()); + 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")); + } if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); @@ -208,10 +245,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`points` needs at least two [x, y] pairs")); } let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default(); - let local: Vec = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area")); + } + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height()); + let local: Vec = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect(); *network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false)); - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } } diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 8899dc4e0..042b3321a 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -439,3 +439,28 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() { assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over"); assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer"); } + +#[test] +fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { + let mut s = Session::new(); + let path = "M0 0 L24 0 L24 24 L0 24 Z"; + let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap(); + let n = s.doc().unwrap().node(intrinsic).unwrap(); + assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box"); + // Both sides: each scales independently, like resizing a vector. + let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (16.0, 8.0)); + let n = net(&s, both); + let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y))); + assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled"); + // One side: the other keeps its proportion. + let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap(); + let n = s.doc().unwrap().node(width).unwrap(); + assert_eq!((n.width, n.height), (16.0, 16.0)); + // A path or point set that draws nothing is an error, not an invisible zero-size layer. + let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); + let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); +} From c87baa37cc4a6c6e3ab7ef48c65faf56db7de15f Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:51 +0800 Subject: [PATCH 3/8] docs: record the component set outline and vector size behaviour --- docs/components-parity.md | 1 + docs/mcp.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/components-parity.md b/docs/components-parity.md index 2640ed1c0..8be233629 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Component sets: the purple dashed frame is canvas chrome again. A new set carries no stroke, the canvas draws the outline, and exports and screenshots leave it out; a file written by 0.6.0 loses exactly that stored decoration when opened. A stroke someone chose stays. Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 786fde0aa..caecf3724 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -426,6 +426,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: ### Editing vectors +`vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale +the geometry to the size asked for (with both, each side independently; with one, the other keeps +its proportion). A path or point set that draws nothing is refused instead of making an invisible +0×0 layer. + 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), From aff3c4f18bec87927e86f0606bee9639f6f718d4 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:22:48 +0800 Subject: [PATCH 4/8] M1: gate the outline migration on a marker and refuse impossible vector sizes Review follow-up. A file written by this version carries editorOutlines, so a stroke someone chose is kept even when it matches the old decoration; only files without the marker get the one-time legacy cleanup. vector.create now errors when a requested side cannot scale the geometry or when both axes would collapse, instead of creating an invisible layer. --- crates/engine/src/cmd/create.rs | 28 +++++++++++++-------- crates/engine/src/vector_behaviour_tests.rs | 8 ++++++ crates/format/src/large_tests.rs | 11 ++++++-- crates/format/src/lib.rs | 16 ++++++++++-- docs/mcp.md | 3 ++- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dedd1e493..84fa419a1 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,24 +167,30 @@ pub fn parse_path_data(d: &str) -> Option { } /// 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. -fn vector_scale(width: Option, height: Option, bw: f64, bh: f64) -> (f64, f64) { - let fit = |asked: Option, have: f64| match asked { - Some(a) if have > 1e-9 => a.max(0.0) / have, - _ => 1.0, +/// independently (as resizing a vector does); with one, the other keeps its proportion. A size +/// asked for on an axis the geometry has none of is an error, not silently ignored. +fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { + let fit = |asked: Option, have: f64, axis: &str| match asked { + Some(a) if have > 1e-9 => Ok(a.max(0.0) / have), + Some(_) => Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })), + None => Ok(1.0), }; - match (width, height) { - (Some(_), Some(_)) => (fit(width, bw), fit(height, bh)), + let (sx, sy) = match (width, height) { + (Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?), (Some(_), None) => { - let s = fit(width, bw); + let s = fit(width, bw, "width")?; (s, s) } (None, Some(_)) => { - let s = fit(height, bh); + let s = fit(height, bh, "height")?; (s, s) } (None, None) => (1.0, 1.0), + }; + if sx <= 0.0 && sy <= 0.0 { + return Err(format!("`width` and `height` would make {what} 0×0")); } + Ok((sx, sy)) } pub fn create(s: &mut Session, p: &Value) -> Result { @@ -225,7 +231,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { 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()); + 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)); } @@ -248,7 +254,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { if bb.width() <= 1e-9 && bb.height() <= 1e-9 { return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area")); } - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height()); + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?; let local: Vec = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect(); *network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false)); w = bb.width() * sx; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 042b3321a..35d610746 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -463,4 +463,12 @@ fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { assert!(e.contains("draws nothing"), "{e}"); let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string(); assert!(e.contains("draws nothing"), "{e}"); + // A size on an axis the geometry has none of is refused, not silently ignored. + let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string(); + assert!(e.contains("horizontal extent"), "{e}"); + let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); + assert!(e.contains("vertical extent"), "{e}"); + // A size that would collapse both axes is refused too. + let e = s.execute("vector.create", &json!({"path": path, "width": 0})).unwrap_err().to_string(); + assert!(e.contains("0×0"), "{e}"); } diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 45eaf27c9..143dc82a0 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -187,7 +187,7 @@ fn files_with_every_field_spelled_out_still_open() { } #[test] -fn a_component_sets_old_editor_outline_is_dropped_on_open() { +fn a_component_sets_old_editor_outline_is_dropped_only_without_the_marker() { let mut d = Document::new("Sets"); let page = d.first_page().unwrap(); let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); @@ -200,8 +200,15 @@ fn a_component_sets_old_editor_outline_is_dropped_on_open() { let n = d.get_mut(custom).unwrap(); n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; n.stroke.dashes = vec![1.0, 2.0]; + // A file this version writes carries the marker: the same stroke is a choice, not decoration. let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); - let (back, _) = from_json(&text).unwrap(); + let (current, _) = from_json(&text).unwrap(); + assert_eq!(current.node(old).unwrap().strokes.len(), 1, "a marked file keeps a stroke someone chose"); + assert_eq!(current.node(custom).unwrap().strokes.len(), 1); + // A file without the marker is from a version that stored the decoration; drop exactly that. + let mut legacy = to_json(&d).unwrap(); + legacy.as_object_mut().unwrap().remove("editorOutlines"); + let (back, _) = from_json(&serde_json::to_string(&legacy).unwrap()).unwrap(); let old = back.node(old).unwrap(); assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); let custom = back.node(custom).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 93e67e5bb..4ae154e74 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -232,13 +232,18 @@ struct DocumentJson<'a> { version: u32, #[serde(rename = "imageIndex", serialize_with = "image_index")] images: &'a BTreeMap, + /// The editor draws component-set outlines itself instead of storing them as strokes. Files + /// without this marker were written by a version that stored the purple dashed outline as a + /// real stroke (see [`drop_component_set_decoration`]). + #[serde(rename = "editorOutlines")] + editor_outlines: bool, #[serde(flatten)] doc: &'a Document, } impl<'a> DocumentJson<'a> { fn new(doc: &'a Document) -> Self { - Self { format: FORMAT, version: VERSION, images: &doc.images, doc } + Self { format: FORMAT, version: VERSION, images: &doc.images, editor_outlines: true, doc } } } @@ -277,6 +282,8 @@ type ImageIndex = Option>; struct Header { version: Option, images: ImageIndex, + /// Whether the writer drew component-set outlines outside the document. + editor_outlines: bool, } impl<'de> Deserialize<'de> for Header { @@ -292,6 +299,7 @@ impl<'de> Deserialize<'de> for Header { while let Some(key) = map.next_key::()? { match key.as_str() { "version" => h.version = map.next_value::()?.as_u64(), + "editorOutlines" => h.editor_outlines = map.next_value::()?.as_bool().unwrap_or(false), "imageIndex" => { h.images = map .next_value::()? @@ -337,7 +345,11 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; - drop_component_set_decoration(&mut doc); + // Only files from before the marker can carry the old stored decoration; a file this version + // wrote keeps a stroke someone chose, even one that looks exactly like it. + if !header.editor_outlines { + drop_component_set_decoration(&mut doc); + } let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/docs/mcp.md b/docs/mcp.md index caecf3724..864bc1f19 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -429,7 +429,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale the geometry to the size asked for (with both, each side independently; with one, the other keeps its proportion). A path or point set that draws nothing is refused instead of making an invisible -0×0 layer. +0×0 layer, as is a size on an axis the geometry has none of and a size that would collapse both +axes. 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 From 55aa0165fd8ba2ef9d2e0821ef20a6f7b661a8db Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:41:11 +0700 Subject: [PATCH 5/8] #801: keep a component set's outline as its real stroke (reverts the export change) --- crates/doc/src/node.rs | 4 +-- crates/engine/src/export_behaviour_tests.rs | 14 --------- crates/format/src/large_tests.rs | 30 ------------------- crates/format/src/lib.rs | 33 ++------------------- crates/ui-egui/src/canvas.rs | 7 ----- crates/ui-egui/src/canvas_fixes_tests.rs | 14 --------- docs/components-parity.md | 1 - 7 files changed, 4 insertions(+), 99 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 582cdb757..00f7d5599 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -565,14 +565,14 @@ impl Node { NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)], _ => vec![], }; - // A component set's purple dashed frame is editor chrome: the canvas draws it, and it is - // never a stroke in the document, so exports don't show it. let strokes = match &kind { NodeKind::Line => vec![Paint::solid(Color::BLACK)], + NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))], _ => vec![], }; let stroke = match &kind { NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() }, + NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() }, _ => StrokeProps::default(), }; let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal }; diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index e3652a19c..6896ce3e0 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -317,20 +317,6 @@ fn svg_export_options() { assert!(svg.contains("stroke-width=\"4\""), "an inside stroke on a rectangle simplifies too (the rectangle inset by half the weight)"); } -#[test] -fn a_component_sets_editor_outline_is_not_exported() { - let mut s = Session::new(); - let a = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40}))); - let b = id(&ex(&mut s, "node.create", json!({"type": "component", "x": 300, "y": 0, "width": 100, "height": 40}))); - let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); - assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the purple dashed frame is editor chrome, not a stroke"); - let svg = text(&export(&mut s, json!({"ids": [set], "format": "svg"}))); - assert!(!svg.to_ascii_lowercase().contains("9747ff") && !svg.contains("stroke-dasharray"), "{svg}"); - let png = pixels(&export(&mut s, json!({"ids": [set]}))); - let purple = png.pixels().filter(|p| p[3] > 0 && p[0].abs_diff(0x97) < 24 && p[1].abs_diff(0x47) < 24 && p[2].abs_diff(0xFF) < 24).count(); - assert_eq!(purple, 0, "the exported set has no purple outline pixels"); -} - #[test] fn copy_as_png_svg_and_code() { let mut s = Session::new(); diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 143dc82a0..889074f18 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -186,36 +186,6 @@ fn files_with_every_field_spelled_out_still_open() { assert_same_doc(&d, &again); } -#[test] -fn a_component_sets_old_editor_outline_is_dropped_only_without_the_marker() { - let mut d = Document::new("Sets"); - let page = d.first_page().unwrap(); - let old = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 100.0, 40.0).unwrap(); - let custom = d.create(page, NodeKind::ComponentSet { description: String::new() }, "Chip", 0.0, 80.0, 100.0, 40.0).unwrap(); - // As 0.6.0 wrote every set: the canvas's purple dashed outline stored as a real stroke. - let n = d.get_mut(old).unwrap(); - n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))]; - n.stroke.dashes = vec![10.0, 5.0]; - // A stroke someone chose is design content and stays. - let n = d.get_mut(custom).unwrap(); - n.strokes = vec![Paint::solid(Color::from_rgb8_const(0x11, 0x22, 0x33))]; - n.stroke.dashes = vec![1.0, 2.0]; - // A file this version writes carries the marker: the same stroke is a choice, not decoration. - let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); - let (current, _) = from_json(&text).unwrap(); - assert_eq!(current.node(old).unwrap().strokes.len(), 1, "a marked file keeps a stroke someone chose"); - assert_eq!(current.node(custom).unwrap().strokes.len(), 1); - // A file without the marker is from a version that stored the decoration; drop exactly that. - let mut legacy = to_json(&d).unwrap(); - legacy.as_object_mut().unwrap().remove("editorOutlines"); - let (back, _) = from_json(&serde_json::to_string(&legacy).unwrap()).unwrap(); - let old = back.node(old).unwrap(); - assert!(old.strokes.is_empty() && old.stroke.dashes.is_empty(), "the decoration is canvas chrome, not a document stroke"); - let custom = back.node(custom).unwrap(); - assert_eq!(custom.strokes.len(), 1, "a real stroke is not the decoration"); - assert_eq!(custom.stroke.dashes, vec![1.0, 2.0]); -} - #[test] fn the_header_is_read_on_its_own() { // A newer file is refused before its document is looked at, whatever the version's size. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4ae154e74..9f7e51bc4 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -28,8 +28,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Seek, Write}; use std::sync::Arc; -use figcraft_doc::color::{Color, Paint}; -use figcraft_doc::{Document, ImageData, NodeKind}; +use figcraft_doc::{Document, ImageData}; use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Value, json}; @@ -232,18 +231,13 @@ struct DocumentJson<'a> { version: u32, #[serde(rename = "imageIndex", serialize_with = "image_index")] images: &'a BTreeMap, - /// The editor draws component-set outlines itself instead of storing them as strokes. Files - /// without this marker were written by a version that stored the purple dashed outline as a - /// real stroke (see [`drop_component_set_decoration`]). - #[serde(rename = "editorOutlines")] - editor_outlines: bool, #[serde(flatten)] doc: &'a Document, } impl<'a> DocumentJson<'a> { fn new(doc: &'a Document) -> Self { - Self { format: FORMAT, version: VERSION, images: &doc.images, editor_outlines: true, doc } + Self { format: FORMAT, version: VERSION, images: &doc.images, doc } } } @@ -282,8 +276,6 @@ type ImageIndex = Option>; struct Header { version: Option, images: ImageIndex, - /// Whether the writer drew component-set outlines outside the document. - editor_outlines: bool, } impl<'de> Deserialize<'de> for Header { @@ -299,7 +291,6 @@ impl<'de> Deserialize<'de> for Header { while let Some(key) = map.next_key::()? { match key.as_str() { "version" => h.version = map.next_value::()?.as_u64(), - "editorOutlines" => h.editor_outlines = map.next_value::()?.as_bool().unwrap_or(false), "imageIndex" => { h.images = map .next_value::()? @@ -318,21 +309,6 @@ impl<'de> Deserialize<'de> for Header { } } -/// Earlier versions gave every component set the purple dashed outline of the canvas as a real -/// stroke, so it exported with the set. New sets carry no paint; files written back then lose -/// exactly that decoration when opened, and the canvas draws the outline itself. -fn drop_component_set_decoration(doc: &mut Document) { - let decoration = Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF)); - let ids: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).map(|n| n.id).collect(); - for id in ids { - let Some(n) = doc.node_mut(id) else { continue }; - if n.strokes.len() == 1 && n.strokes.first() == Some(&decoration) && n.stroke.dashes.as_slice() == [10.0, 5.0] { - n.strokes.clear(); - n.stroke.dashes.clear(); - } - } -} - /// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first /// (a newer file is refused before its document is read), then the document is deserialized /// straight from the text. @@ -345,11 +321,6 @@ fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatE return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX))); } let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; - // Only files from before the marker can carry the old stored decoration; a file this version - // wrote keeps a stroke someone chose, even one that looks exactly like it. - if !header.editor_outlines { - drop_component_set_decoration(&mut doc); - } let issues = doc.validate(); if !issues.is_empty() { doc.repair(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 627fae565..9e0c1d797 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -936,13 +936,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } - // Component sets keep Figma's purple dashed frame on the canvas: editor chrome, not a - // document paint, so it never shows in an export. - if matches!(n.kind, NodeKind::ComponentSet { .. }) - && let Some(q) = node_quad(app, &doc, *c) - { - painter.add(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); - } } // Prototype connections. crate::proto_canvas::draw(app, painter); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index bca1f4fbc..4cd32d565 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -51,20 +51,6 @@ fn frame_outlines_and_slices_draw_when_shown() { assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again"); } -#[test] -fn component_sets_draw_their_editor_outline() { - let mut s = Session::new(); - let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); - let b = id(&s.execute("node.create", &json!({"type": "component", "x": 200, "y": 0, "width": 100, "height": 40})).unwrap()); - let set = id(&s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap()); - s.execute("select.set", &json!({"ids": []})).unwrap(); - assert!(s.doc().unwrap().node(set).unwrap().strokes.is_empty(), "the outline is not a document stroke"); - let mut u = Ui::new(s); - let q = crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), set).unwrap(); - let shapes = flat(&u.frame_output(vec![]).shapes); - assert!(dashes_along(&shapes, q[0], q[1]) > 3, "the set shows its dashed outline"); -} - /// A job a few hundred milliseconds of rendering big. fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job { crate::render_worker::Job { diff --git a/docs/components-parity.md b/docs/components-parity.md index 8be233629..2640ed1c0 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -76,7 +76,6 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Component sets: the purple dashed frame is canvas chrome again. A new set carries no stroke, the canvas draws the outline, and exports and screenshots leave it out; a file written by 0.6.0 loses exactly that stored decoration when opened. A stroke someone chose stays. Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | From f1d336249a3dddaf0688eb8c2b58e267e77ca87a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:54:24 +0700 Subject: [PATCH 6/8] #801: vector.create accepts 0 on an axis the geometry has none of and refuses a flattening size; points scaling tests --- crates/engine/src/cmd/create.rs | 24 +++++++++------- crates/engine/src/vector_behaviour_tests.rs | 31 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84fa419a1..3a9f6d67f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,15 +167,23 @@ pub fn parse_path_data(d: &str) -> Option { } /// 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 -/// asked for on an axis the geometry has none of is an error, not silently ignored. +/// 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 +/// or less on an axis it has is refused rather than flattening it. fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { let fit = |asked: Option, have: f64, axis: &str| match asked { - Some(a) if have > 1e-9 => Ok(a.max(0.0) / have), - Some(_) => Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })), None => Ok(1.0), + Some(a) if have <= 1e-9 => { + if a == 0.0 { + Ok(1.0) + } else { + Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })) + } + } + Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have), + Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")), }; - let (sx, sy) = match (width, height) { + Ok(match (width, height) { (Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?), (Some(_), None) => { let s = fit(width, bw, "width")?; @@ -186,11 +194,7 @@ fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: (s, s) } (None, None) => (1.0, 1.0), - }; - if sx <= 0.0 && sy <= 0.0 { - return Err(format!("`width` and `height` would make {what} 0×0")); - } - Ok((sx, sy)) + }) } pub fn create(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 35d610746..fb734085b 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -468,7 +468,32 @@ fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { assert!(e.contains("horizontal extent"), "{e}"); let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); assert!(e.contains("vertical extent"), "{e}"); - // A size that would collapse both axes is refused too. - let e = s.execute("vector.create", &json!({"path": path, "width": 0})).unwrap_err().to_string(); - assert!(e.contains("0×0"), "{e}"); + // But 0 on an axis it has none of is what it already is: a horizontal line with height 0. + let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap(); + let n = s.doc().unwrap().node(line).unwrap(); + assert_eq!((n.width, n.height), (16.0, 0.0)); + // A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other. + for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] { + let e = s.execute("vector.create", &p).unwrap_err().to_string(); + assert!(e.contains("positive"), "{p}: {e}"); + } +} + +#[test] +fn vector_points_take_the_requested_size() { + let mut s = Session::new(); + let points = json!([[10, 20], [34, 20], [34, 32]]); + let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::>(); + // Both sides: each axis scales on its own; the vertices move with the box. + let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (48.0, 6.0)); + assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]); + // One side: the other keeps its proportion. + let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap(); + let n = s.doc().unwrap().node(one).unwrap(); + assert_eq!((n.width, n.height), (48.0, 24.0)); + assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]); + let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string(); + assert!(e.contains("positive"), "{e}"); } From 057bf9a28955b5eb427362436acd0dc4dc82736c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:54:25 +0700 Subject: [PATCH 7/8] #801: create_node and mcp.md say width/height scale a vector's path --- crates/mcp/src/tools.rs | 2 +- docs/mcp.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 79ef49458..7714da5a3 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec { "type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"}, "x": num("Left, relative to the parent frame (default 0)"), "y": num("Top, relative to the parent frame (default 0)"), - "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"), + "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"), "height": num("Height (default 100; ignored for lines/arrows and auto-height text)"), "parent": id_schema("Parent frame/group/component id (default: the current page)"), "index": int("Z-index among the parent's children (default: on top)"), diff --git a/docs/mcp.md b/docs/mcp.md index 6191b7410..c5ffe99f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -429,8 +429,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale the geometry to the size asked for (with both, each side independently; with one, the other keeps its proportion). A path or point set that draws nothing is refused instead of making an invisible -0×0 layer, as is a size on an axis the geometry has none of and a size that would collapse both -axes. +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. 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 From fec902b7d554f9d764115dc397ff40d0851d6fc7 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:00:25 +0800 Subject: [PATCH 8/8] M1: refuse negative vector sizes A negative width or height was clamped to zero by the scale helper, producing a layer smaller than asked for; vector.create now says so. Regression test covers width -10 with height 8. --- crates/engine/src/cmd/create.rs | 7 ++++++- crates/engine/src/vector_behaviour_tests.rs | 4 +++- docs/mcp.md | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 84fa419a1..d9e64203b 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -170,8 +170,13 @@ pub fn parse_path_data(d: &str) -> Option { /// independently (as resizing a vector does); with one, the other keeps its proportion. A size /// asked for on an axis the geometry has none of is an error, not silently ignored. fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { + for (axis, asked) in [("width", width), ("height", height)] { + if asked.is_some_and(|a| a < 0.0) { + return Err(format!("`{axis}` can't be negative")); + } + } let fit = |asked: Option, have: f64, axis: &str| match asked { - Some(a) if have > 1e-9 => Ok(a.max(0.0) / have), + Some(a) if have > 1e-9 => Ok(a / have), Some(_) => Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })), None => Ok(1.0), }; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 35d610746..7410bc04f 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -468,7 +468,9 @@ fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { assert!(e.contains("horizontal extent"), "{e}"); let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); assert!(e.contains("vertical extent"), "{e}"); - // A size that would collapse both axes is refused too. + // A size that would collapse both axes is refused too, and so is a negative one. let e = s.execute("vector.create", &json!({"path": path, "width": 0})).unwrap_err().to_string(); assert!(e.contains("0×0"), "{e}"); + let e = s.execute("vector.create", &json!({"path": path, "width": -10, "height": 8})).unwrap_err().to_string(); + assert!(e.contains("can't be negative"), "{e}"); } diff --git a/docs/mcp.md b/docs/mcp.md index 864bc1f19..cb2cb8707 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -429,8 +429,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale the geometry to the size asked for (with both, each side independently; with one, the other keeps its proportion). A path or point set that draws nothing is refused instead of making an invisible -0×0 layer, as is a size on an axis the geometry has none of and a size that would collapse both -axes. +0×0 layer, as is a size on an axis the geometry has none of, a negative size, and a size that +would collapse both axes. 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