From ac6adc2d548d8314a6513c462bce9c1915bbf5f3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 02:26:32 +0700 Subject: [PATCH] M7: imported nested instances link to their main's layers A nested instance opened from a .fig or file JSON document is first linked to its own component; matching it to the outer main's layer then went by that component's name instead of the layer's, so nested instances of variants (or under a name of their own) and their layers stayed unlinked, and a second export lost what they changed. They go by their own name until they mirror a layer. --- crates/engine/src/components.rs | 7 ++- crates/engine/src/fig_export_assets_tests.rs | 55 ++++++++++++++++++++ docs/file-format-parity.md | 2 +- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 98002e4..8145ea8 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -420,8 +420,11 @@ fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap = BTreeMap::new(); for c in &n.children { let Some(cn) = doc.node(*c) else { continue }; - let name = - if src_names { cn.source.and_then(|s| doc.node(s)).map(|s| s.name.as_str()).unwrap_or(cn.name.as_str()) } else { cn.name.as_str() }; + // A nested instance linked only to its own component so far (an imported one) mirrors no + // layer yet: it goes by its own name, like the main's layer it will be matched with. + let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component)); + let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s)); + let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str()); let k = seen.entry(name).or_insert(0); let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; *k += 1; diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs index de3d06f..9e8567b 100644 --- a/crates/engine/src/fig_export_assets_tests.rs +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -151,3 +151,58 @@ fn every_example_keeps_its_variables_and_styles() { } } } + +/// A component holding an instance of a variant, under a name of its own: opened from a `.fig` +/// file, instances of that component link every layer to the main's, the nested instance and its +/// layers included (a nested instance is first linked to its own component, whose name is not the +/// layer's). +#[test] +fn nested_instances_of_variants_link_to_their_mains_layers() { + let mut s = Session::new(); + let variant = |s: &mut Session, y: i64, fill: &str| { + let f = id(&run(s, "frame.create", json!({"x": 0, "y": y, "width": 120, "height": 80, "name": "Scene"}))); + run(s, "node.set", json!({"ids": [f], "fill": fill})); + run(s, "shape.ellipse", json!({"x": 70, "y": 10, "width": 30, "height": 30, "parent": f, "props": {"name": "Sun"}})); + id(&run(s, "component.create", json!({"ids": [f]}))) + }; + let (day, dusk) = (variant(&mut s, 0, "#8FCDF2"), variant(&mut s, 120, "#4A3A8E")); + run(&mut s, "node.set", json!({"ids": [day], "name": "Time=Day"})); + run(&mut s, "node.set", json!({"ids": [dusk], "name": "Time=Dusk"})); + run(&mut s, "component.combineVariants", json!({"ids": [day, dusk]})); + let card = id(&run(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 140, "name": "Card"}))); + let photo = id(&run(&mut s, "instance.create", json!({"component": day, "parent": card, "x": 10, "y": 10}))); + run(&mut s, "node.set", json!({"ids": [photo], "name": "Photo", "width": 180, "height": 120})); + let card = id(&run(&mut s, "component.create", json!({"ids": [card]}))); + let first = id(&run(&mut s, "instance.create", json!({"component": card, "x": 300, "y": 200}))); + let second = id(&run(&mut s, "instance.create", json!({"component": card, "x": 600, "y": 200}))); + run(&mut s, "node.set", json!({"ids": [first], "name": "First"})); + run(&mut s, "node.set", json!({"ids": [second], "name": "Second"})); + // The second one shows the other variant. + let nested = s.doc().unwrap().node(figcraft_doc::NodeId(second)).unwrap().children[0].0; + run(&mut s, "instance.swap", json!({"ids": [nested], "component": dusk})); + let before = outline(s.doc().unwrap()); + let path = std::env::temp_dir().join(format!("figcraft-fig-nested-{}.fig", std::process::id())); + run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()})); + let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + assert_eq!(r["warnings"], json!([])); + let doc = s.doc().unwrap(); + assert_eq!(outline(doc), before); + let mut nested = 0; + for inst in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) { + let inside = doc.ancestors(inst.id).iter().any(|a| doc.node(*a).is_some_and(|x| matches!(x.kind, NodeKind::Instance { .. }))); + nested += usize::from(inside); + for layer in doc.subtree(inst.id).into_iter().skip(1).filter_map(|i| doc.node(i)) { + assert!(layer.source.is_some(), "{} in {}", layer.name, inst.name); + } + // Inside another instance, a nested instance mirrors the main's layer, not its component. + if let (true, NodeKind::Instance { component, .. }) = (inside, &inst.kind) { + assert_ne!(inst.source, Some(*component), "{}", inst.name); + } + } + assert_eq!(nested, 2); + // Exported and opened once more, nothing moves: the links were what the export needed. + run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()})); + run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert_eq!(outline(s.doc().unwrap()), before); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7183c82..bf28290 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -155,7 +155,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | +| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | | 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder that mirrors the decoder and the schema of the fields written; what's left out is a warning. The import gained ellipse arcs, boolean operation types and component set descriptions, and reads durations as whole milliseconds. Verified only by round trips through our own importer. Summary ~59% → ~60%; `.fig` write 15–30 h → 5–15 h (checks against real files) | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Summary ~55% → ~56%; SVG options 1–2 h → done |