diff --git a/crates/op-figma/examples/probe_frame.rs b/crates/op-figma/examples/probe_frame.rs index a8215e689..6084d2cf0 100644 --- a/crates/op-figma/examples/probe_frame.rs +++ b/crates/op-figma/examples/probe_frame.rs @@ -41,6 +41,12 @@ const INTERESTING: &[&str] = &[ "transform", "parentIndex", "type", + "horizontalConstraint", + "verticalConstraint", + "constraints", + "proportionsConstrained", + "textAutoResize", + "fontSize", "blendMode", "styleType", ]; diff --git a/crates/op-figma/src/common.rs b/crates/op-figma/src/common.rs index 98d4164a3..3ee9eb214 100644 --- a/crates/op-figma/src/common.rs +++ b/crates/op-figma/src/common.rs @@ -323,9 +323,12 @@ fn has_command_geometry(figma: &FigValue) -> bool { }) } -fn scale_tree_node(mut child: TreeNode, sx: f64, sy: f64) -> TreeNode { +/// Rescale one node's own fields in place, without touching children. +/// Shared by the whole-subtree rescaler and by the override resolver, +/// which scales node-by-node so a branch can stop inheriting an outer +/// instance's ratio the moment Figma hands it resolved geometry. +pub(crate) fn scale_node_fields(figma: &mut FigValue, sx: f64, sy: f64) { let metric_scale = sx.abs().min(sy.abs()); - let figma = &mut child.figma; if let Some(mut transform) = figma.get("transform").cloned() { let m02 = transform.get_f64("m02").unwrap_or(0.0) * sx; let m12 = transform.get_f64("m12").unwrap_or(0.0) * sy; @@ -388,15 +391,18 @@ fn scale_tree_node(mut child: TreeNode, sx: f64, sy: f64) -> TreeNode { FigValue::Float(accumulated_y as f32), ); } +} +fn scale_tree_node(mut child: TreeNode, sx: f64, sy: f64) -> TreeNode { + scale_node_fields(&mut child.figma, sx, sy); // Stop at nested instances. Their own resolution pass rescales // their subtree against their (already-scaled) box, and their // derived entries are recorded in nested-instance space — letting // an ancestor's ratio recurse past this point applies it twice // (a 0.4-scaled outer instance squeezed a nested button's label // box from 24 px to 9.6 px while its derived font size stayed 12). - let is_nested_instance = child.figma.get_str("type") == Some("INSTANCE") - || child.figma.get("symbolData").is_some(); + let is_nested_instance = + child.figma.get_str("type") == Some("INSTANCE") || child.figma.get("symbolData").is_some(); if !is_nested_instance { child.children = scale_tree_children_owned(child.children, sx, sy); } diff --git a/crates/op-figma/src/instance.rs b/crates/op-figma/src/instance.rs index 3ad1f7d27..2b8253696 100644 --- a/crates/op-figma/src/instance.rs +++ b/crates/op-figma/src/instance.rs @@ -33,7 +33,7 @@ use apply::{ apply_to_node, flatten_dfs, local_id, strip_first_guid, virtual_guid_base, walk_virtual, }; pub use assignment::seed_assignments_from_instances; -use assignment::{guessed_mapping_is_implausible, rescale_only}; +use assignment::{guessed_mapping_is_implausible, instance_scale, rescale_only}; pub(crate) use swap_filter::filter_swap_stale_derived; /// Layout keys an instance inherits from its master SYMBOL. @@ -659,6 +659,8 @@ pub fn apply_instance_overrides_cached( return rescaled; } let empty: HashMap = HashMap::new(); + // `rescaled` already carries the instance ratio, so the walk + // below must not scale a second time. return rescaled .into_iter() .map(|c| { @@ -668,6 +670,8 @@ pub fn apply_instance_overrides_cached( &empty, &safe_nested_override, &safe_nested_derived, + &HashSet::new(), + (1.0, 1.0), ) }) .collect(); @@ -709,11 +713,17 @@ pub fn apply_instance_overrides_cached( } } - // Scale authored component-space geometry first. Derived size, - // transform, and font fields are already in instance space, so - // they overwrite this base rather than being scaled twice. - rescale_only(symbol_node, instance_size) - .into_iter() + // Hand the instance/symbol ratio to the resolver instead of + // pre-scaling the whole subtree: it scales branch by branch and + // drops the ratio wherever Figma's derived data already supplies + // instance-space geometry. + let scale = instance_scale(symbol_node, instance_size); + let mut derived_branch: HashSet = HashSet::new(); + mark_derived_branches(symbol_node, &node_derived, &mut derived_branch); + symbol_node + .children + .iter() + .cloned() .map(|c| { apply_to_node( c, @@ -721,7 +731,37 @@ pub fn apply_instance_overrides_cached( &node_derived, &nested_override, &nested_derived, + &derived_branch, + scale, ) }) .collect() } + +/// Collect the guids of every node whose derived entry carries resolved +/// GEOMETRY, plus their ancestors. Those branches are already in +/// instance space, so the instance/symbol ratio must not touch them. +/// A derived entry without `size` / `transform` (a bare marker, or one +/// that only overrides paint) says nothing about geometry and leaves +/// its branch on the component-space path. +fn mark_derived_branches( + node: &TreeNode, + node_derived: &HashMap, + out: &mut HashSet, +) -> bool { + let key = node.figma.get("guid").and_then(guid_to_string); + let mut has = key + .as_ref() + .and_then(|k| node_derived.get(k.as_str())) + .is_some_and(|d| d.get("size").is_some() || d.get("transform").is_some()); + for child in &node.children { + // No short-circuit: every marked descendant must be recorded. + has |= mark_derived_branches(child, node_derived, out); + } + if has { + if let Some(key) = key { + out.insert(key); + } + } + has +} diff --git a/crates/op-figma/src/instance/apply.rs b/crates/op-figma/src/instance/apply.rs index 6d749245b..8a1da55e2 100644 --- a/crates/op-figma/src/instance/apply.rs +++ b/crates/op-figma/src/instance/apply.rs @@ -3,11 +3,11 @@ //! Strategy-2 resolution and the foreign-session anchoring. use super::{guid_path_key, OVERRIDE_SKIP_KEYS}; -use crate::common::round2; +use crate::common::{round2, scale_node_fields}; use crate::figma_types::FigVec2; use crate::kiwi::FigValue; use crate::tree::{guid_to_string, TreeNode}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Local-id getter — falls back to 0 when `guid.localID` is absent /// (matches TS `a.figma.guid?.localID ?? 0`). @@ -115,6 +115,8 @@ pub(super) fn apply_to_node( node_derived: &HashMap, nested_override: &HashMap>, nested_derived: &HashMap>, + derived_branch: &HashSet, + scale: (f64, f64), ) -> TreeNode { let key = node .figma @@ -130,6 +132,34 @@ pub(super) fn apply_to_node( children, } = node; + // Instance rescale, branch by branch. Figma records resolved + // geometry ("derived") for the instance children it re-laid out + // and omits it entirely for branches that just came along for the + // ride. A branch that carries derived data anywhere inside it is + // already in instance space and must be left alone; only a branch + // with no derived data at all is still component-space and needs + // the instance/symbol ratio (that is the icon symbol whose artwork + // sat at (60,60) inside a 40x40 box). Scaling a derived branch + // instead pulled hidden price rows into view under text that kept + // its own resolved size. + let scales_here = !derived_branch.contains(&key); + let authored_size = figma.get("size").and_then(FigVec2::from_value); + if scales_here { + scale_node_fields(&mut figma, scale.0, scale.1); + } + // A node Figma resized itself re-bases its children on its own + // ratio. Inside an instance-space branch the ratio is spent, so + // children stay put — scaling them anyway left a 102 px row + // holding 6 px labels. + let child_scale = match ( + authored_size, + d.and_then(|d| d.get("size")).and_then(FigVec2::from_value), + ) { + (Some(a), Some(dv)) if a.x.abs() > 0.001 && a.y.abs() > 0.001 => (dv.x / a.x, dv.y / a.y), + _ if !scales_here => (1.0, 1.0), + _ => scale, + }; + if d.is_none() && ov.is_none() && nested_ov.is_none() && nested_d.is_none() { return TreeNode { figma, @@ -142,6 +172,8 @@ pub(super) fn apply_to_node( node_derived, nested_override, nested_derived, + derived_branch, + child_scale, ) }) .collect(), @@ -235,6 +267,8 @@ pub(super) fn apply_to_node( node_derived, nested_override, nested_derived, + derived_branch, + child_scale, ) }) .collect(), diff --git a/crates/op-figma/src/instance/assignment.rs b/crates/op-figma/src/instance/assignment.rs index 8a5beafe4..a01990e6f 100644 --- a/crates/op-figma/src/instance/assignment.rs +++ b/crates/op-figma/src/instance/assignment.rs @@ -241,6 +241,20 @@ pub fn seed_assignments_from_instances( } } +/// Ratio between an instance's box and its symbol's, `(1.0, 1.0)` +/// when either is missing or degenerate. +pub(super) fn instance_scale(symbol_node: &TreeNode, instance_size: Option) -> (f64, f64) { + if let (Some(size), Some(Some(sym_size))) = ( + instance_size, + symbol_node.figma.get("size").map(FigVec2::from_value), + ) { + if sym_size.x != 0.0 && sym_size.y != 0.0 { + return (size.x / sym_size.x, size.y / sym_size.y); + } + } + (1.0, 1.0) +} + /// Clone the symbol children rescaled to the instance size: the /// no-override fast path, also the fallback when a guessed mapping /// fails the confidence gate.