From 01eb35df25f3a26396be6c61b855cd8526acf86b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:00:02 +0200 Subject: [PATCH 1/3] M6: .fig import lays out the layers of resized instances by their constraints (fixes #266) --- crates/format/src/fig/map.rs | 120 ++++++++++++++++++++++++++++----- crates/format/src/fig/tests.rs | 58 +++++++++++++++- 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4..7d726183 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -113,11 +113,21 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { #[derive(Clone)] struct View<'a> { layers: Vec<&'a Kv<'a>>, + /// Size and transform worked out from the constraints, over the layers' own. + fitted: Option<((f64, f64), Affine)>, } impl<'a> View<'a> { fn of(n: &'a Kv<'a>) -> Self { - Self { layers: vec![n] } + Self { layers: vec![n], fitted: None } + } + + 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")?)))) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) } fn with(&self, top: impl IntoIterator>) -> Self { @@ -147,6 +157,61 @@ impl<'a> View<'a> { } } +/// How the layers of a resized container follow it when the file gives no derived layout for +/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. +#[derive(Clone, Copy)] +enum Fit { + Frame { from: (f64, f64), to: (f64, f64) }, + Scale(f64, f64), +} + +impl Fit { + fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + ((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to }) + } + + fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 }; + let (kx, ky) = (k(from.0, to.0), k(from.1, to.1)); + ((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky)) + } + + /// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with + /// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its + /// rotation and flip and stretches along its own axes. + fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) { + 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 { + 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), + Some("STRETCH") => (lo, hi + new - old), + Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0), + Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old), + _ => (lo, hi), + }; + 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) + } + Fit::Scale(kx, ky) => (kx, ky, bb.x0 * kx, bb.y0 * ky), + }; + let along = |x: f64, y: f64, fallback: f64| { + let len = x.hypot(y); + let k = (kx * x).hypot(ky * y) / len; + if len > 1e-12 && k.is_finite() { k } else { fallback } + }; + 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) + } +} + /// 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)] @@ -263,7 +328,7 @@ impl<'a> Mapper<'a> { let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow"); flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name}))); } - kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0)); + kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0)); } flows.sort_by(|a, b| a.0.cmp(b.0)); if !flows.is_empty() { @@ -276,7 +341,15 @@ impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks). - fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option { + fn layer( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + ) -> Option { if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -287,10 +360,19 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let view = match (inst, &path) { + 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), }; + // 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()); + 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 rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), _ => id.to_string(), @@ -338,21 +420,26 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); - let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { - space * view.get("transform").and_then(matrix).unwrap_or_default() - } else { - Affine::IDENTITY - }; + let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + let main = self.nodes.get(&sym).map(|m| View::of(m)); + let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); } } } else { + // Inside an instance a layer's children are its main's: they follow it from the main's + // size to its own. + let fit = match (inst, ty) { + (None, _) => None, + (Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()), + (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), + }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1)); + kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); } } if stand.is_some_and(|s| s.0 == "FRAME") { @@ -487,10 +574,10 @@ impl<'a> Mapper<'a> { if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { o.insert("blendMode".into(), json!(b)); } - if let Some(s) = v.get("size").and_then(vec2) { - o.insert("size".into(), s); + if let Some((x, y)) = v.size() { + o.insert("size".into(), json!({"x": x, "y": y})); } - let t = v.get("transform").and_then(matrix).unwrap_or_default(); + let t = v.transform().unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); // Paints, from the shared style when one is linked (the layer's own copy can be stale). let mut styles = Obj::new(); @@ -739,7 +826,8 @@ impl<'a> Mapper<'a> { vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { - let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k))); + let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); + let norm = vd.get("normalizedSize").and_then(|s| num(s, k)); size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); @@ -1128,7 +1216,7 @@ impl<'a> Mapper<'a> { found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); let mut out = Vec::new(); for (_, id, n) in found { - let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break }; + let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break }; let name = n.get("name").and_then(Kv::str).unwrap_or("Style"); let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or(""); let ty = n.get("styleType").and_then(Kv::str).unwrap_or(""); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 541739c7..aa2d5d06 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -32,6 +32,7 @@ enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQU enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC +enum ConstraintType MIN CENTER MAX STRETCH SCALE struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y @@ -70,7 +71,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -665,6 +666,61 @@ fn instances_inside_their_own_main_stop() { assert!(doc.nodes().any(|n| n.name == "Self")); } +#[test] +fn resized_instances_lay_their_layers_out_by_their_constraints() { + let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]; + let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { + node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), + layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), + layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), + layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), + // A group scales its layers with it. + layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), + node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), + node( + 9, + Some((1, "\"")), + "INSTANCE", + "Small logo", + vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], + ), + // Derived layout the file gives wins over the constraints. + node( + 10, + Some((1, "#")), + "INSTANCE", + "Derived logo", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 200.0)), + ("symbolData", o(vec![("symbolID", g(5))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), + ], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let box_of = |inst: &str, name: &str| { + let i = named(&doc, inst); + let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); + let t = n.transform.translation(); + [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) + }; + 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'. + 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!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); +} + #[path = "tail_tests.rs"] mod tail; From d8556e8f17afc4c33cc7a4f33d9a9fe7cef21d65 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:12:46 +0200 Subject: [PATCH 2/3] 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 7d726183..338bbcd2 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 aa2d5d06..6d382c15 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"] From ce366ea3258c9b1e40999ff75be4c6eea8ac1c61 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:14:45 +0200 Subject: [PATCH 3/3] M6: .fig import: a resized nested instance keeps the non-geometric derived data; resize check with a tolerance (#266) --- crates/format/src/fig/map.rs | 47 +++++++++++++++++++++++++--------- crates/format/src/fig/tests.rs | 12 ++++++++- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 338bbcd2..5a08dd40 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -218,8 +218,18 @@ impl Fit { } } -/// Overrides and derived layout by id path; each entry says whether it's derived layout. -type Overrides<'a> = BTreeMap, Vec<(bool, &'a Kv<'a>)>>; +/// Overrides and derived layout by id path. +type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; + +/// Where an override entry comes from. +#[derive(Clone, Copy, PartialEq)] +enum Entry { + Override, + Derived, + /// Derived layout of a nested instance for a size it no longer has: its size and place no + /// longer hold, the rest (resolved colours, text) still does. + Stale, +} /// 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. @@ -377,15 +387,19 @@ impl<'a> Mapper<'a> { // A layer of a resized instance the file gives no derived layout for follows its // 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 derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (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"); let main = View::of(base); + let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); 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))); + } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + && let Some(s) = set("size").and_then(size).or_else(|| main.size()) + { + view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), @@ -1011,14 +1025,16 @@ 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: 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(); + // A nested instance's derived layout is for the size it has in its main: resized here, the + // sizes and places in it no longer hold and its layers follow their constraints instead. + let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); + let resized = outer.is_some() + && match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) { + (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), + (a, b) => a.is_some() != b.is_some(), + }; 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(); @@ -1028,9 +1044,16 @@ 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, (false, ov)); + e.insert(0, (Entry::Override, ov)); } else { - e.push((layer == 1, ov)); + e.push(( + match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }, + ov, + )); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 6d382c15..af8ac5a0 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -760,7 +760,14 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { ("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))])])), + ( + "derivedSymbolData", + arr(vec![o(vec![ + ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), + ("size", xy(150.0, 10.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ])]), + ), ], stretch(), ] @@ -782,6 +789,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { // 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)); + // What else the derived data gives (the colour a variable resolves to, say) still holds. + let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); + assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); } #[path = "tail_tests.rs"]