M6: .fig import: size overrides keep the constraint placement, nested instances resized again drop their old derived layout (#266)

This commit is contained in:
Marco Arnulfo
2026-10-14 12:12:46 +02:00
parent 01eb35df25
commit d8556e8f17
2 changed files with 102 additions and 19 deletions
+37 -17
View File
@@ -86,6 +86,10 @@ fn vec2(v: &Kv) -> Option<Value> {
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<Affine> {
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<Affine> {
@@ -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<String>, 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<String>,
overrides: Rc<BTreeMap<Vec<String>, Vec<&'a Kv<'a>>>>,
overrides: Rc<Overrides<'a>>,
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
/// Mains being expanded (an instance inside its own main stops there).
symbols: Vec<String>,
@@ -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<String> = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default();
let mut overrides: BTreeMap<Vec<String>, 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<String> =
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));
}
}
}
+65 -2
View File
@@ -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"]