From cfefc1fb4e9ea1fadd13f2a347556d34e3322a5c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 25 Apr 2026 16:33:37 +0300 Subject: [PATCH] fix(kiwi): preserve derived instance layout --- packages/core/src/canvas/renderer.ts | 26 ++- packages/core/src/canvas/scene.ts | 111 ++++++++--- packages/core/src/geometry.ts | 186 ++++++++++++++++++ packages/core/src/io/formats/raster/render.ts | 27 +-- .../kiwi/instance-overrides/constraints.ts | 58 +++++- .../core/src/kiwi/instance-overrides/dsd.ts | 81 ++++++-- .../core/src/kiwi/instance-overrides/index.ts | 17 +- .../src/kiwi/instance-overrides/resolve.ts | 92 ++++++++- .../core/src/kiwi/instance-overrides/types.ts | 8 +- packages/core/src/layout.ts | 32 +-- packages/core/src/scene-graph/index.ts | 2 + tests/engine/fig-roundtrip.test.ts | 165 ++++++++++++++++ tests/engine/render-cache.test.ts | 162 +++++++++++++++ 13 files changed, 876 insertions(+), 91 deletions(-) create mode 100644 tests/engine/render-cache.test.ts diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 489abb6c4..39bd5e4aa 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -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, diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index f3caf68e3..791b79b6d 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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) diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts index 65f1abac9..b75c6270f 100644 --- a/packages/core/src/geometry.ts +++ b/packages/core/src/geometry.ts @@ -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 +} diff --git a/packages/core/src/io/formats/raster/render.ts b/packages/core/src/io/formats/raster/render.ts index ae0a80f79..27c1e8e6f 100644 --- a/packages/core/src/io/formats/raster/render.ts +++ b/packages/core/src/io/formats/raster/render.ts @@ -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> => !!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) { diff --git a/packages/core/src/kiwi/instance-overrides/constraints.ts b/packages/core/src/kiwi/instance-overrides/constraints.ts index be829bff1..321016e0e 100644 --- a/packages/core/src/kiwi/instance-overrides/constraints.ts +++ b/packages/core/src/kiwi/instance-overrides/constraints.ts @@ -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): void { +function propagateScaling(ctx: OverrideContext, scaled: Set): void { + const { graph } = ctx const clonesOf = buildClonesMap(graph) const queue = [...scaled] const visited = new Set() @@ -116,6 +163,11 @@ function propagateScaling(graph: SceneGraph, scaled: Set): 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) } diff --git a/packages/core/src/kiwi/instance-overrides/dsd.ts b/packages/core/src/kiwi/instance-overrides/dsd.ts index a1c61501e..0a9e22f5c 100644 --- a/packages/core/src/kiwi/instance-overrides/dsd.ts +++ b/packages/core/src/kiwi/instance-overrides/dsd.ts @@ -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 | 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; hasSize: boolean } { + const updates: Partial = {} + const figmaDerivedLayout: NonNullable = {} + + 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; sizeSet: Set } { const modified = new Set() const sizeSet = new Set() @@ -86,21 +143,15 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set; sizeS const target = ctx.graph.getNode(targetId) if (!target) continue - const updates: Partial = {} - 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) diff --git a/packages/core/src/kiwi/instance-overrides/index.ts b/packages/core/src/kiwi/instance-overrides/index.ts index b78f31eb9..fb86eb508 100644 --- a/packages/core/src/kiwi/instance-overrides/index.ts +++ b/packages/core/src/kiwi/instance-overrides/index.ts @@ -49,6 +49,19 @@ function buildKiwiPropertyNodes( return result } +function buildKiwiGeometryNodes( + changeMap: Map, + guidToNodeId: Map +): Set { + const result = new Set() + 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): 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 } } diff --git a/packages/core/src/kiwi/instance-overrides/resolve.ts b/packages/core/src/kiwi/instance-overrides/resolve.ts index c509773e6..e5ffb99c6 100644 --- a/packages/core/src/kiwi/instance-overrides/resolve.ts +++ b/packages/core/src/kiwi/instance-overrides/resolve.ts @@ -5,6 +5,8 @@ import type { GUID } from '../codec' import type { OverrideContext } from './types' const MAX_CHAIN_DEPTH = 20 +const siblingIndexCache = new WeakMap>() +const candidateCache = new WeakMap>() /** * 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 } diff --git a/packages/core/src/kiwi/instance-overrides/types.ts b/packages/core/src/kiwi/instance-overrides/types.ts index 15907f5e1..abd134c05 100644 --- a/packages/core/src/kiwi/instance-overrides/types.ts +++ b/packages/core/src/kiwi/instance-overrides/types.ts @@ -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 componentIdRoot: Map swappedInstances: Set - /** Nodes whose kiwi NC has explicit property values (fills, cornerRadius, etc.) */ + /** Nodes whose kiwi NC has explicit property values (cornerRadius, visibility, etc.) */ kiwiPropertyNodes: Set + /** Nodes whose Figma-derived geometry should not be overwritten by clone propagation. */ + geometryOverrideNodes: Set } diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 712d97c14..c76a35c26 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -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 = {} + 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') { diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index c99e8ac5d..e373493c5 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -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 = {}): 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 }] diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index 2da50ef78..359186080 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -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) { diff --git a/tests/engine/render-cache.test.ts b/tests/engine/render-cache.test.ts new file mode 100644 index 000000000..61ec4f464 --- /dev/null +++ b/tests/engine/render-cache.test.ts @@ -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> + +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() + } + }) +})