diff --git a/crates/op-figma/src/converters.rs b/crates/op-figma/src/converters.rs index ae268a6f6..4b11757cf 100644 --- a/crates/op-figma/src/converters.rs +++ b/crates/op-figma/src/converters.rs @@ -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(), diff --git a/crates/op-figma/src/instance.rs b/crates/op-figma/src/instance.rs index 64fb08e0d..fc59efcb0 100644 --- a/crates/op-figma/src/instance.rs +++ b/crates/op-figma/src/instance.rs @@ -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] = &[ diff --git a/crates/op-figma/src/instance/fingerprint.rs b/crates/op-figma/src/instance/fingerprint.rs index 7c32d1a2c..9bf0df754 100644 --- a/crates/op-figma/src/instance/fingerprint.rs +++ b/crates/op-figma/src/instance/fingerprint.rs @@ -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()); diff --git a/crates/op-figma/src/instance/fingerprint_tests.rs b/crates/op-figma/src/instance/fingerprint_tests.rs index 6e4a178a5..3ddcd36ed 100644 --- a/crates/op-figma/src/instance/fingerprint_tests.rs +++ b/crates/op-figma/src/instance/fingerprint_tests.rs @@ -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 { + 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> = 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, Vec) { - 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); -} diff --git a/crates/op-figma/src/instance/foreign_tests.rs b/crates/op-figma/src/instance/foreign_tests.rs new file mode 100644 index 000000000..f7f2d0c96 --- /dev/null +++ b/crates/op-figma/src/instance/foreign_tests.rs @@ -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> = 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, Vec) { + 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); +} diff --git a/crates/op-figma/src/instance/swap_filter.rs b/crates/op-figma/src/instance/swap_filter.rs new file mode 100644 index 000000000..e7bb04b5a --- /dev/null +++ b/crates/op-figma/src/instance/swap_filter.rs @@ -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, +) -> Vec { + // Candidate node sizes (subtree minus root) + instance ratio. + let mut flat: Vec<&TreeNode> = Vec::new(); + flatten_dfs(symbol_node, &mut flat); + let candidates: Vec = 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 = 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::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 = + clusters[best_i].iter().map(|s| s.lid).collect(); + let drop_idx: std::collections::HashSet = 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() +}