mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
132 lines
6.6 KiB
Rust
132 lines
6.6 KiB
Rust
//! 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);
|
||
}
|