diff --git a/packages/fig/src/instance-overrides/constraints.ts b/packages/fig/src/instance-overrides/constraints.ts index 43703baaa..130eeafee 100644 --- a/packages/fig/src/instance-overrides/constraints.ts +++ b/packages/fig/src/instance-overrides/constraints.ts @@ -30,7 +30,17 @@ export function applyConstraintScaling(ctx: OverrideContext): void { const figmaId = ctx.nodeIdToGuid.get(node.id) 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) @@ -109,6 +119,25 @@ function scaledStrokes( })) } +function scaledGeometryUpdates( + source: SceneNode, + shapeScaleX: number, + shapeScaleY: number, + hasDerivedGeometry: boolean +): Partial { + const updates: Partial = {} + 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( graph: SceneGraph, instance: SceneNode, @@ -116,6 +145,7 @@ function scaleChildren( sx: number, sy: number, scaled: Set, + geometryOverrideNodes: Set, useCurrentChildAsSource = false, strokeScale?: number ): void { @@ -141,15 +171,10 @@ function scaleChildren( } const shapeScaleX = hScale ? sx : 1 const shapeScaleY = vScale ? sy : 1 - if (source.fillGeometry.length > 0) { - updates.fillGeometry = scaleGeometryBlobs(source.fillGeometry, shapeScaleX, shapeScaleY) - } - if (source.strokeGeometry.length > 0) { - updates.strokeGeometry = scaleGeometryBlobs(source.strokeGeometry, shapeScaleX, shapeScaleY) - } - if (source.vectorNetwork) { - updates.vectorNetwork = scaleVectorNetwork(source.vectorNetwork, shapeScaleX, shapeScaleY) - } + Object.assign( + updates, + scaledGeometryUpdates(source, shapeScaleX, shapeScaleY, geometryOverrideNodes.has(child.id)) + ) updates.strokes = scaledStrokes(source, child, shapeScaleX, shapeScaleY, strokeScale) graph.updateNode(child.id, updates) scaled.add(child.id) @@ -162,6 +187,7 @@ function scaleChildren( hScale ? sx : 1, vScale ? sy : 1, scaled, + geometryOverrideNodes, useCurrentChildAsSource, strokeScale ) diff --git a/packages/fig/src/instance-overrides/derived-symbol-data/index.ts b/packages/fig/src/instance-overrides/derived-symbol-data/index.ts index ec31e0b70..85db23af7 100644 --- a/packages/fig/src/instance-overrides/derived-symbol-data/index.ts +++ b/packages/fig/src/instance-overrides/derived-symbol-data/index.ts @@ -17,6 +17,12 @@ function applyDsdOverride( const targetId = resolveOverrideTarget(ctx, nodeId, guids) 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) if (!target) return @@ -35,7 +41,6 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set; sizeS const modified = new Set() const sizeSet = new Set() const visibleSiblingCount = new Map() - for (const [ncId, nc] of ctx.changeMap) { if (nc.type !== 'INSTANCE') continue const derived = nc.derivedSymbolData diff --git a/packages/fig/src/instance-overrides/derived-symbol-data/layout.ts b/packages/fig/src/instance-overrides/derived-symbol-data/layout.ts index fd8339451..c664fab05 100644 --- a/packages/fig/src/instance-overrides/derived-symbol-data/layout.ts +++ b/packages/fig/src/instance-overrides/derived-symbol-data/layout.ts @@ -32,15 +32,14 @@ function resolveSizeOnlyPosition( ) return null const source = ctx.graph.getNode(node.componentId) - if (!source) return null - const sourceParent = source.parentId ? ctx.graph.getNode(source.parentId) : null - if (!sourceParent) return { x: source.x, y: source.y } - const withinParent = + const targetParent = ctx.graph.getNode(node.parentId) + if (!source || !targetParent) return null + const fitsTargetParent = source.x >= 0 && source.y >= 0 && - source.x + source.width <= sourceParent.width + 0.01 && - source.y + source.height <= sourceParent.height + 0.01 - return withinParent ? { x: source.x, y: source.y } : { x: 0, y: 0 } + source.x + source.width <= targetParent.width + 0.01 && + source.y + source.height <= targetParent.height + 0.01 + return fitsTargetParent ? { x: source.x, y: source.y } : null } function buildDsdTextUpdates( @@ -66,7 +65,7 @@ function buildDsdTextUpdates( export function buildDsdLayoutUpdates( ctx: OverrideContext, - visibleSiblingCount: Map, + _visibleSiblingCount: Map, d: DerivedSymbolOverride, target: SceneNode ): { updates: Partial; hasSize: boolean } { @@ -85,7 +84,7 @@ export function buildDsdLayoutUpdates( figmaDerivedLayout.x = d.transform.m02 figmaDerivedLayout.y = d.transform.m12 } else if (d.size) { - const position = resolveSizeOnlyPosition(ctx, visibleSiblingCount, target) + const position = resolveSizeOnlyPosition(ctx, _visibleSiblingCount, target) if (position) { updates.x = position.x updates.y = position.y diff --git a/packages/fig/src/instance-overrides/symbol/overrides.ts b/packages/fig/src/instance-overrides/symbol/overrides.ts index ac470c163..6f2e5de88 100644 --- a/packages/fig/src/instance-overrides/symbol/overrides.ts +++ b/packages/fig/src/instance-overrides/symbol/overrides.ts @@ -7,6 +7,20 @@ function isActiveInstance(ctx: OverrideContext, nodeId: string | undefined): nod return nodeId !== undefined && (!ctx.activeNodeIds || ctx.activeNodeIds.has(nodeId)) } +function preserveInstanceRootBounds( + hasRootSize: boolean, + instanceId: string, + targetId: string, + patch: ReturnType +): 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. * @@ -37,6 +51,7 @@ export function applySymbolOverrides(ctx: OverrideContext, propertiesOnly = fals const patch = patchFromSymbolOverride(ctx, targetId, ov) if (!patch) continue + preserveInstanceRootBounds(nc.size !== undefined, nodeId, targetId, patch) if (propertiesOnly) patch.swapComponentId = undefined if (!patch.swapComponentId && !patch.props) continue overriddenNodes.add(targetId) diff --git a/packages/fig/src/instance-overrides/types.ts b/packages/fig/src/instance-overrides/types.ts index 7f4b39c9c..c1b6668b7 100644 --- a/packages/fig/src/instance-overrides/types.ts +++ b/packages/fig/src/instance-overrides/types.ts @@ -75,6 +75,7 @@ export interface InstanceNodeChange { guid?: GUID parentIndex?: { guid?: GUID } transform?: Matrix + size?: Vector overrideKey?: GUID symbolData?: SymbolData componentPropRefs?: ComponentPropRef[] diff --git a/tests/engine/io/fig/import/derived-symbol-data.test.ts b/tests/engine/io/fig/import/derived-symbol-data.test.ts index eff86f7ac..3df2f7b29 100644 --- a/tests/engine/io/fig/import/derived-symbol-data.test.ts +++ b/tests/engine/io/fig/import/derived-symbol-data.test.ts @@ -35,6 +35,23 @@ describe('fig import derived symbol data', () => { 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', () => { const graph = new SceneGraph() const target = graph.createNode('TEXT', pageId(graph), { text: 'Menu Item' }) diff --git a/tests/engine/io/fig/import/edge-cases.test.ts b/tests/engine/io/fig/import/edge-cases.test.ts index 7e6e8d82f..98c2a831f 100644 --- a/tests/engine/io/fig/import/edge-cases.test.ts +++ b/tests/engine/io/fig/import/edge-cases.test.ts @@ -445,12 +445,14 @@ describe('edge cases', () => { type: 'INSTANCE', name: 'Search', phase: 'CREATED', + size: { x: 50, y: 60 }, symbolData: { symbolID: { sessionID: 1, localID: 1 }, symbolOverrides: [ { 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) .find((node) => node.type === 'INSTANCE') 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 () => { diff --git a/tests/engine/io/fig/import/scaled-instance-strokes.test.ts b/tests/engine/io/fig/import/scaled-instance-strokes.test.ts index fec3d713b..9694501dc 100644 --- a/tests/engine/io/fig/import/scaled-instance-strokes.test.ts +++ b/tests/engine/io/fig/import/scaled-instance-strokes.test.ts @@ -5,6 +5,15 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec' 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', () => { test('preserves vector stroke weight while scaling icon geometry', () => { 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 }) }) + 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), + 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), + 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) + ], + [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', () => { const componentGuid = { sessionID: 1, localID: 30 } const vectorGuid = { sessionID: 1, localID: 31 }