diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dedd1e49..84fa419a 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 042b3321..35d61074 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 45eaf27c..143dc82a 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 93e67e5b..4ae154e7 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 caecf372..864bc1f1 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