From 2d6638599d1003f658955454d579c4211e09b0b3 Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Wed, 28 Oct 2026 22:10:39 +0100 Subject: [PATCH] M3: scaled instances keep their scale when their main is rebuilt (#922) The Scale tool (and `transform.scale`) scales a layer's children whatever their constraints. After the edit, overrides are recorded by diffing the changed layers, and a size or place that changed while an ancestor resized is read as what constraints give, so it isn't an override. For a scaled instance that left its layers' scaled size and points unrecorded: the next rebuild of the instance (a layer added to its main, say) put a 14 px icon's triangle back at the main's 24 px size. Strokes already kept their scaled weight, as a separate override group. `scale_node` now marks `size` and `position` overrides on the layers it scales below the scaled layer when they are layers of an instance and the value changed. Undo takes them back with the rest of the edit. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main; this covers the rebuild path that still did. Regressions: a scaled instance through value edits and a rebuild of its main; one scaled with the tool, with undo; a scaled icon nested in a component and in an instance of that component, through edits of both mains. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w --- ROADMAP.md | 4 +- crates/engine/src/cmd/arrange.rs | 14 +++ crates/engine/src/lib.rs | 2 + crates/engine/src/scaled_instance_tests.rs | 131 +++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/scaled_instance_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 74a617f3..a94fedb8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (scaled instances keep their scale when their main is rebuilt (#922); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. @@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 4806afb3..19572c95 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -405,6 +405,11 @@ fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result> { pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) }; let sub = doc.subtree(id); + // The layers below `id` scale whatever their constraints, so where they are layers of an + // instance their new size and place are overrides. The edit's own diff takes geometry that + // changed with an ancestor's size for what constraints give, so the next rebuild of the + // instance would put them back at their main's size (#922). + let in_instance: Vec = sub.iter().enumerate().map(|(depth, i)| depth > 0 && crate::components::inside_instance(doc, *i)).collect(); let tiles = |paints: &mut Vec| { for p in paints { if let figcraft_color::PaintKind::Image(img) = &mut p.kind @@ -416,6 +421,7 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { }; for (depth, i) in sub.iter().enumerate() { let n = doc.get_mut(*i)?; + let (w0, h0, t0) = (n.width, n.height, n.transform); tiles(&mut n.fills); tiles(&mut n.strokes); let cl = &mut n.child_layout; @@ -511,6 +517,14 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { let c = n.transform.as_coeffs(); n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]); } + if in_instance.get(depth).copied().unwrap_or(false) { + if n.width != w0 || n.height != h0 { + n.overrides.insert("size".into()); + } + if n.transform != t0 { + n.overrides.insert("position".into()); + } + } } Ok(()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ea135ca1..0d4b044c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -424,6 +424,8 @@ mod save_format_tests; #[cfg(test)] mod scale_tool_tests; #[cfg(test)] +mod scaled_instance_tests; +#[cfg(test)] mod section_contents_tests; #[cfg(test)] mod section_tests; diff --git a/crates/engine/src/scaled_instance_tests.rs b/crates/engine/src/scaled_instance_tests.rs new file mode 100644 index 00000000..f79e9534 --- /dev/null +++ b/crates/engine/src/scaled_instance_tests.rs @@ -0,0 +1,131 @@ +//! Instances scaled with the Scale tool keep their scaled layers when a main around them or their +//! own main changes (#922): a 24 px icon with a 1.5 px stroke shown at 14 px has a 0.875 px stroke, +//! and its points and size are 14/24 of its main's. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::{copy_of, ex, healthy, idv, node}; +use figcraft_doc::{NodeId, NodeKind}; + +/// The scale the icon is shown at: 24 px to 14 px. +const K: f64 = 14.0 / 24.0; + +/// Whether two lengths are equal up to rounding. +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// The icon: a 24 px component holding a flattened triangle with a 1.5 px stroke; returns the +/// component and the triangle. +fn icon(s: &mut Session) -> (NodeId, NodeId) { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 24, "height": 24}))); + let p = idv(&ex(s, "shape.polygon", json!({"x": 4, "y": 4, "width": 16, "height": 16, "parent": f}))); + ex(s, "vector.flatten", json!({"ids": [p]})); + let v = node(s, f).children[0]; + assert!(matches!(node(s, v).kind, NodeKind::Vector { .. })); + ex(s, "node.set", json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 1.5}})); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + (c, v) +} + +/// `copy` shows main layer `main` at 14/24: stroke, size and points. +fn assert_scaled(s: &Session, copy: NodeId, main: NodeId, why: &str) { + let (c, m) = (node(s, copy), node(s, main)); + assert!(near(c.stroke.weight, m.stroke.weight * K), "{why}: stroke {} (main {})", c.stroke.weight, m.stroke.weight); + assert!(near(c.width, m.width * K) && near(c.height, m.height * K), "{why}: size {}×{} (main {}×{})", c.width, c.height, m.width, m.height); + let (NodeKind::Vector { network: a }, NodeKind::Vector { network: b }) = (&c.kind, &m.kind) else { panic!("{why}: not vectors") }; + assert_eq!(a.vertices.len(), b.vertices.len(), "{why}"); + for (va, vb) in a.vertices.iter().zip(&b.vertices) { + assert!(near(va.x, vb.x * K) && near(va.y, vb.y * K), "{why}: point ({}, {}) (main ({}, {}))", va.x, va.y, vb.x, vb.y); + } +} + +/// Edits of a main: a value on its root, a value on a layer, a layer added (a rebuild). +fn main_edits(main: NodeId, layer: NodeId) -> Vec<(&'static str, Value)> { + vec![ + ("effects on the main", json!({"id": main, "props": {"effects": [{"type": "dropShadow"}]}})), + ("effects cleared on the main", json!({"id": main, "props": {"effects": []}})), + ("fill on the main", json!({"id": main, "props": {"fills": ["#ff0000"]}})), + ("fill on a main layer", json!({"id": layer, "props": {"fills": ["#00ff00"]}})), + ] +} + +/// An instance scaled on the page keeps its scaled layers through value edits of its main and +/// a rebuild (a layer added to the main). +#[test] +fn a_scaled_instance_keeps_its_scale_when_its_main_changes() { + let mut s = Session::new(); + let (c, v) = icon(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0}))); + ex(&mut s, "transform.scale", json!({"ids": [i], "factor": K})); + let iv = copy_of(&s, i, v); + assert_scaled(&s, iv, v, "scaled"); + for (why, p) in main_edits(c, v) { + ex(&mut s, "node.set", p); + assert_scaled(&s, copy_of(&s, i, v), v, why); + } + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c})); + assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main"); + healthy(&s); +} + +/// Dragging a Scale tool handle records the same overrides, and undoing the drag takes them back. +#[test] +fn an_instance_scaled_with_the_tool_keeps_its_scale_and_undo_takes_it_back() { + use crate::tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + let (c, v) = icon(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0}))); + ex(&mut s, "select.set", json!({"ids": [i]})); + ex(&mut s, "tool.scale", json!({})); + // The bottom right handle, from 24 px to 14 px about the top left corner. + for (kind, (x, y)) in + [(PointerKind::Down, (124.0, 24.0)), (PointerKind::Move, (119.0, 19.0)), (PointerKind::Move, (114.0, 14.0)), (PointerKind::Up, (114.0, 14.0))] + { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); + } + assert!(near(node(&s, i).width, 14.0), "{}", node(&s, i).width); + assert_scaled(&s, copy_of(&s, i, v), v, "scaled with the tool"); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c})); + assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main"); + s.undo().unwrap(); + s.undo().unwrap(); + let back = node(&s, copy_of(&s, i, v)); + assert!(near(back.stroke.weight, 1.5) && back.overrides.is_empty(), "undo: {} {:?}", back.stroke.weight, back.overrides); + healthy(&s); +} + +/// The report's case: an icon scaled inside a header that then becomes a component keeps its scale +/// in the header and in the header's instances, through edits of both mains. +#[test] +fn a_scaled_nested_icon_keeps_its_scale_when_the_component_holding_it_changes() { + let mut s = Session::new(); + let (c, v) = icon(&mut s); + // The header holds the icon at 14 px, scaled with the Scale tool, then becomes a component. + let h = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 200, "height": 48}))); + let hi = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 117, "parent": h}))); + ex(&mut s, "transform.scale", json!({"ids": [hi], "factor": K})); + let header = idv(&ex(&mut s, "component.create", json!({"ids": [h]}))); + let screen = idv(&ex(&mut s, "instance.create", json!({"component": header, "x": 0, "y": 300}))); + // The icon's triangle as the header and as the instance of the header show it. + let both = |s: &Session| { + let in_header = copy_of(s, hi, v); + [in_header, copy_of(s, copy_of(s, screen, hi), in_header)] + }; + let check = |s: &Session, why: &str| { + for (copy, where_) in both(s).into_iter().zip(["the header's icon", "the icon in an instance of the header"]) { + assert_scaled(s, copy, v, &format!("{why}: {where_}")); + } + }; + check(&s, "made a component"); + for (why, p) in main_edits(header, hi).into_iter().chain(main_edits(c, v)) { + ex(&mut s, "node.set", p); + check(&s, why); + } + ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 110, "width": 20, "height": 20, "parent": header})); + check(&s, "a layer added to the header"); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c})); + check(&s, "a layer added to the icon"); + healthy(&s); +}