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:
parent
0ef64fea3a
commit
f6e4dd4c83
|
|
@ -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<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(
|
||||
graph: SceneGraph,
|
||||
instance: SceneNode,
|
||||
|
|
@ -116,6 +145,7 @@ function scaleChildren(
|
|||
sx: number,
|
||||
sy: number,
|
||||
scaled: Set<string>,
|
||||
geometryOverrideNodes: Set<string>,
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string>; sizeS
|
|||
const modified = new Set<string>()
|
||||
const sizeSet = new Set<string>()
|
||||
const visibleSiblingCount = new Map<string, number>()
|
||||
|
||||
for (const [ncId, nc] of ctx.changeMap) {
|
||||
if (nc.type !== 'INSTANCE') continue
|
||||
const derived = nc.derivedSymbolData
|
||||
|
|
|
|||
|
|
@ -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<string, number>,
|
||||
_visibleSiblingCount: Map<string, number>,
|
||||
d: DerivedSymbolOverride,
|
||||
target: SceneNode
|
||||
): { updates: Partial<SceneNode>; 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
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
*
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export interface InstanceNodeChange {
|
|||
guid?: GUID
|
||||
parentIndex?: { guid?: GUID }
|
||||
transform?: Matrix
|
||||
size?: Vector
|
||||
overrideKey?: GUID
|
||||
symbolData?: SymbolData
|
||||
componentPropRefs?: ComponentPropRef[]
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<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', () => {
|
||||
const componentGuid = { sessionID: 1, localID: 30 }
|
||||
const vectorGuid = { sessionID: 1, localID: 31 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue