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.
This commit is contained in:
phonedu
2026-10-18 18:22:48 +08:00
parent c87baa37cc
commit aff3c4f18b
5 changed files with 50 additions and 16 deletions
+17 -11
View File
@@ -167,24 +167,30 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
}
/// 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<f64>, height: Option<f64>, bw: f64, bh: f64) -> (f64, f64) {
let fit = |asked: Option<f64>, 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<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
let fit = |asked: Option<f64>, 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<Value> {
@@ -225,7 +231,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Point> = 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;
@@ -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}");
}
+9 -2
View File
@@ -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();
+14 -2
View File
@@ -232,13 +232,18 @@ struct DocumentJson<'a> {
version: u32,
#[serde(rename = "imageIndex", serialize_with = "image_index")]
images: &'a BTreeMap<String, ImageData>,
/// 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<BTreeSet<String>>;
struct Header {
version: Option<u64>,
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::<String>()? {
match key.as_str() {
"version" => h.version = map.next_value::<Value>()?.as_u64(),
"editorOutlines" => h.editor_outlines = map.next_value::<Value>()?.as_bool().unwrap_or(false),
"imageIndex" => {
h.images = map
.next_value::<Value>()?
@@ -337,7 +345,11 @@ fn parse_json(text: &str) -> Result<(Document, Vec<String>, 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();
+2 -1
View File
@@ -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