fix(kiwi): preserve derived instance layout

This commit is contained in:
Danila Poyarkov 2026-04-25 16:33:37 +03:00
parent 63d3f2e0cb
commit cfefc1fb4e
13 changed files with 876 additions and 91 deletions

View file

@ -33,7 +33,7 @@ import {
DEFAULT_FONT_FAMILY,
IS_BROWSER
} from '../constants'
import { computeVisualBounds } from '../geometry'
import { computeDescendantVisualBounds } from '../geometry'
import { RenderProfiler } from '../profiler'
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
import {
@ -902,15 +902,21 @@ export class SkiaRenderer {
this.worldViewport = { x: -1e6, y: -1e6, w: 2e6, h: 2e6 }
const recorder = new this.ck.PictureRecorder()
const pageNode = graph.getNode(this.pageId ?? graph.rootId)
const sceneNodes = pageNode
? pageNode.childIds
.map((childId) => graph.getNode(childId))
.filter((node): node is SceneNode => node != null)
: []
const sceneBounds =
sceneNodes.length > 0
? computeVisualBounds(sceneNodes, (id) => graph.getAbsolutePosition(id))
: { x: 0, y: 0, width: 1, height: 1 }
const sceneContentBounds = pageNode
? computeDescendantVisualBounds(
pageNode.childIds,
(id) => graph.getNode(id),
(id) => graph.getAbsolutePosition(id)
)
: null
const sceneBounds = sceneContentBounds
? {
x: sceneContentBounds.minX,
y: sceneContentBounds.minY,
width: sceneContentBounds.maxX - sceneContentBounds.minX,
height: sceneContentBounds.maxY - sceneContentBounds.minY
}
: { x: 0, y: 0, width: 1, height: 1 }
const padding = 1024
const bounds = this.ck.LTRBRect(
sceneBounds.x - padding,

View file

@ -312,6 +312,39 @@ function drawVectorStrokeGeometry(
for (const p of sg) canvas.drawPath(p, r.fillPaint)
}
function vectorStrokePaths(r: SkiaRenderer, node: SceneNode): Path[] | null {
if (!node.vectorNetwork) return null
const paths: Path[] = []
for (const segment of node.vectorNetwork.segments) {
const start = node.vectorNetwork.vertices[segment.start]
const end = node.vectorNetwork.vertices[segment.end]
const path = new r.ck.Path()
path.moveTo(start.x, start.y)
const isStraight =
Math.abs(segment.tangentStart.x) < 0.001 &&
Math.abs(segment.tangentStart.y) < 0.001 &&
Math.abs(segment.tangentEnd.x) < 0.001 &&
Math.abs(segment.tangentEnd.y) < 0.001
if (isStraight) {
path.lineTo(end.x, end.y)
} else {
path.cubicTo(
start.x + segment.tangentStart.x,
start.y + segment.tangentStart.y,
end.x + segment.tangentEnd.x,
end.y + segment.tangentEnd.y,
end.x,
end.y
)
}
paths.push(path)
}
return paths.length > 0 ? paths : null
}
function drawVectorPathStrokes(
r: SkiaRenderer,
canvas: Canvas,
@ -369,6 +402,48 @@ function drawRegularStroke(
}
}
function drawNodeStroke(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
stroke: SceneNode['strokes'][0],
sc: Color,
sg: Path[] | null,
vectorPaths: Path[] | null,
vectorStroke: Path[] | null
): void {
if (vectorStroke && stroke.align === 'CENTER' && node.cornerRadius === 0) {
drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc)
return
}
if (!sg) {
if (vectorPaths) drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc)
else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
return
}
if (stroke.align !== 'INSIDE') {
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
return
}
const clipPaths = node.type === 'VECTOR' ? r.getFillGeometry(node) : null
if (node.type === 'VECTOR' && !clipPaths) {
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
return
}
canvas.save()
if (clipPaths) {
for (const path of clipPaths) canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
} else {
r.clipNodeShape(canvas, node, rect, hasRadius)
}
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
canvas.restore()
}
export function renderShapeUncached(
r: SkiaRenderer,
canvas: Canvas,
@ -391,33 +466,17 @@ export function renderShapeUncached(
}
const sg = node.strokeGeometry.length > 0 ? r.getStrokeGeometry(node) : null
const vectorPaths = !sg && node.type === 'VECTOR' ? r.getVectorPaths(node) : null
const vectorPaths = node.type === 'VECTOR' ? r.getVectorPaths(node) : null
const vectorStroke = node.type === 'VECTOR' ? vectorStrokePaths(r, node) : null
for (let si = 0; si < node.strokes.length; si++) {
const stroke = node.strokes[si]
if (!stroke.visible) continue
const sc = r.resolveStrokeColor(stroke, si, node, graph)
if (sg) {
if (stroke.align === 'INSIDE') {
canvas.save()
const clipPaths = node.type === 'VECTOR' ? r.getFillGeometry(node) : null
if (clipPaths) {
for (const path of clipPaths) canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
} else {
r.clipNodeShape(canvas, node, rect, hasRadius)
}
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
canvas.restore()
} else {
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
}
continue
}
if (vectorPaths) {
drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc)
continue
}
drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
drawNodeStroke(r, canvas, node, rect, hasRadius, stroke, sc, sg, vectorPaths, vectorStroke)
}
if (vectorStroke) {
for (const path of vectorStroke) path.delete()
}
r.renderEffects(canvas, node, rect, hasRadius, 'front')
@ -575,7 +634,10 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
if (!text) return
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
const shouldClipText = node.textAutoResize === 'NONE' || node.textAutoResize === 'TRUNCATE'
if (shouldClipText) {
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
}
if (node.textPicture) {
const pic = r.ck.MakePicture(node.textPicture)
@ -588,7 +650,8 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
}
if (r.fontsLoaded && r.fontProvider) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
canvas.drawParagraph(paragraph, 0, -1)
const paragraphY = node.fontSize < 13 ? 0 : -1
canvas.drawParagraph(paragraph, 0, paragraphY)
paragraph.delete()
} else if (r.textFont) {
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)

View file

@ -168,3 +168,189 @@ export function computeVisualBounds(
if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 }
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
export interface VisualBounds {
minX: number
minY: number
maxX: number
maxY: number
}
export interface VisualBoundsNode {
id: string
width: number
height: number
rotation?: number
flipX?: boolean
flipY?: boolean
strokes?: Stroke[]
effects?: Effect[]
fillGeometry?: Array<{ commandsBlob: Uint8Array }>
strokeGeometry?: Array<{ commandsBlob: Uint8Array }>
childIds?: string[]
visible?: boolean
type?: string
clipsContent?: boolean
}
export function unionVisualBounds(
a: VisualBounds | null,
b: VisualBounds | null
): VisualBounds | null {
if (!a) return b
if (!b) return a
return {
minX: Math.min(a.minX, b.minX),
minY: Math.min(a.minY, b.minY),
maxX: Math.max(a.maxX, b.maxX),
maxY: Math.max(a.maxY, b.maxY)
}
}
export function intersectVisualBounds(a: VisualBounds, b: VisualBounds): VisualBounds | null {
const minX = Math.max(a.minX, b.minX)
const minY = Math.max(a.minY, b.minY)
const maxX = Math.min(a.maxX, b.maxX)
const maxY = Math.min(a.maxY, b.maxY)
return minX < maxX && minY < maxY ? { minX, minY, maxX, maxY } : null
}
function geometryCommandCoordCount(command: number): number | null {
if (command === 0) return 0
if (command === 1 || command === 2) return 1
if (command === 4) return 3
return null
}
export function geometryBlobBounds(paths: Array<{ commandsBlob: Uint8Array }>): Rect | null {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const path of paths) {
const blob = path.commandsBlob
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let offset = 0
while (offset < blob.length) {
const command = blob[offset++]
const coords = geometryCommandCoordCount(command)
if (coords == null) break
for (let i = 0; i < coords; i++) {
if (offset + 8 > blob.length) break
const x = dv.getFloat32(offset, true)
const y = dv.getFloat32(offset + 4, true)
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
offset += 8
}
}
}
return minX === Infinity ? null : { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
function transformLocalPoint(node: VisualBoundsNode, point: Vector): Vector {
let x = node.flipX ? node.width - point.x : point.x
let y = node.flipY ? node.height - point.y : point.y
const rotation = node.rotation ?? 0
if (rotation !== 0) {
const rotated = rotatePoint(x, y, node.width / 2, node.height / 2, degToRad(rotation))
x = rotated.x
y = rotated.y
}
return { x, y }
}
function transformedLocalBounds(node: VisualBoundsNode, local: Rect, abs: Vector): VisualBounds {
const points = [
{ x: local.x, y: local.y },
{ x: local.x + local.width, y: local.y },
{ x: local.x + local.width, y: local.y + local.height },
{ x: local.x, y: local.y + local.height }
].map((point) => transformLocalPoint(node, point))
return {
minX: abs.x + Math.min(...points.map((point) => point.x)),
minY: abs.y + Math.min(...points.map((point) => point.y)),
maxX: abs.x + Math.max(...points.map((point) => point.x)),
maxY: abs.y + Math.max(...points.map((point) => point.y))
}
}
export function nodeVisualBounds(
node: VisualBoundsNode,
getAbsolutePosition: (id: string) => Vector
): VisualBounds {
const abs = getAbsolutePosition(node.id)
const base = computeVisualBounds([node], getAbsolutePosition)
let bounds: VisualBounds = {
minX: base.x,
minY: base.y,
maxX: base.x + base.width,
maxY: base.y + base.height
}
const localGeometry = geometryBlobBounds([
...(node.fillGeometry ?? []),
...(node.strokeGeometry ?? [])
])
if (localGeometry) {
bounds = unionVisualBounds(bounds, transformedLocalBounds(node, localGeometry, abs)) ?? bounds
}
return bounds
}
function collectDescendantVisualBounds(
nodeId: string,
getNode: (id: string) => VisualBoundsNode | undefined,
getAbsolutePosition: (id: string) => Vector,
clip: VisualBounds | null = null
): VisualBounds | null {
const node = getNode(nodeId)
if (!node?.visible) return null
const own = nodeVisualBounds(node, getAbsolutePosition)
let bounds = clip ? intersectVisualBounds(own, clip) : own
const isClippableContainer =
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
let childClip = clip
if (isClippableContainer && node.clipsContent) {
const abs = getAbsolutePosition(node.id)
const nodeClip = {
minX: abs.x,
minY: abs.y,
maxX: abs.x + node.width,
maxY: abs.y + node.height
}
childClip = childClip ? intersectVisualBounds(childClip, nodeClip) : nodeClip
}
for (const childId of node.childIds ?? []) {
bounds = unionVisualBounds(
bounds,
collectDescendantVisualBounds(childId, getNode, getAbsolutePosition, childClip)
)
}
return bounds
}
export function computeDescendantVisualBounds(
nodeIds: string[],
getNode: (id: string) => VisualBoundsNode | undefined,
getAbsolutePosition: (id: string) => Vector
): VisualBounds | null {
let bounds: VisualBounds | null = null
for (const nodeId of nodeIds) {
bounds = unionVisualBounds(
bounds,
collectDescendantVisualBounds(nodeId, getNode, getAbsolutePosition)
)
}
return bounds
}

View file

@ -1,4 +1,4 @@
import { computeVisualBounds } from '../../../geometry'
import { computeDescendantVisualBounds } from '../../../geometry'
import { extractExportGraph } from '../../subgraph'
import type { SkiaRenderer } from '../../../canvas'
@ -41,25 +41,12 @@ function nodeNeedsSceneBackdrop(graph: SceneGraph, nodeId: string): boolean {
return node.childIds.some((childId) => nodeNeedsSceneBackdrop(graph, childId))
}
export function computeContentBounds(
graph: SceneGraph,
nodeIds: string[]
): { minX: number; minY: number; maxX: number; maxY: number } | null {
const nodes = nodeIds
.map((id) => graph.getNode(id))
.filter(
(node): node is NonNullable<ReturnType<SceneGraph['getNode']>> => !!node && node.visible
)
if (nodes.length === 0) return null
const bounds = computeVisualBounds(nodes, (id) => graph.getAbsolutePosition(id))
return {
minX: bounds.x,
minY: bounds.y,
maxX: bounds.x + bounds.width,
maxY: bounds.y + bounds.height
}
export function computeContentBounds(graph: SceneGraph, nodeIds: string[]) {
return computeDescendantVisualBounds(
nodeIds,
(id) => graph.getNode(id),
(id) => graph.getAbsolutePosition(id)
)
}
function ckImageFormat(ck: CanvasKit, format: ExportFormat) {

View file

@ -1,6 +1,7 @@
import { copyGeometryPaths } from '../../scene-graph/copy'
import { buildClonesMap } from './sync'
import type { SceneGraph, SceneNode } from '../../scene-graph'
import type { GeometryPath, SceneGraph, SceneNode, VectorNetwork } from '../../scene-graph'
import type { OverrideContext } from './types'
/**
@ -29,7 +30,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
scaleChildren(graph, node, comp, sx, sy, scaled, basis !== comp)
}
if (scaled.size > 0) propagateScaling(graph, scaled)
if (scaled.size > 0) propagateScaling(ctx, scaled)
}
function resolveScaleBasis(
@ -50,6 +51,40 @@ function resolveScaleBasis(
return null
}
function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): GeometryPath[] {
if (sx === 1 && sy === 1) return copyGeometryPaths(geom)
return geom.map((g) => {
const scaled = g.commandsBlob.slice()
const dv = new DataView(scaled.buffer, scaled.byteOffset, scaled.byteLength)
let offset = 0
while (offset < scaled.length) {
const command = scaled[offset++]
let coords = 0
if (command === 1 || command === 2) coords = 1
else if (command === 4) coords = 3
for (let i = 0; i < coords; i++) {
dv.setFloat32(offset, dv.getFloat32(offset, true) * sx, true)
dv.setFloat32(offset + 4, dv.getFloat32(offset + 4, true) * sy, true)
offset += 8
}
}
return { windingRule: g.windingRule, commandsBlob: scaled }
})
}
function scaleVectorNetwork(network: VectorNetwork | null, sx: number, sy: number): VectorNetwork | null {
if (!network) return null
return {
vertices: network.vertices.map((vertex) => ({ ...vertex, x: vertex.x * sx, y: vertex.y * sy })),
segments: network.segments.map((segment) => ({
...segment,
tangentStart: { x: segment.tangentStart.x * sx, y: segment.tangentStart.y * sy },
tangentEnd: { x: segment.tangentEnd.x * sx, y: segment.tangentEnd.y * sy }
})),
regions: structuredClone(network.regions)
}
}
function scaleChildren(
graph: SceneGraph,
instance: SceneNode,
@ -79,6 +114,17 @@ function scaleChildren(
updates.y = source.y * sy
updates.height = source.height * sy
}
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)
}
graph.updateNode(child.id, updates)
scaled.add(child.id)
@ -96,7 +142,8 @@ function scaleChildren(
}
}
function propagateScaling(graph: SceneGraph, scaled: Set<string>): void {
function propagateScaling(ctx: OverrideContext, scaled: Set<string>): void {
const { graph } = ctx
const clonesOf = buildClonesMap(graph)
const queue = [...scaled]
const visited = new Set<string>()
@ -116,6 +163,11 @@ function propagateScaling(graph: SceneGraph, scaled: Set<string>): void {
if (clone.height !== source.height) cu.height = source.height
if (clone.x !== source.x) cu.x = source.x
if (clone.y !== source.y) cu.y = source.y
if (!ctx.geometryOverrideNodes.has(cloneId)) {
if (source.fillGeometry.length > 0) cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry.length > 0) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (source.vectorNetwork) cu.vectorNetwork = structuredClone(source.vectorNetwork)
}
if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu)
queue.push(cloneId)
}

View file

@ -64,6 +64,63 @@ function resolveDsdGeometry(
return result
}
function hasSingleVisibleSibling(ctx: OverrideContext, node: SceneNode): boolean {
if (!node.parentId) return false
return ctx.graph.getChildren(node.parentId).filter((child) => child.visible).length === 1
}
function resolveSizeOnlyPosition(ctx: OverrideContext, node: SceneNode): Pick<SceneNode, 'x' | 'y'> | null {
if (!hasSingleVisibleSibling(ctx, node) || !node.componentId) 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 =
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 }
}
function buildDsdLayoutUpdates(
ctx: OverrideContext,
d: DerivedSymbolOverride,
target: SceneNode
): { updates: Partial<SceneNode>; hasSize: boolean } {
const updates: Partial<SceneNode> = {}
const figmaDerivedLayout: NonNullable<SceneNode['figmaDerivedLayout']> = {}
if (d.size) {
updates.width = d.size.x
updates.height = d.size.y
figmaDerivedLayout.width = d.size.x
figmaDerivedLayout.height = d.size.y
}
if (d.transform) {
updates.x = d.transform.m02
updates.y = d.transform.m12
figmaDerivedLayout.x = d.transform.m02
figmaDerivedLayout.y = d.transform.m12
} else if (d.size) {
const position = resolveSizeOnlyPosition(ctx, target)
if (position) {
updates.x = position.x
updates.y = position.y
figmaDerivedLayout.x = position.x
figmaDerivedLayout.y = position.y
}
}
if (Object.keys(figmaDerivedLayout).length > 0) {
updates.figmaDerivedLayout = figmaDerivedLayout
}
Object.assign(updates, resolveDsdGeometry(d, target, ctx.blobs))
return { updates, hasSize: d.size !== undefined }
}
function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeSet: Set<string> } {
const modified = new Set<string>()
const sizeSet = new Set<string>()
@ -86,21 +143,15 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeS
const target = ctx.graph.getNode(targetId)
if (!target) continue
const updates: Partial<SceneNode> = {}
if (d.size) {
updates.width = d.size.x
updates.height = d.size.y
const { updates, hasSize } = buildDsdLayoutUpdates(ctx, d, target)
if (d.fillGeometry?.length || d.strokeGeometry?.length) {
ctx.geometryOverrideNodes.add(targetId)
}
if (d.transform) {
updates.x = d.transform.m02
updates.y = d.transform.m12
}
Object.assign(updates, resolveDsdGeometry(d, target, ctx.blobs))
if (Object.keys(updates).length > 0) {
ctx.graph.updateNode(targetId, updates)
modified.add(targetId)
if (d.size) sizeSet.add(targetId)
if (hasSize) sizeSet.add(targetId)
}
}
}
@ -135,10 +186,12 @@ function propagateDsdChanges(
if (source.height !== clone.height) cu.height = source.height
if (source.x !== clone.x) cu.x = source.x
if (source.y !== clone.y) cu.y = source.y
if (source.fillGeometry !== clone.fillGeometry)
cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry)
cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (!ctx.geometryOverrideNodes.has(cloneId)) {
if (source.fillGeometry !== clone.fillGeometry)
cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry)
cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
}
if (Object.keys(cu).length > 0) ctx.graph.updateNode(cloneId, cu)
}
queue.push(cloneId)

View file

@ -49,6 +49,19 @@ function buildKiwiPropertyNodes(
return result
}
function buildKiwiGeometryNodes(
changeMap: Map<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>
): Set<string> {
const result = new Set<string>()
for (const [figmaId, nodeId] of guidToNodeId) {
const nc = changeMap.get(figmaId)
if (!nc) continue
if (nc.fillGeometry?.length || nc.strokeGeometry?.length) result.add(nodeId)
}
return result
}
function propagateResolvedFills(graph: SceneGraph, protectedNodes: Set<string>): void {
for (let pass = 0; pass < 10; pass++) {
let changed = false
@ -91,6 +104,7 @@ function buildOverrideContext(
}
const kiwiPropertyNodes = buildKiwiPropertyNodes(graph, changeMap, guidToNodeId)
const geometryOverrideNodes = buildKiwiGeometryNodes(changeMap, guidToNodeId)
return {
graph,
@ -103,7 +117,8 @@ function buildOverrideContext(
preComputedRoot: new Map(),
componentIdRoot: new Map(),
swappedInstances: new Set(),
kiwiPropertyNodes
kiwiPropertyNodes,
geometryOverrideNodes
}
}

View file

@ -5,6 +5,8 @@ import type { GUID } from '../codec'
import type { OverrideContext } from './types'
const MAX_CHAIN_DEPTH = 20
const siblingIndexCache = new WeakMap<OverrideContext, Map<string, number | null>>()
const candidateCache = new WeakMap<OverrideContext, Map<string, string[]>>()
/**
* Pre-compute componentId root for every node.
@ -80,6 +82,79 @@ export function getComponentRoot(ctx: OverrideContext, nodeId: string, depth = 0
* ambiguity when multiple siblings share the same root).
* Pass 3: recurse into children.
*/
function sourceSiblingIndex(ctx: OverrideContext, figmaGuid: string): number | null {
let cache = siblingIndexCache.get(ctx)
if (!cache) {
cache = new Map()
siblingIndexCache.set(ctx, cache)
}
if (cache.has(figmaGuid)) return cache.get(figmaGuid) ?? null
const nc = ctx.changeMap.get(figmaGuid)
const parentId = nc?.parentIndex?.guid ? guidToString(nc.parentIndex.guid) : null
const symbolId = nc?.symbolData?.symbolID ? guidToString(nc.symbolData.symbolID) : null
if (!nc || !parentId || !symbolId) {
cache.set(figmaGuid, null)
return null
}
const siblings = [...ctx.changeMap]
.filter(([, sibling]) => {
const siblingParent = sibling.parentIndex?.guid ? guidToString(sibling.parentIndex.guid) : null
const siblingSymbol = sibling.symbolData?.symbolID ? guidToString(sibling.symbolData.symbolID) : null
return siblingParent === parentId && siblingSymbol === symbolId
})
.sort(([, a], [, b]) =>
(a.transform?.m12 ?? 0) - (b.transform?.m12 ?? 0) ||
(a.transform?.m02 ?? 0) - (b.transform?.m02 ?? 0)
)
.map(([id]) => id)
const index = siblings.indexOf(figmaGuid)
const result = index !== -1 ? index : null
cache.set(figmaGuid, result)
return result
}
function findNodeBySourceSiblingIndex(
ctx: OverrideContext,
parentId: string,
componentId: string,
figmaGuid: string
): string | null {
const index = sourceSiblingIndex(ctx, figmaGuid)
if (index == null) return null
const targetRoot = ctx.preComputedRoot.get(componentId) ?? getComponentRoot(ctx, componentId)
let cache = candidateCache.get(ctx)
if (!cache) {
cache = new Map()
candidateCache.set(ctx, cache)
}
const cacheKey = `${parentId}\0${targetRoot}`
let candidates = cache.get(cacheKey)
if (!candidates) {
candidates = []
const collect = (id: string) => {
const node = ctx.graph.getNode(id)
if (!node) return
if (node.componentId) {
const root = ctx.preComputedRoot.get(node.componentId) ?? getComponentRoot(ctx, node.componentId)
if (root === targetRoot) candidates?.push(id)
}
for (const childId of node.childIds) collect(childId)
}
collect(parentId)
candidates.sort((aId, bId) => {
const a = ctx.graph.getNode(aId)
const b = ctx.graph.getNode(bId)
return (a?.y ?? 0) - (b?.y ?? 0) || (a?.x ?? 0) - (b?.x ?? 0)
})
cache.set(cacheKey, candidates)
}
return candidates[index] ?? null
}
export function findNodeByComponentId(
ctx: OverrideContext,
parentId: string,
@ -132,8 +207,8 @@ export function resolveOverrideTarget(
guids: GUID[]
): string | null {
let currentId = instanceId
for (const guid of guids) {
const key = guidToString(guid)
for (let index = 0; index < guids.length; index++) {
const key = guidToString(guids[index])
const figmaGuid = ctx.overrideKeyToGuid.get(key) ?? key
const remapped = ctx.guidToNodeId.get(figmaGuid)
if (!remapped) return null
@ -147,12 +222,19 @@ export function resolveOverrideTarget(
continue
}
// After an instance swap, the child's componentId points to the new
// component, not the one referenced by the guidPath. Fall back to the
// single child of a swapped instance — it occupies the same slot.
const indexed = findNodeBySourceSiblingIndex(ctx, currentId, remapped, figmaGuid)
if (indexed) {
currentId = indexed
continue
}
// Some .fig DSD paths include an intermediate source instance while the
// imported tree has a single wrapper clone in that slot. Descend into the
// wrapper and retry the same guid before giving up.
const parent = ctx.graph.getNode(currentId)
if (parent?.childIds.length === 1) {
currentId = parent.childIds[0]
index--
continue
}

View file

@ -48,11 +48,15 @@ export interface ComponentPropDef {
export interface InstanceNodeChange {
type?: string
guid?: GUID
parentIndex?: { guid?: GUID }
transform?: Matrix
overrideKey?: GUID
symbolData?: SymbolData
componentPropRefs?: ComponentPropRef[]
componentPropAssignments?: ComponentPropAssignment[]
componentPropDefs?: ComponentPropDef[]
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
derivedSymbolData?: DerivedSymbolOverride[]
}
@ -75,6 +79,8 @@ export interface OverrideContext {
preComputedRoot: Map<string, string>
componentIdRoot: Map<string, string>
swappedInstances: Set<string>
/** Nodes whose kiwi NC has explicit property values (fills, cornerRadius, etc.) */
/** Nodes whose kiwi NC has explicit property values (cornerRadius, visibility, etc.) */
kiwiPropertyNodes: Set<string>
/** Nodes whose Figma-derived geometry should not be overwritten by clone propagation. */
geometryOverrideNodes: Set<string>
}

View file

@ -110,7 +110,7 @@ function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<
computeLayoutsBottomUp(graph, childId, visited)
}
if (node.layoutMode !== 'NONE') {
if (node.layoutMode !== 'NONE' && node.type !== 'INSTANCE') {
computeLayout(graph, nodeId)
}
}
@ -622,18 +622,31 @@ function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode)
const computedH = yogaNode.getComputedHeight()
const updates: Partial<SceneNode> = {}
const derived = frame.figmaDerivedLayout
if (frame.primaryAxisSizing === 'HUG') {
if (frame.layoutMode === 'HORIZONTAL') updates.width = computedW
else updates.height = computedH
if (frame.layoutMode === 'HORIZONTAL') updates.width = derived?.width ?? computedW
else updates.height = derived?.height ?? computedH
}
if (frame.counterAxisSizing === 'HUG') {
if (frame.layoutMode === 'HORIZONTAL') updates.height = computedH
else updates.width = computedW
if (frame.layoutMode === 'HORIZONTAL') updates.height = derived?.height ?? computedH
else updates.width = derived?.width ?? computedW
}
graph.updateNode(frame.id, updates)
}
function updateChildFromYoga(graph: SceneGraph, child: SceneNode, yogaChild: YogaNode): void {
if (!child.visible || child.layoutPositioning === 'ABSOLUTE' || child.type === 'INSTANCE') return
const derived = child.figmaDerivedLayout
graph.updateNode(child.id, {
x: derived?.x ?? yogaChild.getComputedLeft(),
y: derived?.y ?? yogaChild.getComputedTop(),
width: derived?.width ?? yogaChild.getComputedWidth(),
height: derived?.height ?? yogaChild.getComputedHeight()
})
}
function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
applyFrameSize(graph, frame, yogaNode)
@ -645,14 +658,7 @@ function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!yogaChild) continue
if (child.visible && child.layoutPositioning !== 'ABSOLUTE') {
graph.updateNode(child.id, {
x: yogaChild.getComputedLeft(),
y: yogaChild.getComputedTop(),
width: yogaChild.getComputedWidth(),
height: yogaChild.getComputedHeight()
})
}
updateChildFromYoga(graph, child, yogaChild)
if (child.layoutMode !== 'NONE') {
if (child.layoutMode === 'GRID' && child.visible && child.layoutPositioning !== 'ABSOLUTE') {

View file

@ -312,6 +312,7 @@ export interface SceneNode {
width: number
height: number
rotation: number
figmaDerivedLayout: { x?: number; y?: number; width?: number; height?: number } | null
fills: Fill[]
strokes: Stroke[]
@ -473,6 +474,7 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
width: 100,
height: 100,
rotation: 0,
figmaDerivedLayout: null,
fills:
type === 'TEXT'
? [{ type: 'SOLID' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]

View file

@ -2,8 +2,11 @@ import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { computeContentBounds } from '../../packages/core/src/io/formats/raster/render'
import {
parseFigFile,
computeAllLayouts,
exportFigFile,
importNodeChanges,
initCodec,
@ -110,6 +113,168 @@ describe('node type coverage', () => {
})
})
function childNamed(
graph: SceneGraph,
parent: SceneNode | undefined,
name: string
): SceneNode | undefined {
return parent ? graph.getChildren(parent.id).find((node) => node.name === name) : undefined
}
function childMatching(
graph: SceneGraph,
parent: SceneNode | undefined,
predicate: (node: SceneNode) => boolean
): SceneNode | undefined {
return parent ? graph.getChildren(parent.id).find(predicate) : undefined
}
function previewChild(
graph: SceneGraph,
nodes: SceneNode[],
name: string
): SceneNode | undefined {
const preview = nodes.find((node) => node.name === 'Preview Thumbnail')
return childNamed(graph, preview, name)
}
describe('derived instance layout regressions', () => {
let layoutGraph: SceneGraph
let layoutNodes: SceneNode[]
beforeAll(async () => {
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
layoutGraph = await parseFigFile(buf.buffer as ArrayBuffer)
computeAllLayouts(layoutGraph)
layoutNodes = collectAllNodes(layoutGraph)
})
test('preserves repeated badge overrides without moving sibling component wrappers', () => {
const input = previewChild(layoutGraph, layoutNodes, 'Input')
const inputRoot = childNamed(layoutGraph, input, '_input')
const inputFrame = childNamed(layoutGraph, inputRoot, 'Input')
const content = childNamed(layoutGraph, inputFrame, 'Content')
const tags = childNamed(layoutGraph, content, 'Tags')
const firstBadge = childNamed(layoutGraph, tags, 'Badge')
const firstBadgeContent = childNamed(layoutGraph, firstBadge, '_badge-and-tag')
const placeholderFrame = childNamed(layoutGraph, content, 'Placeholder')
const placeholderText = childNamed(layoutGraph, placeholderFrame, 'Placeholder')
expect(inputFrame).toMatchObject({ x: 0, y: 0 })
expect(inputFrame?.width).toBeCloseTo(375.7498, 3)
expect(inputFrame?.height).toBeCloseTo(39.3803, 3)
expect(content).toMatchObject({ x: 0, y: 0 })
expect(firstBadge?.x).toBeCloseTo(7.1268, 3)
expect(firstBadge?.y).toBeCloseTo(5.3451, 3)
expect(firstBadge?.width).toBeCloseTo(85.3239, 3)
expect(firstBadge?.height).toBeCloseTo(28.6901, 3)
expect(firstBadgeContent).toMatchObject({ x: 0, y: 0 })
expect(placeholderFrame?.x).toBeCloseTo(277.352, 3)
expect(placeholderFrame?.y).toBeCloseTo(0, 3)
expect(placeholderText?.x).toBeCloseTo(14.2535, 3)
expect(placeholderText?.y).toBeCloseTo(10.6901, 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)
expect(datepicker?.y).toBeCloseTo(518, 3)
expect(datepicker?.width).toBeCloseTo(362, 3)
expect(datepicker?.height).toBeCloseTo(422, 3)
})
test('keeps checked-list icons aligned with labels', () => {
const title = previewChild(layoutGraph, layoutNodes, 'Title + Description')
const checkedList = childNamed(layoutGraph, title, 'Checked List')
const listItems = checkedList ? layoutGraph.getChildren(checkedList.id) : []
expect(listItems).toHaveLength(3)
for (const item of listItems) {
const list = childNamed(layoutGraph, item, '_list')
const inline = childNamed(layoutGraph, list, 'Inline')
const icon = childNamed(layoutGraph, inline, 'Static Icon')
const content = childNamed(layoutGraph, inline, 'Content')
const label = content ? layoutGraph.getChildren(content.id).find((node) => node.text) : undefined
expect(icon?.y).toBeCloseTo(0, 3)
expect(label?.y).toBeCloseTo(0, 3)
expect(icon?.height).toBeCloseTo(label?.height ?? 0, 3)
}
})
test('preserves WYSIWYG toolbar padding', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
const toolbarRoot = childNamed(layoutGraph, wysiwyg, '_on-text-WYSIWYG-toolbar')
const toolbar = childMatching(
layoutGraph,
toolbarRoot,
(node) => node.name === 'Toolbar' && node.width === 286 && node.height === 48
)
const buttons = childMatching(
layoutGraph,
toolbar,
(node) => node.name === 'Toolbar' && node.width === 270 && node.height === 32
)
expect(toolbar?.x).toBeCloseTo(0, 3)
expect(toolbar?.y).toBeCloseTo(37, 3)
expect(buttons?.x).toBeCloseTo(8, 3)
expect(buttons?.y).toBeCloseTo(8, 3)
expect(buttons?.width).toBeCloseTo(270, 3)
expect(buttons?.height).toBeCloseTo(32, 3)
})
test('scales WYSIWYG icon vector geometry with nested instances', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
const toolbarRoot = childNamed(layoutGraph, wysiwyg, '_on-text-WYSIWYG-toolbar')
const toolbar = childMatching(
layoutGraph,
toolbarRoot,
(node) => node.name === 'Toolbar' && node.width === 286 && node.height === 48
)
const buttons = childMatching(
layoutGraph,
toolbar,
(node) => node.name === 'Toolbar' && node.width === 270 && node.height === 32
)
const linkButton = layoutGraph.getChildren(buttons?.id ?? '').find((node) => node.x === 136)
const linkIcon = childNamed(layoutGraph, linkButton, 'link')
const linkVectors = linkIcon ? layoutGraph.getChildren(linkIcon.id) : []
expect(linkIcon?.width).toBeCloseTo(18, 3)
expect(linkIcon?.height).toBeCloseTo(18, 3)
for (const vector of linkVectors) {
const xs = vector.vectorNetwork?.vertices.map((vertex) => vertex.x) ?? []
const ys = vector.vectorNetwork?.vertices.map((vertex) => vertex.y) ?? []
expect(Math.max(...xs)).toBeLessThanOrEqual(vector.width + 0.001)
expect(Math.max(...ys)).toBeLessThanOrEqual(vector.height + 0.001)
}
})
test('exports WYSIWYG and logo visual overflow instead of clipping to node boxes', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
expect(wysiwyg).toBeDefined()
const wysiwygBounds = wysiwyg ? computeContentBounds(layoutGraph, [wysiwyg.id]) : null
expect(wysiwygBounds?.minX).toBeCloseTo(5, 3)
expect(wysiwygBounds?.minY).toBeCloseTo(577, 3)
expect(wysiwygBounds ? wysiwygBounds.maxX - wysiwygBounds.minX : 0).toBeCloseTo(294, 3)
expect(wysiwygBounds ? wysiwygBounds.maxY - wysiwygBounds.minY : 0).toBeCloseTo(93, 3)
const title = previewChild(layoutGraph, layoutNodes, 'Title + Description')
const logoGroup = childNamed(
layoutGraph,
childNamed(layoutGraph, title, 'Logo'),
'logo-short-6'
)
expect(logoGroup).toBeDefined()
const logoBounds = logoGroup ? computeContentBounds(layoutGraph, [logoGroup.id]) : null
const logoAbs = logoGroup ? layoutGraph.getAbsolutePosition(logoGroup.id) : { x: 0, y: 0 }
const logo = logoGroup
expect(logoBounds?.minX).toBeLessThan(logoAbs.x)
expect(logoBounds?.minY).toBeLessThan(logoAbs.y)
expect(logoBounds?.maxX).toBeGreaterThan(logoAbs.x + (logo?.width ?? 0))
expect(logoBounds?.maxY).toBeGreaterThan(logoAbs.y + (logo?.height ?? 0))
})
})
describe('property integrity', () => {
test('all nodes have finite dimensions', () => {
for (const n of allNodes) {

View file

@ -0,0 +1,162 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { initCanvasKit } from '../../packages/cli/src/headless'
import {
computeAllLayouts,
initCodec,
parseFigFile,
renderNodesToImage,
SceneGraph,
SkiaRenderer,
type SceneNode
} from '@open-pencil/core'
let graph: SceneGraph
let movingNodeId: string
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
await initCodec()
const buf = readFileSync(resolve(import.meta.dir, '../fixtures/gold-preview.fig'))
graph = await parseFigFile(buf.buffer as ArrayBuffer)
computeAllLayouts(graph)
const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
const input = preview
? graph.getChildren(preview.id).find((node) => node.name === 'Input')
: undefined
if (!input) throw new Error('gold-preview Input fixture node not found')
movingNodeId = input.id
})
function renderPreview(renderer: SkiaRenderer, sceneVersion: number): Uint8Array {
renderer.render(graph, new Set(), {}, sceneVersion)
const image = renderer.surface.makeImageSnapshot()
const pixels = image.readPixels(0, 0, {
width: 900,
height: 700,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})!
image.delete()
return pixels
}
function childNamed(parent: SceneNode | undefined, name: string): SceneNode | undefined {
return parent ? graph.getChildren(parent.id).find((node) => node.name === name) : undefined
}
function fixtureInputBadge(): SceneNode {
const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
const input = childNamed(preview, 'Input')
const inputRoot = childNamed(input, '_input')
const inputFrame = childNamed(inputRoot, 'Input')
const content = childNamed(inputFrame, 'Content')
const tags = childNamed(content, 'Tags')
const badge = childNamed(tags, 'Badge')
if (!badge) throw new Error('gold-preview badge fixture node not found')
return badge
}
function countDarkPixels(pixels: Uint8Array): number {
let dark = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] > 200 && pixels[i] < 80 && pixels[i + 1] < 80 && pixels[i + 2] < 80) {
dark++
}
}
return dark
}
function pixelIndex(width: number, x: number, y: number): number {
return (y * width + x) * 4
}
function maskYCenter(
pixels: Uint8Array,
width: number,
height: number,
matches: (index: number) => boolean,
xRange = [0, width]
): number {
let minY = Infinity
let maxY = -Infinity
for (let y = 0; y < height; y++) {
for (let x = xRange[0]; x < xRange[1]; x++) {
if (!matches(pixelIndex(width, x, y))) continue
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}
}
if (minY === Infinity) throw new Error('pixel mask had no matches')
return (minY + maxY) / 2
}
describe('render cache regressions', () => {
test('badge label is vertically centered in the pill', async () => {
const surface = ck.MakeSurface(120, 60)!
const renderer = new SkiaRenderer(ck, surface)
await renderer.loadFonts()
try {
const badge = fixtureInputBadge()
const png = renderNodesToImage(ck, renderer, graph, graph.getPages()[0].id, [badge.id], {
scale: 1,
format: 'PNG'
})
expect(png).toBeTruthy()
const image = ck.MakeImageFromEncoded(png!)!
const width = image.width()
const height = image.height()
const pixels = image.readPixels(0, 0, {
width,
height,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})!
image.delete()
const contentCenter = maskYCenter(pixels, width, height, (i) => pixels[i + 3] > 10)
const textCenter = maskYCenter(
pixels,
width,
height,
(i) => pixels[i + 3] > 128 && pixels[i] < 130 && pixels[i + 1] < 140 && pixels[i + 2] < 160,
[20, 67]
)
expect(Math.abs(textCenter - contentCenter)).toBeLessThanOrEqual(0.6)
} finally {
surface.delete()
}
})
test('scene picture redraw keeps text after moving a node', async () => {
const surface = ck.MakeSurface(900, 700)!
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 900
renderer.viewportHeight = 700
renderer.dpr = 1
await renderer.loadFonts()
renderer.panX = 0
renderer.panY = 0
renderer.zoom = 0.75
renderer.pageId = graph.getPages()[0].id
try {
const beforeDark = countDarkPixels(renderPreview(renderer, 1))
const movingNode = graph.getNode(movingNodeId)
expect(movingNode).toBeDefined()
const originalX = movingNode?.x ?? 0
graph.updateNode(movingNodeId, { x: originalX + 20 })
expect(graph.getNode(movingNodeId)?.x).toBeCloseTo(originalX + 20, 3)
const afterDark = countDarkPixels(renderPreview(renderer, 2))
expect(afterDark).toBeGreaterThan(beforeDark * 0.8)
} finally {
surface.delete()
}
})
})