fix(kiwi): preserve derived instance layout
This commit is contained in:
parent
63d3f2e0cb
commit
cfefc1fb4e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
162
tests/engine/render-cache.test.ts
Normal file
162
tests/engine/render-cache.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue