From f9701ef855d98dd28c7b1c78eab2f2fd4bac7797 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 7 Aug 2026 08:38:18 +0300 Subject: [PATCH] fix(fig): preserve effective clone geometry - Restore authoritative image-leaf bounds after final component swaps\n- Preserve derived cross-axis positions for thin divider clones\n- Add synthetic and Gold Preview regression coverage --- CHANGELOG.md | 1 + .../derived-symbol-data/propagate.ts | 82 ++++++++++++++++++ packages/fig/src/instance-overrides/index.ts | 13 ++- packages/fig/tests/instance-overrides.test.ts | 83 +++++++++++++++++++ .../fig/import/instance-regressions.test.ts | 18 ++++ 5 files changed, 196 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b883e125..d220c41b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ ### Fixed - Preserve Figma’s imported glyph outlines through layout and appearance updates so text keeps its intended weight and shape. +- Keep swapped image avatars and thin stepper dividers at their effective imported size and position. - Scale proportion-constrained `.fig` instance geometry through fixed wrapper layers so imported logos and icons retain their intended size. - Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely. - Match Figma Plugin API vector path and network editing, including bounds, transforms, winding rules, region fills, validation, and handle mirroring. (#444) diff --git a/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts b/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts index 7422c34ae..a5d4010f1 100644 --- a/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts +++ b/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts @@ -48,6 +48,88 @@ function buildCloneUpdates( return updates } +export function reconcileEffectiveCloneGeometry( + ctx: OverrideContext, + scaledInstanceIds: Set +): void { + restoreScaledInstanceLeafBounds(ctx, scaledInstanceIds) + restoreThinCloneCrossPositions(ctx) +} + +function restoreScaledInstanceLeafBounds( + ctx: OverrideContext, + scaledInstanceIds: Set +): void { + for (const instanceId of scaledInstanceIds) { + const instance = ctx.graph.getNode(instanceId) + if (instance?.layoutMode !== 'NONE' || instance.childIds.length !== 1) continue + const child = ctx.graph.getNode(instance.childIds[0]) + if ( + !child || + child.childIds.length > 0 || + child.horizontalConstraint !== 'SCALE' || + child.verticalConstraint !== 'SCALE' + ) { + continue + } + const width = child.figmaDerivedLayout?.width + const height = child.figmaDerivedLayout?.height + const restoresDerivedBounds = width !== undefined && height !== undefined + const restoresImageBounds = + !restoresDerivedBounds && + child.type === 'ROUNDED_RECTANGLE' && + child.fills.some((fill) => fill.type === 'IMAGE') + if (!restoresDerivedBounds && !restoresImageBounds) continue + const restoredWidth = width ?? instance.width + const restoredHeight = height ?? instance.height + if (child.width === restoredWidth && child.height === restoredHeight) continue + ctx.graph.updateNode(child.id, { width: restoredWidth, height: restoredHeight }) + } +} + +function isThinCenteredCrossChild(parent: SceneNode, clone: SceneNode): boolean { + if (parent.counterAxisAlign !== 'CENTER') return false + if (parent.layoutMode === 'HORIZONTAL') return clone.height <= 1 && clone.width > clone.height + if (parent.layoutMode === 'VERTICAL') return clone.width <= 1 && clone.height > clone.width + return false +} + +function thinCloneCrossPosition( + graph: OverrideContext['graph'], + clone: SceneNode +): NonNullable | null { + if ( + clone.source.format !== null || + !clone.componentId || + !clone.name.endsWith('Divider') || + !clone.parentId || + clone.figmaDerivedLayout?.x !== undefined || + clone.figmaDerivedLayout?.y !== undefined + ) { + return null + } + const parent = graph.getNode(clone.parentId) + const source = graph.getNode(clone.componentId) + const sourceLayout = source?.figmaDerivedLayout + if (!parent || !source || sourceLayout?.x === undefined || sourceLayout.y === undefined) return null + if (clone.width !== source.width || clone.height !== source.height) return null + return isThinCenteredCrossChild(parent, clone) ? sourceLayout : null +} + +function restoreThinCloneCrossPositions(ctx: OverrideContext): void { + for (const clone of overrideCandidates(ctx.graph, ctx.activeNodeIds)) { + const sourceLayout = thinCloneCrossPosition(ctx.graph, clone) + if (!sourceLayout) continue + ctx.graph.updateNode(clone.id, { + figmaDerivedLayout: { + ...clone.figmaDerivedLayout, + x: sourceLayout.x, + y: sourceLayout.y + } + }) + } +} + export function applyGeneratedFreeformStretch(ctx: OverrideContext): void { for (const node of overrideCandidates(ctx.graph, ctx.activeNodeIds)) { if ( diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index a1571163b..336131664 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -29,7 +29,7 @@ import type { JsonObject } from '@open-pencil/scene-graph/primitives' import { applyComponentProperties } from './component-props' import { applyConstraintScaling } from './constraints' import { applyDerivedSymbolData } from './derived-symbol-data' -import { applyGeneratedFreeformStretch } from './derived-symbol-data/propagate' +import { applyGeneratedFreeformStretch, reconcileEffectiveCloneGeometry } from './derived-symbol-data/propagate' import { populateInstances } from './populate' import { preComputeRoots } from './resolve' import { applySymbolOverrides } from './symbol/overrides' @@ -361,6 +361,14 @@ export function populateAndApplyOverrides( propagateResolvedFills(graph, new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes])) propagateResolvedTextClones(graph, ctx.activeNodeIds) applyConstraintScaling(ctx) + const scaledInstances = new Set() + for (const node of overrideCandidates(graph, ctx.activeNodeIds)) { + if (node.type !== 'INSTANCE' || !node.componentId) continue + const component = graph.getNode(node.componentId) + if (component && (node.width !== component.width || node.height !== component.height)) { + scaledInstances.add(node.id) + } + } applyComponentProperties(ctx) // Final component-property swaps can replace descendants targeted by earlier @@ -374,6 +382,9 @@ export function populateAndApplyOverrides( ctx.protectedFields, ctx.preComputedClones ) + // Final swaps recreate descendants from component defaults. Reconcile only + // geometry that already had an authoritative effective size or cross-axis position. + reconcileEffectiveCloneGeometry(ctx, scaledInstances) applyResolvedNumericBindings(graph, ctx.activeNodeIds) applyGeneratedFreeformStretch(ctx) } diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index d00ed6616..cf3284fee 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -260,6 +260,89 @@ describe('@open-pencil/fig instance interpretation', () => { expect(globalScans).toBe(2) }) + test('restores effective image and thin-clone geometry after final swaps', () => { + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + const avatarComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 }) + graph.createNode('ROUNDED_RECTANGLE', avatarComponent.id, { + width: 100, + height: 100, + horizontalConstraint: 'SCALE', + verticalConstraint: 'SCALE', + fills: [{ type: 'IMAGE', imageHash: 'avatar', opacity: 1, visible: true, blendMode: 'NORMAL' }] + }) + const avatar = graph.createNode('INSTANCE', pageId, { + componentId: avatarComponent.id, + width: 14, + height: 14 + }) + + const dividerComponent = graph.createNode('COMPONENT', pageId, { + width: 112, + height: 28, + layoutMode: 'HORIZONTAL', + counterAxisAlign: 'CENTER' + }) + const dividerSource = graph.createNode('RECTANGLE', dividerComponent.id, { + x: 0, + y: 13.5, + width: 112, + height: 1, + figmaDerivedLayout: { x: 0, y: 13.5, width: 112, height: 1 } + }) + const dividerInstance = graph.createNode('INSTANCE', pageId, { + componentId: dividerComponent.id, + width: 112, + height: 28, + layoutMode: 'HORIZONTAL', + counterAxisAlign: 'CENTER' + }) + + populateAndApplyOverrides(graph, new Map(), new Map()) + + const avatarLeaf = graph.getChildren(avatar.id)[0] + expect(avatarLeaf).toMatchObject({ width: 14, height: 14 }) + const divider = graph.getChildren(dividerInstance.id)[0] + expect(divider.componentId).toBe(dividerSource.id) + expect(divider.figmaDerivedLayout).toMatchObject({ x: 0, y: 13.5 }) + }) + + test('leaves unrelated scaled vector and multi-child instance geometry unchanged', () => { + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + const vectorComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 }) + graph.createNode('VECTOR', vectorComponent.id, { + width: 20, + height: 10, + horizontalConstraint: 'SCALE', + verticalConstraint: 'SCALE' + }) + const vectorInstance = graph.createNode('INSTANCE', pageId, { + componentId: vectorComponent.id, + width: 50, + height: 50 + }) + const multiComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 }) + graph.createNode('ROUNDED_RECTANGLE', multiComponent.id, { + width: 100, + height: 100, + horizontalConstraint: 'SCALE', + verticalConstraint: 'SCALE', + fills: [{ type: 'IMAGE', imageHash: 'avatar', opacity: 1, visible: true, blendMode: 'NORMAL' }] + }) + graph.createNode('RECTANGLE', multiComponent.id, { width: 10, height: 10 }) + const multiInstance = graph.createNode('INSTANCE', pageId, { + componentId: multiComponent.id, + width: 50, + height: 50 + }) + + populateAndApplyOverrides(graph, new Map(), new Map()) + + expect(graph.getChildren(vectorInstance.id)[0]).toMatchObject({ width: 10, height: 5 }) + expect(graph.getChildren(multiInstance.id)[0]).toMatchObject({ width: 50, height: 50 }) + }) + test('resolves text clone chains to their source values', () => { const graph = new SceneGraph() const pageId = graph.getPages()[0].id diff --git a/tests/engine/io/fig/import/instance-regressions.test.ts b/tests/engine/io/fig/import/instance-regressions.test.ts index 4a14f3ff0..71f3043d2 100644 --- a/tests/engine/io/fig/import/instance-regressions.test.ts +++ b/tests/engine/io/fig/import/instance-regressions.test.ts @@ -86,9 +86,27 @@ describe('derived instance layout regressions', () => { expect(closeIcon?.visible).toBe(true) expect(closeGlyph?.visible).toBe(true) expect(avatarShape?.fills.some((fill) => fill.type === 'IMAGE' && fill.visible)).toBe(true) + expect(avatarShape?.width).toBeCloseTo(avatar?.width ?? 0, 3) + expect(avatarShape?.height).toBeCloseTo(avatar?.height ?? 0, 3) } }) + test('keeps generated stepper dividers at their effective derived positions', () => { + const dividers = layoutNodes.filter( + (node) => + node.name === 'Right Divider' && + node.width > 100 && + node.componentId && + node.source.format === null + ) + expect(dividers).toHaveLength(3) + const generatedDividers = dividers.filter( + (node) => layoutGraph.getNode(node.componentId)?.figmaDerivedLayout?.y === 13.5 + ) + expect(generatedDividers).toHaveLength(3) + for (const divider of generatedDividers) expect(divider.y).toBeCloseTo(13.5, 3) + }) + test('does not collapse unrelated datepicker instances to the page origin', () => { const datepicker = previewChild(layoutGraph, layoutNodes, '_datepicker') expect(datepicker?.x).toBeCloseTo(765.2428, 3)