fix(fig): preserve derived instance geometry

- Keep placed instance bounds authoritative over root symbol overrides

- Reject size-only positions outside the target parent

- Avoid applying constraint scale twice to derived vector paths
This commit is contained in:
Danila Poyarkov 2026-07-21 02:29:59 +03:00
parent 0ef64fea3a
commit f6e4dd4c83
8 changed files with 148 additions and 21 deletions

View file

@ -30,7 +30,17 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
const figmaId = ctx.nodeIdToGuid.get(node.id) const figmaId = ctx.nodeIdToGuid.get(node.id)
const strokeScale = figmaId ? ctx.changeMap.get(figmaId)?.strokeWeight : undefined const strokeScale = figmaId ? ctx.changeMap.get(figmaId)?.strokeWeight : undefined
scaleChildren(graph, node, comp, sx, sy, scaled, basis !== comp, strokeScale) scaleChildren(
graph,
node,
comp,
sx,
sy,
scaled,
ctx.geometryOverrideNodes,
basis !== comp,
strokeScale
)
} }
if (scaled.size > 0) propagateScaling(ctx, scaled) if (scaled.size > 0) propagateScaling(ctx, scaled)
@ -109,6 +119,25 @@ function scaledStrokes(
})) }))
} }
function scaledGeometryUpdates(
source: SceneNode,
shapeScaleX: number,
shapeScaleY: number,
hasDerivedGeometry: boolean
): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
if (!hasDerivedGeometry && source.fillGeometry.length > 0) {
updates.fillGeometry = scaleGeometryBlobs(source.fillGeometry, shapeScaleX, shapeScaleY)
}
if (!hasDerivedGeometry && source.strokeGeometry.length > 0) {
updates.strokeGeometry = scaleGeometryBlobs(source.strokeGeometry, shapeScaleX, shapeScaleY)
}
if (source.vectorNetwork) {
updates.vectorNetwork = scaleVectorNetwork(source.vectorNetwork, shapeScaleX, shapeScaleY)
}
return updates
}
function scaleChildren( function scaleChildren(
graph: SceneGraph, graph: SceneGraph,
instance: SceneNode, instance: SceneNode,
@ -116,6 +145,7 @@ function scaleChildren(
sx: number, sx: number,
sy: number, sy: number,
scaled: Set<string>, scaled: Set<string>,
geometryOverrideNodes: Set<string>,
useCurrentChildAsSource = false, useCurrentChildAsSource = false,
strokeScale?: number strokeScale?: number
): void { ): void {
@ -141,15 +171,10 @@ function scaleChildren(
} }
const shapeScaleX = hScale ? sx : 1 const shapeScaleX = hScale ? sx : 1
const shapeScaleY = vScale ? sy : 1 const shapeScaleY = vScale ? sy : 1
if (source.fillGeometry.length > 0) { Object.assign(
updates.fillGeometry = scaleGeometryBlobs(source.fillGeometry, shapeScaleX, shapeScaleY) updates,
} scaledGeometryUpdates(source, shapeScaleX, shapeScaleY, geometryOverrideNodes.has(child.id))
if (source.strokeGeometry.length > 0) { )
updates.strokeGeometry = scaleGeometryBlobs(source.strokeGeometry, shapeScaleX, shapeScaleY)
}
if (source.vectorNetwork) {
updates.vectorNetwork = scaleVectorNetwork(source.vectorNetwork, shapeScaleX, shapeScaleY)
}
updates.strokes = scaledStrokes(source, child, shapeScaleX, shapeScaleY, strokeScale) updates.strokes = scaledStrokes(source, child, shapeScaleX, shapeScaleY, strokeScale)
graph.updateNode(child.id, updates) graph.updateNode(child.id, updates)
scaled.add(child.id) scaled.add(child.id)
@ -162,6 +187,7 @@ function scaleChildren(
hScale ? sx : 1, hScale ? sx : 1,
vScale ? sy : 1, vScale ? sy : 1,
scaled, scaled,
geometryOverrideNodes,
useCurrentChildAsSource, useCurrentChildAsSource,
strokeScale strokeScale
) )

View file

@ -17,6 +17,12 @@ function applyDsdOverride(
const targetId = resolveOverrideTarget(ctx, nodeId, guids) const targetId = resolveOverrideTarget(ctx, nodeId, guids)
if (!targetId) return if (!targetId) return
if (targetId === nodeId) {
// The instance NodeChange already carries its authoritative root bounds.
// Keep descendant propagation from replacing them with component bounds.
sizeSet.add(nodeId)
return
}
const target = ctx.graph.getNode(targetId) const target = ctx.graph.getNode(targetId)
if (!target) return if (!target) return
@ -35,7 +41,6 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeS
const modified = new Set<string>() const modified = new Set<string>()
const sizeSet = new Set<string>() const sizeSet = new Set<string>()
const visibleSiblingCount = new Map<string, number>() const visibleSiblingCount = new Map<string, number>()
for (const [ncId, nc] of ctx.changeMap) { for (const [ncId, nc] of ctx.changeMap) {
if (nc.type !== 'INSTANCE') continue if (nc.type !== 'INSTANCE') continue
const derived = nc.derivedSymbolData const derived = nc.derivedSymbolData

View file

@ -32,15 +32,14 @@ function resolveSizeOnlyPosition(
) )
return null return null
const source = ctx.graph.getNode(node.componentId) const source = ctx.graph.getNode(node.componentId)
if (!source) return null const targetParent = ctx.graph.getNode(node.parentId)
const sourceParent = source.parentId ? ctx.graph.getNode(source.parentId) : null if (!source || !targetParent) return null
if (!sourceParent) return { x: source.x, y: source.y } const fitsTargetParent =
const withinParent =
source.x >= 0 && source.x >= 0 &&
source.y >= 0 && source.y >= 0 &&
source.x + source.width <= sourceParent.width + 0.01 && source.x + source.width <= targetParent.width + 0.01 &&
source.y + source.height <= sourceParent.height + 0.01 source.y + source.height <= targetParent.height + 0.01
return withinParent ? { x: source.x, y: source.y } : { x: 0, y: 0 } return fitsTargetParent ? { x: source.x, y: source.y } : null
} }
function buildDsdTextUpdates( function buildDsdTextUpdates(
@ -66,7 +65,7 @@ function buildDsdTextUpdates(
export function buildDsdLayoutUpdates( export function buildDsdLayoutUpdates(
ctx: OverrideContext, ctx: OverrideContext,
visibleSiblingCount: Map<string, number>, _visibleSiblingCount: Map<string, number>,
d: DerivedSymbolOverride, d: DerivedSymbolOverride,
target: SceneNode target: SceneNode
): { updates: Partial<SceneNode>; hasSize: boolean } { ): { updates: Partial<SceneNode>; hasSize: boolean } {
@ -85,7 +84,7 @@ export function buildDsdLayoutUpdates(
figmaDerivedLayout.x = d.transform.m02 figmaDerivedLayout.x = d.transform.m02
figmaDerivedLayout.y = d.transform.m12 figmaDerivedLayout.y = d.transform.m12
} else if (d.size) { } else if (d.size) {
const position = resolveSizeOnlyPosition(ctx, visibleSiblingCount, target) const position = resolveSizeOnlyPosition(ctx, _visibleSiblingCount, target)
if (position) { if (position) {
updates.x = position.x updates.x = position.x
updates.y = position.y updates.y = position.y

View file

@ -7,6 +7,20 @@ function isActiveInstance(ctx: OverrideContext, nodeId: string | undefined): nod
return nodeId !== undefined && (!ctx.activeNodeIds || ctx.activeNodeIds.has(nodeId)) return nodeId !== undefined && (!ctx.activeNodeIds || ctx.activeNodeIds.has(nodeId))
} }
function preserveInstanceRootBounds(
hasRootSize: boolean,
instanceId: string,
targetId: string,
patch: ReturnType<typeof patchFromSymbolOverride>
): void {
if (!hasRootSize || targetId !== instanceId || !patch?.props) return
// Root bounds belong to the instance NodeChange. Figma may repeat the
// source component size in a root symbol override, but that must not resize
// the placed instance.
delete patch.props.width
delete patch.props.height
}
/** /**
* Apply symbolOverrides from kiwi data. * Apply symbolOverrides from kiwi data.
* *
@ -37,6 +51,7 @@ export function applySymbolOverrides(ctx: OverrideContext, propertiesOnly = fals
const patch = patchFromSymbolOverride(ctx, targetId, ov) const patch = patchFromSymbolOverride(ctx, targetId, ov)
if (!patch) continue if (!patch) continue
preserveInstanceRootBounds(nc.size !== undefined, nodeId, targetId, patch)
if (propertiesOnly) patch.swapComponentId = undefined if (propertiesOnly) patch.swapComponentId = undefined
if (!patch.swapComponentId && !patch.props) continue if (!patch.swapComponentId && !patch.props) continue
overriddenNodes.add(targetId) overriddenNodes.add(targetId)

View file

@ -75,6 +75,7 @@ export interface InstanceNodeChange {
guid?: GUID guid?: GUID
parentIndex?: { guid?: GUID } parentIndex?: { guid?: GUID }
transform?: Matrix transform?: Matrix
size?: Vector
overrideKey?: GUID overrideKey?: GUID
symbolData?: SymbolData symbolData?: SymbolData
componentPropRefs?: ComponentPropRef[] componentPropRefs?: ComponentPropRef[]

View file

@ -35,6 +35,23 @@ describe('fig import derived symbol data', () => {
expect(clone.figmaDerivedLayout).toEqual(source.figmaDerivedLayout) expect(clone.figmaDerivedLayout).toEqual(source.figmaDerivedLayout)
}) })
test('keeps the existing position when derived data only changes size', () => {
const graph = new SceneGraph()
const component = graph.createNode('COMPONENT', pageId(graph), { x: 100, y: 100 })
const target = graph.createNode('INSTANCE', pageId(graph), {
x: 8,
y: 8,
componentId: component.id
})
const ctx = { graph, blobs: [] } as OverrideContext
const { updates } = buildDsdLayoutUpdates(ctx, new Map(), { size: { x: 184, y: 36 } }, target)
expect(updates).toMatchObject({ width: 184, height: 36 })
expect(updates.x).toBeUndefined()
expect(updates.y).toBeUndefined()
})
test('routes derived text glyphs through layout patch updates', () => { test('routes derived text glyphs through layout patch updates', () => {
const graph = new SceneGraph() const graph = new SceneGraph()
const target = graph.createNode('TEXT', pageId(graph), { text: 'Menu Item' }) const target = graph.createNode('TEXT', pageId(graph), { text: 'Menu Item' })

View file

@ -445,12 +445,14 @@ describe('edge cases', () => {
type: 'INSTANCE', type: 'INSTANCE',
name: 'Search', name: 'Search',
phase: 'CREATED', phase: 'CREATED',
size: { x: 50, y: 60 },
symbolData: { symbolData: {
symbolID: { sessionID: 1, localID: 1 }, symbolID: { sessionID: 1, localID: 1 },
symbolOverrides: [ symbolOverrides: [
{ {
guidPath: { guids: [{ sessionID: 90, localID: 1 }] }, guidPath: { guids: [{ sessionID: 90, localID: 1 }] },
overriddenSymbolID: { sessionID: 1, localID: 3 } overriddenSymbolID: { sessionID: 1, localID: 3 },
size: { x: 200, y: 200 }
} }
] ]
} }
@ -461,6 +463,8 @@ describe('edge cases', () => {
.getChildren(graph.getPages()[0].id) .getChildren(graph.getPages()[0].id)
.find((node) => node.type === 'INSTANCE') .find((node) => node.type === 'INSTANCE')
expect(instance?.name).toBe('Avatar') expect(instance?.name).toBe('Avatar')
expect(instance?.width).toBe(50)
expect(instance?.height).toBe(60)
}) })
test('DSD propagates through intermediate clones that are also DSD-targeted', async () => { test('DSD propagates through intermediate clones that are also DSD-targeted', async () => {

View file

@ -5,6 +5,15 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { canvas, doc, node } from './legacy/helpers' import { canvas, doc, node } from './legacy/helpers'
function pointGeometry(x: number, y: number): Uint8Array {
const bytes = new Uint8Array(9)
bytes[0] = 1
const view = new DataView(bytes.buffer)
view.setFloat32(1, x, true)
view.setFloat32(5, y, true)
return bytes
}
describe('fig import scaled instance strokes', () => { describe('fig import scaled instance strokes', () => {
test('preserves vector stroke weight while scaling icon geometry', () => { test('preserves vector stroke weight while scaling icon geometry', () => {
const componentGuid = { sessionID: 1, localID: 10 } const componentGuid = { sessionID: 1, localID: 10 }
@ -56,6 +65,57 @@ describe('fig import scaled instance strokes', () => {
expect(vector?.strokes[0]?.color).toEqual({ r: 0.2, g: 0.25, b: 0.33, a: 1 }) expect(vector?.strokes[0]?.color).toEqual({ r: 0.2, g: 0.25, b: 0.33, a: 1 })
}) })
test('does not scale explicit derived vector geometry twice', () => {
const componentGuid = { sessionID: 1, localID: 21 }
const vectorGuid = { sessionID: 1, localID: 22 }
const graph = importNodeChanges(
[
doc(),
canvas(),
node('SYMBOL', 21, 1, {
guid: componentGuid,
size: { x: 24, y: 24 }
} as Partial<NodeChange>),
node('VECTOR', 22, 1, {
guid: vectorGuid,
parentIndex: { guid: componentGuid, position: '!' },
size: { x: 12, y: 12 },
horizontalConstraint: 'SCALE',
verticalConstraint: 'SCALE',
fillGeometry: [{ commandsBlob: 0, windingRule: 'NONZERO' }]
} as Partial<NodeChange>),
node('INSTANCE', 23, 1, {
size: { x: 16, y: 16 },
symbolData: { symbolID: componentGuid },
derivedSymbolData: [
{
guidPath: { guids: [vectorGuid] },
size: { x: 8, y: 8 },
fillGeometry: [{ commandsBlob: 1, windingRule: 'NONZERO' }]
}
]
} as Partial<NodeChange>)
],
[pointGeometry(12, 12), pointGeometry(6, 6)],
undefined,
{ populate: 'all' }
)
const instance = Array.from(graph.getAllNodes()).find(
(sceneNode) => sceneNode.name === 'INSTANCE_23'
)
const vector = instance?.childIds.map((id) => graph.getNode(id)).find(Boolean)
const geometry = vector?.fillGeometry[0]?.commandsBlob
expect(geometry).toBeDefined()
const view = new DataView(
geometry?.buffer ?? new ArrayBuffer(0),
geometry?.byteOffset ?? 0,
geometry?.byteLength ?? 0
)
expect(view.getFloat32(1, true)).toBe(6)
expect(view.getFloat32(5, true)).toBe(6)
})
test('applies explicit instance stroke scale to scaled vectors', () => { test('applies explicit instance stroke scale to scaled vectors', () => {
const componentGuid = { sessionID: 1, localID: 30 } const componentGuid = { sessionID: 1, localID: 30 }
const vectorGuid = { sessionID: 1, localID: 31 } const vectorGuid = { sessionID: 1, localID: 31 }