From d8556e8f17afc4c33cc7a4f33d9a9fe7cef21d65 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:12:46 +0200 Subject: [PATCH] M6: .fig import: size overrides keep the constraint placement, nested instances resized again drop their old derived layout (#266) --- crates/format/src/fig/map.rs | 54 ++++++++++++++++++--------- crates/format/src/fig/tests.rs | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7d726183b..338bbcd29 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -86,6 +86,10 @@ fn vec2(v: &Kv) -> Option { Some(json!({"x": num(v, "x")?, "y": num(v, "y")?})) } +fn size(v: &Kv) -> Option<(f64, f64)> { + Some((num(v, "x")?, num(v, "y")?)) +} + fn matrix(m: &Kv) -> Option { let c = |k: &str| num(m, k); let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]); @@ -123,7 +127,7 @@ impl<'a> View<'a> { } fn size(&self) -> Option<(f64, f64)> { - self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(|s| Some((num(s, "x")?, num(s, "y")?)))) + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) } fn transform(&self) -> Option { @@ -185,7 +189,8 @@ impl Fit { let [a, b, c, d, _, _] = t.as_coeffs(); let lin = Affine::new([a, b, c, d, 0.0, 0.0]); let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h)); - let (kx, ky, x0, y0) = match self { + // Where the new box goes: its top-left (constraints) or its centre (a group's scale). + let (kx, ky, x0, y0, centred) = match self { Fit::Frame { from, to } => { let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), @@ -197,9 +202,9 @@ impl Fit { let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; - (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0) + (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false) } - Fit::Scale(kx, ky) => (kx, ky, bb.x0 * kx, bb.y0 * ky), + Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true), }; let along = |x: f64, y: f64, fallback: f64| { let len = x.hypot(y); @@ -208,17 +213,21 @@ impl Fit { }; let size = (w * along(a, b, kx), h * along(c, d, ky)); let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1)); - (size, Affine::translate((x0 - nb.x0, y0 - nb.y0)) * lin) + let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) }; + (size, Affine::translate((x0 - at.x, y0 - at.y)) * lin) } } +/// Overrides and derived layout by id path; each entry says whether it's derived layout. +type Overrides<'a> = BTreeMap, Vec<(bool, &'a Kv<'a>)>>; + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] struct Inst<'a> { root: String, path: Vec, - overrides: Rc, Vec<&'a Kv<'a>>>>, + overrides: Rc>, props: Rc>>, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, @@ -360,18 +369,23 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let mut view = match (inst, &path) { - (Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()), - _ => View::of(base), + let entries = match (inst, &path) { + (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), + _ => &[], }; + let mut view = View::of(base).with(entries.iter().map(|e| e.1)); // A layer of a resized instance the file gives no derived layout for follows its - // constraints (auto layout places the others, as in the editor). - let derived = view.layers.iter().skip(1).any(|l| l.get("size").is_some() || l.get("transform").is_some()); + // constraints from the main's geometry, then takes the size and place overridden on it + // (auto layout places the others, as in the editor). + let derived = entries.iter().any(|(d, l)| *d && (l.get("size").is_some() || l.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); - if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, view.size()) { - let t = view.transform().unwrap_or_default(); - view.fitted = Some(f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"))); + let main = View::of(base); + if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { + let t = main.transform().unwrap_or_default(); + let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); + let set = |k: &str| entries.iter().rev().find_map(|(_, l)| l.get(k)); + view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), @@ -996,9 +1010,15 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: BTreeMap, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + // A nested instance's derived layout is for the size it has in its main: resized here, it + // no longer holds and its layers follow their constraints instead. + let resized = outer.is_some() && self.nodes.get(id).and_then(|b| View::of(b).size()) != v.size(); let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; for (layer, list) in lists.into_iter().enumerate() { + if layer == 1 && resized { + continue; + } for ov in list { let rel: Vec = ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); @@ -1008,9 +1028,9 @@ impl<'a> Mapper<'a> { let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); // The outer instance's overrides win over this one's own; derived layout comes last. if layer == 0 && outer.is_some() { - e.insert(0, ov); + e.insert(0, (false, ov)); } else { - e.push(ov); + e.push((layer == 1, ov)); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index aa2d5d066..6d382c154 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -702,6 +702,24 @@ fn resized_instances_lay_their_layers_out_by_their_constraints() { ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), ], ), + // A size the instance overrides: the place still follows the constraints. + node( + 13, + Some((1, "$")), + "INSTANCE", + "Resized badge", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 300.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(5)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), + ]), + ), + ], + ), ]; let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); let box_of = |inst: &str, name: &str| { @@ -713,12 +731,57 @@ fn resized_instances_lay_their_layers_out_by_their_constraints() { assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); - // The main's own, then both instances'. + // The main's own, then the instances'. let mut letters: Vec<[f64; 2]> = doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); letters.sort_by(|a, b| a[0].total_cmp(&b[0])); - assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); + assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); +} + +#[test] +fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { + let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), + node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), + node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), + // The nested instance is 150 wide in its main, with the layout derived for that. + node( + 31, + Some((30, "!")), + "INSTANCE", + "Inner", + [ + vec![ + ("size", xy(150.0, 10.0)), + ("transform", at(0.0, 0.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("size", xy(150.0, 10.0))])])), + ], + stretch(), + ] + .concat(), + ), + node( + 40, + Some((1, "#")), + "INSTANCE", + "Wide slider", + vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let width = |outer: &str| { + let inner = doc.node(named(&doc, outer).children[0]).unwrap(); + (inner.width, doc.node(inner.children[0]).unwrap().width) + }; + // In its main the derived layout holds; stretched to 250 it no longer does. + assert_eq!(width("Slider"), (150.0, 150.0)); + assert_eq!(width("Wide slider"), (250.0, 250.0)); } #[path = "tail_tests.rs"]