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
This commit is contained in:
Danila Poyarkov 2026-08-07 08:38:18 +03:00
parent eb55cb1025
commit f9701ef855
5 changed files with 196 additions and 1 deletions

View file

@ -50,6 +50,7 @@
### Fixed
- Preserve Figmas 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)

View file

@ -48,6 +48,88 @@ function buildCloneUpdates(
return updates
}
export function reconcileEffectiveCloneGeometry(
ctx: OverrideContext,
scaledInstanceIds: Set<string>
): void {
restoreScaledInstanceLeafBounds(ctx, scaledInstanceIds)
restoreThinCloneCrossPositions(ctx)
}
function restoreScaledInstanceLeafBounds(
ctx: OverrideContext,
scaledInstanceIds: Set<string>
): 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<SceneNode['figmaDerivedLayout']> | 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 (

View file

@ -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<string>()
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)
}

View file

@ -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

View file

@ -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)