fix(figma): drop stale pre-swap derived so swapped icons render fully

A nested instance swapped via overriddenSymbolID keeps the pre-swap
component's derivedSymbolData alongside the swapped-in component's. When
the two frames are the same size the fingerprint can't tell them apart
and the stale (earlier-listed) cluster hijacks the mapping, sizing the
swapped frame wrong and clipping its icon. Cluster the derived entries
by localID and keep only the one that geometrically fits the swapped
subtree. Adds a near-exact geometric-match bonus so a near-perfect size
match outweighs the walk-order prior a stale sibling would otherwise
win on. Splits instance.rs (swap_filter.rs) and fingerprint_tests.rs
(foreign_tests.rs) to honor the 800-line cap.

Renders the Test.fig Sales-card pie icon and the other swapped card
icons at their correct size instead of clipped.
This commit is contained in:
Kayshen-X 2026-07-04 16:37:48 +08:00
parent eacc2ccbf0
commit 7885e25ab4
6 changed files with 607 additions and 280 deletions

View file

@ -291,8 +291,19 @@ fn convert_instance(
.get("symbolData")
.and_then(|s| s.get_array("symbolOverrides"))
.map(|a| a.to_vec());
let derived = figma.get_array("derivedSymbolData").map(|a| a.to_vec());
let instance_size = figma.get("size").and_then(FigVec2::from_value);
// A component swap (`overriddenSymbolID` differs from the
// base `symbolID`) leaves the pre-swap component's stale
// derived cluster in the array — drop it so it can't
// hijack the fingerprint mapping onto the swapped subtree.
let is_swap = figma.get("overriddenSymbolID").is_some();
let derived = figma.get_array("derivedSymbolData").map(|a| {
if is_swap {
crate::instance::filter_swap_stale_derived(a, &symbol_node, instance_size)
} else {
a.to_vec()
}
});
let children = apply_instance_overrides_cached(
&symbol_node,
overrides.as_deref(),

View file

@ -24,6 +24,9 @@ mod fingerprint;
mod fingerprint_tests;
mod foreign_session;
#[cfg(test)]
mod foreign_tests;
mod swap_filter;
#[cfg(test)]
mod tests;
use apply::{
@ -31,6 +34,7 @@ use apply::{
};
pub use assignment::seed_assignments_from_instances;
use assignment::{guessed_mapping_is_implausible, rescale_only};
pub(crate) use swap_filter::filter_swap_stale_derived;
/// Layout keys an instance inherits from its master SYMBOL.
const LAYOUT_KEYS: &[&str] = &[

View file

@ -208,6 +208,12 @@ fn score(e: &VirtualEntry, node: &TreeNode, node_idx: usize, ratios: (f64, f64))
let (rx, ry) = ratios;
let mut s = 0.0;
let mut evidence = false;
// Smallest geometric distance seen across the size/transform
// blocks — a near-EXACT match is decisive evidence that must beat
// the walk-order prior (which is meaningless when the derived
// array mixes two components, e.g. an `overriddenSymbolID` swap
// leaves the pre-swap component's stale entries in the array).
let mut best_geom_d = f64::INFINITY;
if let (Some(ds), Some(ns)) = (
entry_size(e),
@ -216,6 +222,7 @@ fn score(e: &VirtualEntry, node: &TreeNode, node_idx: usize, ratios: (f64, f64))
let dx = (ds.x - ns.x).abs().min((ds.x - ns.x * rx).abs());
let dy = (ds.y - ns.y).abs().min((ds.y - ns.y * ry).abs());
let d = dx + dy;
best_geom_d = best_geom_d.min(d);
s += if d <= 4.0 {
3.0
} else if d <= 24.0 {
@ -234,6 +241,7 @@ fn score(e: &VirtualEntry, node: &TreeNode, node_idx: usize, ratios: (f64, f64))
let dx = (tx - ax).abs().min((tx - ax * rx).abs());
let dy = (ty - ay).abs().min((ty - ay * ry).abs());
let d = dx + dy;
best_geom_d = best_geom_d.min(d);
s += if d <= 4.0 {
3.0
} else if d <= 8.0 {
@ -304,6 +312,17 @@ fn score(e: &VirtualEntry, node: &TreeNode, node_idx: usize, ratios: (f64, f64))
if !evidence {
return None;
}
// Near-exact geometric match bonus — a candidate within ~1px on
// both axes is almost certainly the true target, so it must
// outweigh the walk-order prior (max 0.4) that a stale sibling
// entry could otherwise win on (e.g. an `overriddenSymbolID` swap
// leaves the pre-swap component's stale derived first in the
// array). Capped at 0.45 so it beats the 0.4 walk prior but stays
// below a real fill-disambiguation signal (~0.5+); fades to 0 by
// d=1.
if best_geom_d <= 1.0 {
s += 0.45 * (1.0 - best_geom_d);
}
// Small walk-order proximity prior — tie-break only, can't clear
// the threshold alone.
s += 0.4 / (1.0 + (e.rel_idx - node_idx as f64).abs());

View file

@ -9,7 +9,7 @@ use super::*;
// ── Fingerprint assignment (Strategy 2 upgrade) ───────────────────
fn text_leaf(name: &str, lid: u32, chars: &str, w: f32, h: f32) -> TreeNode {
pub(super) fn text_leaf(name: &str, lid: u32, chars: &str, w: f32, h: f32) -> TreeNode {
let mut n = sized_leaf(name, lid, w, h, 0.0);
n.figma.set("type", FigValue::Str("TEXT".into()));
n.figma.set(
@ -413,6 +413,159 @@ fn unmatched_low_opacity_fill_falls_back_to_walk_order() {
);
}
/// Component-swap stale-derived shape (Test.fig Sales-card icon): an
/// `overriddenSymbolID` swap leaves the OLD component's derived
/// entries in the array (listed FIRST, so they seed the virtual-GUID
/// base and claim the walk-order prior) alongside the NEW component's
/// entries. A near-EXACT geometric match must beat the stale entry's
/// walk-order advantage so the swapped-in frame gets its true size.
#[test]
fn near_exact_geometry_beats_stale_walk_order_prior() {
// Swapped-in Graph icon subtree: frame 19.39, Stroke1 16.13,
// Stroke3 8.92 (master sizes from Test.fig).
let stroke3 = sized_leaf("stroke3", 12, 8.92, 8.79, 0.0);
let stroke1 = sized_leaf("stroke1", 11, 16.13, 16.04, 0.0);
let mut frame = sized_leaf("frame", 10, 19.39, 19.84, 0.0);
frame.figma.set("type", FigValue::Str("FRAME".into()));
let frame = TreeNode {
children: vec![stroke1, stroke3],
..frame
};
// Real Graph symbol is 24×24 → instance 20×20 gives ratio 0.833.
let sym = TreeNode {
figma: obj(vec![
("type", FigValue::Str("SYMBOL".into())),
("guid", guid(0, 0)),
("size", size(24.0, 24.0)),
]),
children: vec![frame],
};
// 8 derived entries as production forwards them: the STALE 2-User
// component (13723-13727, FIRST — seeds the base + walk prior)
// then the correct Graph entries (13627-13629). Instance box 20×20.
let d = |lid: u32, x: f32, y: f32| derived_with(vec![guid(2, lid)], vec![("size", size(x, y))]);
let derived = vec![
d(13723, 15.4, 14.63),
d(13724, 11.4, 4.67),
d(13725, 7.31, 7.31),
d(13726, 2.36, 5.43),
d(13727, 2.2, 2.9),
d(13627, 16.16, 16.54),
d(13628, 13.44, 13.37),
d(13629, 7.43, 7.32),
];
// Production also forwards strokePaints overrides on the two
// Graph strokes (13628/13629) — include them so the scoring
// matches the real path.
let stroke_paint = || FigValue::Array(vec![obj(vec![("type", FigValue::Str("SOLID".into()))])]);
let over = vec![
ov_with(vec![guid(2, 13628)], vec![("strokePaints", stroke_paint())]),
ov_with(vec![guid(2, 13629)], vec![("strokePaints", stroke_paint())]),
];
let out = apply_instance_overrides(
&sym,
Some(&over),
Some(&derived),
Some(FigVec2 { x: 20.0, y: 20.0 }),
);
fn find<'a>(nodes: &'a [TreeNode], name: &str) -> Option<&'a TreeNode> {
for n in nodes {
if n.figma.get_str("name") == Some(name) {
return Some(n);
}
if let Some(h) = find(&n.children, name) {
return Some(h);
}
}
None
}
let frame = find(&out, "frame").expect("frame present");
let fsz = FigVec2::from_value(frame.figma.get("size").unwrap()).unwrap();
assert!(
(fsz.x - 16.16).abs() < 0.3 && (fsz.y - 16.54).abs() < 0.3,
"near-exact Graph frame derived must win over stale 2-User derived, got {fsz:?}"
);
let stroke1 = find(&out, "stroke1").expect("stroke1 present");
let s1 = FigVec2::from_value(stroke1.figma.get("size").unwrap()).unwrap();
assert!(
(s1.x - 13.44).abs() < 0.3,
"stroke1 must keep its own derived size, got {s1:?}"
);
}
/// Component-swap SAME-SIZED-frame shape (Test.fig Folder/Graph
/// cards): the stale pre-swap "2 User" frame derived (15.4) and the
/// swapped-in Folder frame's true size are nearly IDENTICAL, so the
/// near-exact bonus can't disambiguate them and the stale entry wins
/// on walk-order. The stale cluster must be dropped by geometric fit
/// before fingerprinting.
#[test]
fn swap_drops_stale_cluster_with_same_sized_frame() {
// Swapped-in Folder icon: frame 18.5, two 8-ish strokes.
let s1 = sized_leaf("fstroke1", 12, 8.7, 8.7, 0.0);
let s2 = sized_leaf("fstroke2", 11, 8.6, 8.6, 0.0);
let mut frame = sized_leaf("folderframe", 10, 18.5, 17.55, 0.0);
frame.figma.set("type", FigValue::Str("FRAME".into()));
let frame = TreeNode {
children: vec![s2, s1],
..frame
};
let sym = TreeNode {
figma: obj(vec![
("type", FigValue::Str("SYMBOL".into())),
("guid", guid(0, 0)),
("size", size(24.0, 24.0)),
]),
children: vec![frame],
};
// Instance 20×20 → ratio 0.833: Folder frame → 15.42, strokes → ~7.25.
let d = |lid: u32, x: f32, y: f32| derived_with(vec![guid(2, lid)], vec![("size", size(x, y))]);
// STALE 2-User cluster (13723-13727) FIRST — frame 15.4 ≈ Folder
// frame's scaled 15.42, so it's indistinguishable by size.
let derived = vec![
d(13723, 15.4, 14.63),
d(13724, 11.4, 4.67),
d(13725, 7.31, 7.31),
d(13726, 2.36, 5.43),
d(13727, 2.2, 2.9),
// VALID Folder cluster (13617-13619) — all near-exact to Folder.
d(13617, 15.42, 14.63),
d(13618, 7.25, 7.25),
d(13619, 7.17, 7.17),
];
let out = filter_and_apply_swap(&sym, &derived, FigVec2 { x: 20.0, y: 20.0 });
fn find<'a>(nodes: &'a [TreeNode], name: &str) -> Option<&'a TreeNode> {
for n in nodes {
if n.figma.get_str("name") == Some(name) {
return Some(n);
}
if let Some(h) = find(&n.children, name) {
return Some(h);
}
}
None
}
let frame = find(&out, "folderframe").expect("frame present");
let fsz = FigVec2::from_value(frame.figma.get("size").unwrap()).unwrap();
// Whichever cluster wins, the frame must end ~15.4 (both agree) —
// the real signal is the STROKES: they must be Folder's ~7.25,
// NOT the stale 2-User's 11.4/2.36/2.2.
assert!((fsz.x - 15.42).abs() < 0.3, "frame size, got {fsz:?}");
let stroke = find(&out, "fstroke1").expect("stroke present");
let ssz = FigVec2::from_value(stroke.figma.get("size").unwrap()).unwrap();
assert!(
(ssz.x - 7.25).abs() < 0.5,
"Folder stroke must get its own ~7.25 size, not a stale 2-User size, got {ssz:?}"
);
}
/// Helper: run the swap-stale-derived filter then apply, mirroring
/// what `convert_instance` does when `overriddenSymbolID` swaps.
fn filter_and_apply_swap(sym: &TreeNode, derived: &[FigValue], isz: FigVec2) -> Vec<TreeNode> {
let filtered = crate::instance::filter_swap_stale_derived(derived, sym, Some(isz));
apply_instance_overrides(sym, None, Some(&filtered), Some(isz))
}
/// Space-between drift signature (Test.fig summary-card filter): the
/// derived transform moves a node along ONE axis only (x pushed by
/// justify, y identical). d=dx+dy lands in the dead zone between the
@ -512,281 +665,3 @@ fn conflicted_image_fill_reject_stays_dropped() {
"conflicted loser must not revive onto a sibling via walk order"
);
}
/// Some files anchor the virtual numbering at the symbol ROOT (the
/// base pk's derived size equals the symbol size). The walk-order
/// fallback must then use the root-anchored walk — the children-first
/// walk would shift every mapping by one.
#[test]
fn root_anchored_numbering_uses_root_walk_fallback() {
let sym = symbol_root(vec![
sized_leaf("a", 10, 100.0, 100.0, 0.0),
sized_leaf("b", 11, 100.0, 100.0, 0.0),
sized_leaf("c", 12, 100.0, 100.0, 0.0),
]);
// Base pk 9:50 carries the SYMBOL's own size (100×100 root) →
// numbering is root-anchored: 9:50=root, 9:51=a, 9:52=b, 9:53=c.
let derived = vec![
derived_with(vec![guid(9, 50)], vec![("size", size(100.0, 100.0))]),
guid_path(vec![guid(9, 51)]),
guid_path(vec![guid(9, 52)]),
guid_path(vec![guid(9, 53)]),
guid_path(vec![guid(9, 54)]),
];
// Signal-less name override on 9:51 → must land on "a" (root walk),
// not "b" (children-walk guess).
let over = vec![ov_with(
vec![guid(9, 51)],
vec![("name", FigValue::Str("Tinted".into()))],
)];
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let names: Vec<Option<&str>> = out.iter().map(|c| c.figma.get_str("name")).collect();
assert_eq!(
names[0],
Some("Tinted"),
"root-anchored numbering: 9:51 is the FIRST child, got {names:?}"
);
}
/// Multi-session virtual spaces: a secondary session's numbering is
/// anchored at some SUBTREE of the symbol (its own component-history
/// space). The anchor is only trusted when the group's text-demand
/// entries land on TEXT nodes under it — then signal-less opacity
/// hides apply to the right items.
#[test]
fn secondary_session_anchors_to_validated_subtree() {
let t = |name: &str, lid: u32, chars: &str| text_leaf(name, lid, chars, 40.0, 14.0);
let crumbs = TreeNode {
figma: obj(vec![
("type", FigValue::Str("FRAME".into())),
("guid", guid(1, 20)),
("name", FigValue::Str("crumbs".into())),
("size", size(200.0, 20.0)),
]),
children: vec![
t("t1", 21, "Page"),
t("t2", 22, "Page"),
t("t3", 23, "Page"),
],
};
let header = sized_leaf("header", 10, 300.0, 40.0, 0.0);
let sym = symbol_root(vec![header, crumbs]);
// 4 bare base-session entries (9:xx) dodge Strategy 1's count
// match; secondary session 30 carries 3 text-demand entries and a
// visible=false on 30:71 (→ t1 under the crumbs-children anchor).
let derived = vec![
guid_path(vec![guid(9, 50)]),
guid_path(vec![guid(9, 51)]),
guid_path(vec![guid(9, 52)]),
guid_path(vec![guid(9, 53)]),
derived_with(vec![guid(30, 71)], vec![("derivedTextData", obj(vec![]))]),
derived_with(vec![guid(30, 72)], vec![("derivedTextData", obj(vec![]))]),
derived_with(vec![guid(30, 73)], vec![("derivedTextData", obj(vec![]))]),
];
let over = vec![ov_with(
vec![guid(30, 71)],
vec![("visible", FigValue::Bool(false))],
)];
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
fn find<'a>(nodes: &'a [TreeNode], name: &str) -> Option<&'a TreeNode> {
for n in nodes {
if n.figma.get_str("name") == Some(name) {
return Some(n);
}
if let Some(hit) = find(&n.children, name) {
return Some(hit);
}
}
None
}
let t1 = find(&out, "t1").expect("t1 present");
assert_eq!(
t1.figma.get_bool("visible"),
Some(false),
"validated subtree anchor must route the hide to t1"
);
let header = find(&out, "header").expect("header present");
assert_ne!(header.figma.get_bool("visible"), Some(false));
}
/// Breadcrumb regression shape (Test.fig Top Nav): a foreign group's
/// text demands ALL validate under a wrong lid-order anchor, but the
/// group also carries a nested-path HEAD pk — which that anchor maps
/// onto a plain FRAME. A head can only be an INSTANCE, so the anchor
/// must be rejected: the opacity=0 meant for an item row must never
/// hide the container frame.
fn breadcrumb_shape() -> (TreeNode, Vec<FigValue>, Vec<FigValue>) {
let t = |name: &str, lid: u32, chars: &str| text_leaf(name, lid, chars, 40.0, 14.0);
let item = |lid: u32, text_lid: u32| TreeNode {
figma: obj(vec![
("type", FigValue::Str("FRAME".into())),
("guid", guid(1, lid)),
("name", FigValue::Str("item".into())),
("size", size(45.0, 15.0)),
]),
children: vec![t("page", text_lid, "Page")],
};
let home = TreeNode {
figma: obj(vec![
("type", FigValue::Str("INSTANCE".into())),
("guid", guid(1, 21)),
("name", FigValue::Str("home".into())),
("size", size(16.0, 16.0)),
]),
children: vec![],
};
let crumbs = TreeNode {
figma: obj(vec![
("type", FigValue::Str("FRAME".into())),
("guid", guid(1, 20)),
("name", FigValue::Str("crumbs".into())),
("size", size(200.0, 20.0)),
]),
children: vec![home, item(22, 23), item(24, 25)],
};
let header = sized_leaf("header", 10, 300.0, 40.0, 0.0);
let sym = symbol_root(vec![header, crumbs]);
// Base-session bare entries (9:xx) dodge Strategy 1. Session 30:
// two IDENTICAL opacity=0 overrides (the hidden items), two
// derived-text entries, and a nested path headed at 30:75. Under
// the crumbs-self lid-order walk (71=crumbs, 72=home, 73=item,
// 74=page, 75=item, 76=page) the text demands 74/76 both land on
// TEXT — but 75 (a nested HEAD, so an INSTANCE) lands on a FRAME.
let derived = vec![
guid_path(vec![guid(9, 50)]),
guid_path(vec![guid(9, 51)]),
derived_with(vec![guid(30, 74)], vec![("derivedTextData", obj(vec![]))]),
derived_with(vec![guid(30, 76)], vec![("derivedTextData", obj(vec![]))]),
];
let over = vec![
ov_with(vec![guid(30, 71)], vec![("opacity", FigValue::Float(0.0))]),
ov_with(vec![guid(30, 73)], vec![("opacity", FigValue::Float(0.0))]),
ov_with(
vec![guid(30, 75), guid(29221, 5)],
vec![("name", FigValue::Str("x".into()))],
),
];
(sym, over, derived)
}
fn find_named<'a>(nodes: &'a [TreeNode], name: &str) -> Vec<&'a TreeNode> {
let mut out = Vec::new();
fn go<'a>(nodes: &'a [TreeNode], name: &str, out: &mut Vec<&'a TreeNode>) {
for n in nodes {
if n.figma.get_str("name") == Some(name) {
out.push(n);
}
go(&n.children, name, out);
}
}
go(nodes, name, &mut out);
out
}
#[test]
fn foreign_anchor_rejected_when_nested_head_lands_on_non_instance() {
let (sym, over, derived) = breadcrumb_shape();
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let crumbs = &find_named(&out, "crumbs")[0];
assert_ne!(
crumbs.figma.get_f64("opacity"),
Some(0.0),
"container frame must not take an item's opacity=0"
);
let home = &find_named(&out, "home")[0];
assert_ne!(home.figma.get_f64("opacity"), Some(0.0));
}
/// When no anchor validates, K identical cosmetic overrides matching
/// a UNIQUE family of K same-named same-typed siblings apply to the
/// whole family — permutation-independent, so no guessing involved.
#[test]
fn foreign_identical_overrides_apply_to_unique_sibling_family() {
let (sym, over, derived) = breadcrumb_shape();
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let items = find_named(&out, "item");
assert_eq!(items.len(), 2);
for it in &items {
assert_eq!(
it.figma.get_f64("opacity"),
Some(0.0),
"identical opacity=0 pair must land on the unique item family"
);
}
let crumbs = &find_named(&out, "crumbs")[0];
assert_ne!(crumbs.figma.get_f64("opacity"), Some(0.0));
}
/// The family pairing is order-ARBITRARY — it is only safe because
/// every entry carries the same cosmetic payload. Any per-pk derived
/// data riding on those pks (fontSize, sizes, …) must therefore be
/// dropped, not applied through the arbitrary pairing.
#[test]
fn family_fallback_applies_only_the_cosmetic_payload() {
let (sym, over, mut derived) = breadcrumb_shape();
// A derived fontSize rides on family payload pk 30:71 — fontSize
// alone is not a fingerprint signal, so the pk still reaches the
// family fallback with this data attached.
derived.push(derived_with(
vec![guid(30, 71)],
vec![("fontSize", FigValue::Float(99.0))],
));
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let items = find_named(&out, "item");
assert_eq!(items.len(), 2);
for it in &items {
assert_eq!(
it.figma.get_f64("opacity"),
Some(0.0),
"cosmetic payload still applies to the family"
);
assert_ne!(
it.figma.get_f64("fontSize"),
Some(99.0),
"per-pk derived data must not ride the arbitrary pairing"
);
}
}
/// A signal-bearing entry REJECTED by the fingerprint (its size
/// contradicts every candidate) must not keep its walk-order guess in
/// the head-resolution map — otherwise nested overrides forward into
/// a node the evidence already disqualified.
#[test]
fn rejected_mapping_does_not_forward_nested_overrides() {
let mut nested_instance = sized_leaf("nested", 10, 50.0, 50.0, 0.0);
nested_instance
.figma
.set("type", FigValue::Str("INSTANCE".into()));
let sym = symbol_root(vec![
nested_instance,
sized_leaf("b", 11, 60.0, 60.0, 0.0),
sized_leaf("c", 12, 70.0, 70.0, 0.0),
]);
// Walk order maps 9:50 → "nested". Its derived size (900×900)
// grossly contradicts every candidate → fingerprint rejects it.
let derived = vec![
derived_with(vec![guid(9, 50)], vec![("size", size(900.0, 900.0))]),
guid_path(vec![guid(9, 51)]),
// Nested entry headed by the REJECTED pk.
derived_with(
vec![guid(9, 50), guid(11, 8523)],
vec![("fontSize", FigValue::Float(20.0))],
),
];
let out = apply_instance_overrides(&sym, None, Some(&derived), None);
let nested = out
.iter()
.find(|n| n.figma.get_str("name") == Some("nested"))
.expect("nested present");
assert!(
nested.figma.get("derivedSymbolData").is_none(),
"nested forwarding must not ride a rejected walk guess, got {:?}",
nested.figma.get("derivedSymbolData")
);
// And the contradicted size must not have been applied anywhere.
let sz = FigVec2::from_value(nested.figma.get("size").unwrap()).unwrap();
assert!((sz.x - 50.0).abs() < 0.001);
}

View file

@ -0,0 +1,285 @@
//! Foreign-session anchoring, uniform-family fallback, swap-cluster
//! and nested-forwarding tests — carved off `fingerprint_tests.rs`
//! at the 800-line cap.
use super::fingerprint_tests::text_leaf;
use super::tests::{derived_with, guid, guid_path, obj, ov_with, size, sized_leaf, symbol_root};
use super::*;
/// Some files anchor the virtual numbering at the symbol ROOT (the
/// base pk's derived size equals the symbol size). The walk-order
/// fallback must then use the root-anchored walk — the children-first
/// walk would shift every mapping by one.
#[test]
fn root_anchored_numbering_uses_root_walk_fallback() {
let sym = symbol_root(vec![
sized_leaf("a", 10, 100.0, 100.0, 0.0),
sized_leaf("b", 11, 100.0, 100.0, 0.0),
sized_leaf("c", 12, 100.0, 100.0, 0.0),
]);
// Base pk 9:50 carries the SYMBOL's own size (100×100 root) →
// numbering is root-anchored: 9:50=root, 9:51=a, 9:52=b, 9:53=c.
let derived = vec![
derived_with(vec![guid(9, 50)], vec![("size", size(100.0, 100.0))]),
guid_path(vec![guid(9, 51)]),
guid_path(vec![guid(9, 52)]),
guid_path(vec![guid(9, 53)]),
guid_path(vec![guid(9, 54)]),
];
// Signal-less name override on 9:51 → must land on "a" (root walk),
// not "b" (children-walk guess).
let over = vec![ov_with(
vec![guid(9, 51)],
vec![("name", FigValue::Str("Tinted".into()))],
)];
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let names: Vec<Option<&str>> = out.iter().map(|c| c.figma.get_str("name")).collect();
assert_eq!(
names[0],
Some("Tinted"),
"root-anchored numbering: 9:51 is the FIRST child, got {names:?}"
);
}
/// Multi-session virtual spaces: a secondary session's numbering is
/// anchored at some SUBTREE of the symbol (its own component-history
/// space). The anchor is only trusted when the group's text-demand
/// entries land on TEXT nodes under it — then signal-less opacity
/// hides apply to the right items.
#[test]
fn secondary_session_anchors_to_validated_subtree() {
let t = |name: &str, lid: u32, chars: &str| text_leaf(name, lid, chars, 40.0, 14.0);
let crumbs = TreeNode {
figma: obj(vec![
("type", FigValue::Str("FRAME".into())),
("guid", guid(1, 20)),
("name", FigValue::Str("crumbs".into())),
("size", size(200.0, 20.0)),
]),
children: vec![
t("t1", 21, "Page"),
t("t2", 22, "Page"),
t("t3", 23, "Page"),
],
};
let header = sized_leaf("header", 10, 300.0, 40.0, 0.0);
let sym = symbol_root(vec![header, crumbs]);
// 4 bare base-session entries (9:xx) dodge Strategy 1's count
// match; secondary session 30 carries 3 text-demand entries and a
// visible=false on 30:71 (→ t1 under the crumbs-children anchor).
let derived = vec![
guid_path(vec![guid(9, 50)]),
guid_path(vec![guid(9, 51)]),
guid_path(vec![guid(9, 52)]),
guid_path(vec![guid(9, 53)]),
derived_with(vec![guid(30, 71)], vec![("derivedTextData", obj(vec![]))]),
derived_with(vec![guid(30, 72)], vec![("derivedTextData", obj(vec![]))]),
derived_with(vec![guid(30, 73)], vec![("derivedTextData", obj(vec![]))]),
];
let over = vec![ov_with(
vec![guid(30, 71)],
vec![("visible", FigValue::Bool(false))],
)];
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
fn find<'a>(nodes: &'a [TreeNode], name: &str) -> Option<&'a TreeNode> {
for n in nodes {
if n.figma.get_str("name") == Some(name) {
return Some(n);
}
if let Some(hit) = find(&n.children, name) {
return Some(hit);
}
}
None
}
let t1 = find(&out, "t1").expect("t1 present");
assert_eq!(
t1.figma.get_bool("visible"),
Some(false),
"validated subtree anchor must route the hide to t1"
);
let header = find(&out, "header").expect("header present");
assert_ne!(header.figma.get_bool("visible"), Some(false));
}
/// Breadcrumb regression shape (Test.fig Top Nav): a foreign group's
/// text demands ALL validate under a wrong lid-order anchor, but the
/// group also carries a nested-path HEAD pk — which that anchor maps
/// onto a plain FRAME. A head can only be an INSTANCE, so the anchor
/// must be rejected: the opacity=0 meant for an item row must never
/// hide the container frame.
fn breadcrumb_shape() -> (TreeNode, Vec<FigValue>, Vec<FigValue>) {
let t = |name: &str, lid: u32, chars: &str| text_leaf(name, lid, chars, 40.0, 14.0);
let item = |lid: u32, text_lid: u32| TreeNode {
figma: obj(vec![
("type", FigValue::Str("FRAME".into())),
("guid", guid(1, lid)),
("name", FigValue::Str("item".into())),
("size", size(45.0, 15.0)),
]),
children: vec![t("page", text_lid, "Page")],
};
let home = TreeNode {
figma: obj(vec![
("type", FigValue::Str("INSTANCE".into())),
("guid", guid(1, 21)),
("name", FigValue::Str("home".into())),
("size", size(16.0, 16.0)),
]),
children: vec![],
};
let crumbs = TreeNode {
figma: obj(vec![
("type", FigValue::Str("FRAME".into())),
("guid", guid(1, 20)),
("name", FigValue::Str("crumbs".into())),
("size", size(200.0, 20.0)),
]),
children: vec![home, item(22, 23), item(24, 25)],
};
let header = sized_leaf("header", 10, 300.0, 40.0, 0.0);
let sym = symbol_root(vec![header, crumbs]);
// Base-session bare entries (9:xx) dodge Strategy 1. Session 30:
// two IDENTICAL opacity=0 overrides (the hidden items), two
// derived-text entries, and a nested path headed at 30:75. Under
// the crumbs-self lid-order walk (71=crumbs, 72=home, 73=item,
// 74=page, 75=item, 76=page) the text demands 74/76 both land on
// TEXT — but 75 (a nested HEAD, so an INSTANCE) lands on a FRAME.
let derived = vec![
guid_path(vec![guid(9, 50)]),
guid_path(vec![guid(9, 51)]),
derived_with(vec![guid(30, 74)], vec![("derivedTextData", obj(vec![]))]),
derived_with(vec![guid(30, 76)], vec![("derivedTextData", obj(vec![]))]),
];
let over = vec![
ov_with(vec![guid(30, 71)], vec![("opacity", FigValue::Float(0.0))]),
ov_with(vec![guid(30, 73)], vec![("opacity", FigValue::Float(0.0))]),
ov_with(
vec![guid(30, 75), guid(29221, 5)],
vec![("name", FigValue::Str("x".into()))],
),
];
(sym, over, derived)
}
fn find_named<'a>(nodes: &'a [TreeNode], name: &str) -> Vec<&'a TreeNode> {
let mut out = Vec::new();
fn go<'a>(nodes: &'a [TreeNode], name: &str, out: &mut Vec<&'a TreeNode>) {
for n in nodes {
if n.figma.get_str("name") == Some(name) {
out.push(n);
}
go(&n.children, name, out);
}
}
go(nodes, name, &mut out);
out
}
#[test]
fn foreign_anchor_rejected_when_nested_head_lands_on_non_instance() {
let (sym, over, derived) = breadcrumb_shape();
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let crumbs = &find_named(&out, "crumbs")[0];
assert_ne!(
crumbs.figma.get_f64("opacity"),
Some(0.0),
"container frame must not take an item's opacity=0"
);
let home = &find_named(&out, "home")[0];
assert_ne!(home.figma.get_f64("opacity"), Some(0.0));
}
/// When no anchor validates, K identical cosmetic overrides matching
/// a UNIQUE family of K same-named same-typed siblings apply to the
/// whole family — permutation-independent, so no guessing involved.
#[test]
fn foreign_identical_overrides_apply_to_unique_sibling_family() {
let (sym, over, derived) = breadcrumb_shape();
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let items = find_named(&out, "item");
assert_eq!(items.len(), 2);
for it in &items {
assert_eq!(
it.figma.get_f64("opacity"),
Some(0.0),
"identical opacity=0 pair must land on the unique item family"
);
}
let crumbs = &find_named(&out, "crumbs")[0];
assert_ne!(crumbs.figma.get_f64("opacity"), Some(0.0));
}
/// The family pairing is order-ARBITRARY — it is only safe because
/// every entry carries the same cosmetic payload. Any per-pk derived
/// data riding on those pks (fontSize, sizes, …) must therefore be
/// dropped, not applied through the arbitrary pairing.
#[test]
fn family_fallback_applies_only_the_cosmetic_payload() {
let (sym, over, mut derived) = breadcrumb_shape();
// A derived fontSize rides on family payload pk 30:71 — fontSize
// alone is not a fingerprint signal, so the pk still reaches the
// family fallback with this data attached.
derived.push(derived_with(
vec![guid(30, 71)],
vec![("fontSize", FigValue::Float(99.0))],
));
let out = apply_instance_overrides(&sym, Some(&over), Some(&derived), None);
let items = find_named(&out, "item");
assert_eq!(items.len(), 2);
for it in &items {
assert_eq!(
it.figma.get_f64("opacity"),
Some(0.0),
"cosmetic payload still applies to the family"
);
assert_ne!(
it.figma.get_f64("fontSize"),
Some(99.0),
"per-pk derived data must not ride the arbitrary pairing"
);
}
}
/// A signal-bearing entry REJECTED by the fingerprint (its size
/// contradicts every candidate) must not keep its walk-order guess in
/// the head-resolution map — otherwise nested overrides forward into
/// a node the evidence already disqualified.
#[test]
fn rejected_mapping_does_not_forward_nested_overrides() {
let mut nested_instance = sized_leaf("nested", 10, 50.0, 50.0, 0.0);
nested_instance
.figma
.set("type", FigValue::Str("INSTANCE".into()));
let sym = symbol_root(vec![
nested_instance,
sized_leaf("b", 11, 60.0, 60.0, 0.0),
sized_leaf("c", 12, 70.0, 70.0, 0.0),
]);
// Walk order maps 9:50 → "nested". Its derived size (900×900)
// grossly contradicts every candidate → fingerprint rejects it.
let derived = vec![
derived_with(vec![guid(9, 50)], vec![("size", size(900.0, 900.0))]),
guid_path(vec![guid(9, 51)]),
// Nested entry headed by the REJECTED pk.
derived_with(
vec![guid(9, 50), guid(11, 8523)],
vec![("fontSize", FigValue::Float(20.0))],
),
];
let out = apply_instance_overrides(&sym, None, Some(&derived), None);
let nested = out
.iter()
.find(|n| n.figma.get_str("name") == Some("nested"))
.expect("nested present");
assert!(
nested.figma.get("derivedSymbolData").is_none(),
"nested forwarding must not ride a rejected walk guess, got {:?}",
nested.figma.get("derivedSymbolData")
);
// And the contradicted size must not have been applied anywhere.
let sz = FigVec2::from_value(nested.figma.get("size").unwrap()).unwrap();
assert!((sz.x - 50.0).abs() < 0.001);
}

View file

@ -0,0 +1,133 @@
//! Component-swap stale-derived filtering — when an
//! `overriddenSymbolID` swaps a nested instance's component, the raw
//! `derivedSymbolData` still carries the pre-swap component's entries
//! alongside the swapped-in one. This module drops the stale cluster
//! so it can\'t hijack the fingerprint mapping onto the new subtree.
use super::flatten_dfs;
use crate::figma_types::FigVec2;
use crate::kiwi::FigValue;
use crate::tree::TreeNode;
/// Drop the STALE pre-swap component's derived cluster when an
/// `overriddenSymbolID` swap has left it in the array. Figma keeps the
/// base component's derived (the icon before the swap) AND the
/// swapped-in component's derived; when the two frames happen to be
/// the same size the fingerprint can't tell them apart and the stale
/// (earlier-listed) cluster hijacks the mapping. Single-segment
/// entries are split into contiguous localID clusters and each is
/// scored by how well it fits the swapped subtree's node sizes; only
/// the best-fitting cluster's single-segment entries survive.
/// Multi-segment (nested) entries are always kept. A no-op when there
/// is one cluster or the best doesn't clearly beat the rest.
pub(crate) fn filter_swap_stale_derived(
derived: &[FigValue],
symbol_node: &TreeNode,
instance_size: Option<FigVec2>,
) -> Vec<FigValue> {
// Candidate node sizes (subtree minus root) + instance ratio.
let mut flat: Vec<&TreeNode> = Vec::new();
flatten_dfs(symbol_node, &mut flat);
let candidates: Vec<FigVec2> = flat[1..]
.iter()
.filter_map(|n| n.figma.get("size").and_then(FigVec2::from_value))
.collect();
if candidates.is_empty() {
return derived.to_vec();
}
let (rx, ry) = match (
instance_size,
symbol_node.figma.get("size").and_then(FigVec2::from_value),
) {
(Some(i), Some(s)) if s.x > 0.0 && s.y > 0.0 => (i.x / s.x, i.y / s.y),
_ => (1.0, 1.0),
};
// Single-segment entries with a localID + size; everything else is
// kept verbatim.
struct SingleEntry {
idx: usize,
lid: u32,
size: FigVec2,
}
let mut singles: Vec<SingleEntry> = Vec::new();
for (idx, e) in derived.iter().enumerate() {
let guids = e.get("guidPath").and_then(|p| p.get_array("guids"));
let Some(guids) = guids else { continue };
if guids.len() != 1 {
continue;
}
let Some(lid) = guids.first().and_then(|g| g.get_f64("localID")) else {
continue;
};
let Some(size) = e.get("size").and_then(FigVec2::from_value) else {
continue;
};
singles.push(SingleEntry {
idx,
lid: lid as u32,
size,
});
}
if singles.len() < 2 {
return derived.to_vec();
}
// Cluster by contiguous localID (gap > 16 starts a new cluster).
singles.sort_by_key(|s| s.lid);
let mut clusters: Vec<Vec<&SingleEntry>> = Vec::new();
for s in &singles {
match clusters.last_mut() {
Some(last) if s.lid - last.last().unwrap().lid <= 16 => last.push(s),
_ => clusters.push(vec![s]),
}
}
if clusters.len() < 2 {
return derived.to_vec();
}
// Fit score: mean over the cluster of the best near-match (0..1,
// 1 = exact) to any candidate node, dual-baseline (authored or
// ratio-scaled) like the fingerprint scorer.
let fit = |cluster: &[&SingleEntry]| -> f64 {
let mut total = 0.0;
for s in cluster {
let best = candidates
.iter()
.map(|c| {
let dx = (s.size.x - c.x).abs().min((s.size.x - c.x * rx).abs());
let dy = (s.size.y - c.y).abs().min((s.size.y - c.y * ry).abs());
dx + dy
})
.fold(f64::INFINITY, f64::min);
total += (1.0 - best / 4.0).max(0.0);
}
total / cluster.len() as f64
};
let mut scored: Vec<(f64, usize)> = clusters
.iter()
.enumerate()
.map(|(i, c)| (fit(c), i))
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let (best_score, best_i) = scored[0];
let (second_score, _) = scored[1];
// Only prune when the winner clearly fits better — a swapped-in
// cluster fits nearly perfectly while the stale one mostly doesn't.
if best_score < 0.6 || best_score - second_score < 0.2 {
return derived.to_vec();
}
let keep_lids: std::collections::HashSet<u32> =
clusters[best_i].iter().map(|s| s.lid).collect();
let drop_idx: std::collections::HashSet<usize> = singles
.iter()
.filter(|s| !keep_lids.contains(&s.lid))
.map(|s| s.idx)
.collect();
derived
.iter()
.enumerate()
.filter(|(i, _)| !drop_idx.contains(i))
.map(|(_, e)| e.clone())
.collect()
}