diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index aa8e20525..4dcacaa4b 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -218,7 +218,8 @@ export function importClipboardNodes( populateAndApplyOverrides( graph, guidMap as unknown as Map, - created + created, + blobs ) for (const figmaId of internalTopLevel) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ee54fa06d..5c7a06eed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export { type VectorVertex, type VectorSegment, type VectorRegion, + type GeometryPath, type HandleMirroring, type WindingRule, type VariableType, @@ -68,6 +69,7 @@ export { export { parseColor, colorToHex, colorToHexRaw, colorToRgba255 } from './color' export { vectorNetworkToPath, + geometryBlobToPath, decodeVectorNetworkBlob, encodeVectorNetworkBlob, computeVectorBounds diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index 506ddf0d5..c497a332a 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -307,6 +307,21 @@ export function sceneNodeToKiwi( } } + if (node.fillGeometry.length > 0) { + nc.fillGeometry = node.fillGeometry.map((g) => { + const blobIdx = blobs.length + blobs.push(g.commandsBlob) + return { windingRule: g.windingRule, commandsBlob: blobIdx } + }) + } + if (node.strokeGeometry.length > 0) { + nc.strokeGeometry = node.strokeGeometry.map((g) => { + const blobIdx = blobs.length + blobs.push(g.commandsBlob) + return { windingRule: g.windingRule, commandsBlob: blobIdx } + }) + } + const result: KiwiNodeChange[] = [nc] const children = graph.getChildren(node.id) for (let i = 0; i < children.length; i++) { diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 1f710724d..977378410 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -254,6 +254,8 @@ export interface NodeChange { frameMaskDisabled?: boolean // Vector vectorData?: unknown + fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> + strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> // Text fontSize?: number fontWeight?: number diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 5d0269542..a0c7574a6 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -182,7 +182,8 @@ export function importNodeChanges( populateAndApplyOverrides( graph, changeMap as unknown as Map, - guidToNodeId + guidToNodeId, + blobs ) // Ensure at least one page exists diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts index 19821e77b..090aae1d0 100644 --- a/packages/core/src/kiwi/instance-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides.ts @@ -1,5 +1,5 @@ import type { SceneGraph, SceneNode } from '../scene-graph' -import { guidToString, convertOverrideToProps } from './kiwi-convert' +import { guidToString, convertOverrideToProps, resolveGeometryPaths } from './kiwi-convert' import type { GUID } from './codec' interface SymbolOverride { @@ -27,6 +27,9 @@ interface ComponentPropAssignment { interface DerivedSymbolOverride { guidPath?: { guids?: GUID[] } size?: { x: number; y: number } + transform?: { m00: number; m01: number; m02: number; m10: number; m11: number; m12: number } + fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> + strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> } export interface InstanceNodeChange { @@ -54,7 +57,8 @@ export interface InstanceNodeChange { export function populateAndApplyOverrides( graph: SceneGraph, changeMap: Map, - guidToNodeId: Map + guidToNodeId: Map, + blobs: Uint8Array[] = [] ): void { // Iterative population: cloning creates new instances that themselves need children let populated = 1 @@ -252,6 +256,33 @@ export function populateAndApplyOverrides( // Apply derivedSymbolData — pre-computed sizes for the current set of // component property values. Uses the same guidPath resolution as // symbolOverrides. + function scaleGeometryBlobs( + geom: import('../scene-graph').GeometryPath[], + sx: number, + sy: number + ): import('../scene-graph').GeometryPath[] { + if (sx === 1 && sy === 1) return geom + return geom.map((g) => { + const src = g.commandsBlob + const scaled = new Uint8Array(src.length) + scaled.set(src) + const dv = new DataView(scaled.buffer, scaled.byteOffset, scaled.byteLength) + let o = 0 + while (o < scaled.length) { + const cmd = scaled[o++] + if (cmd === 0) continue + const coords = cmd === 1 || cmd === 2 ? 1 : cmd === 4 ? 3 : -1 + if (coords < 0) break + for (let i = 0; i < coords; i++) { + dv.setFloat32(o, dv.getFloat32(o, true) * sx, true) + dv.setFloat32(o + 4, dv.getFloat32(o + 4, true) * sy, true) + o += 8 + } + } + return { windingRule: g.windingRule, commandsBlob: scaled } + }) + } + function applyDerivedSymbolData() { for (const [ncId, nc] of changeMap) { if (nc.type !== 'INSTANCE') continue @@ -264,12 +295,38 @@ export function populateAndApplyOverrides( for (const d of derived) { const guids = d.guidPath?.guids if (!guids?.length) continue - if (!d.size) continue const targetId = resolveOverrideTarget(nodeId, guids) if (!targetId) continue - graph.updateNode(targetId, { width: d.size.x, height: d.size.y }) + const target = graph.getNode(targetId) + if (!target) continue + + const updates: Partial = {} + if (d.size) { + updates.width = d.size.x + updates.height = d.size.y + } + if (d.transform) { + updates.x = d.transform.m02 + updates.y = d.transform.m12 + } + const fg = resolveGeometryPaths(d.fillGeometry, blobs) + const sg = resolveGeometryPaths(d.strokeGeometry, blobs) + if (fg.length > 0) { + updates.fillGeometry = fg + } else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) { + updates.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height) + } + if (sg.length > 0) { + updates.strokeGeometry = sg + } else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) { + updates.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height) + } + + if (Object.keys(updates).length > 0) { + graph.updateNode(targetId, updates) + } } } } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 1a2ca5fe4..c948a21ab 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -26,8 +26,10 @@ import type { TextDecoration, ArcData, VectorNetwork, + GeometryPath, StyleRun, - CharacterStyleOverride + CharacterStyleOverride, + WindingRule } from '../scene-graph' import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from './codec' @@ -390,6 +392,30 @@ function resolveVectorNetwork( } } +interface KiwiPath { + windingRule?: string + commandsBlob?: number +} + +export function resolveGeometryPaths( + paths: KiwiPath[] | undefined, + blobs: Uint8Array[] +): GeometryPath[] { + if (!paths || paths.length === 0) return [] + const result: GeometryPath[] = [] + for (const p of paths) { + if (p.commandsBlob === undefined || p.commandsBlob < 0 || p.commandsBlob >= blobs.length) + continue + const blob = blobs[p.commandsBlob] + if (!blob || blob.length === 0) continue + result.push({ + windingRule: (p.windingRule === 'EVENODD' ? 'EVENODD' : 'NONZERO') as WindingRule, + commandsBlob: blob + }) + } + return result +} + function extractBoundVariables(nc: NodeChange): Record { const bindings: Record = {} nc.fillPaints?.forEach((paint, i) => { @@ -493,6 +519,8 @@ export function nodeChangeToProps( layoutGrow: (ext(nc).stackChildPrimaryGrow as number) ?? 0, layoutAlignSelf: (ext(nc).stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO', vectorNetwork: resolveVectorNetwork(nc, blobs), + fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs), + strokeGeometry: resolveGeometryPaths(nc.strokeGeometry, blobs), arcData: mapArcData(ext(nc).arcData as Record | undefined), strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap, strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin, diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index eeb19b5fb..1c32bb90a 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -59,7 +59,7 @@ import { DEFAULT_FONT_FAMILY } from './constants' import { isFontLoaded } from './fonts' -import { vectorNetworkToPath } from './vector' +import { vectorNetworkToPath, geometryBlobToPath } from './vector' import { RenderProfiler } from './profiler' import type { SceneNode, SceneGraph, Fill, Stroke } from './scene-graph' @@ -143,7 +143,9 @@ export class SkiaRenderer { private fontProvider: TypefaceFontProvider | null = null private fontsLoaded = false private imageCache = new Map() - private vectorPathCache = new Map() + private vectorPathCache = new Map() + private fillGeometryCache = new Map() + private strokeGeometryCache = new Map() private scenePicture: SkPicture | null = null private scenePictureVersion = -1 private scenePicturePageId: string | null = null @@ -1153,21 +1155,50 @@ export class SkiaRenderer { canvas.restore() } - private getVectorPath(node: SceneNode): Path | null { + private getVectorPaths(node: SceneNode): Path[] | null { if (!node.vectorNetwork) return null const cached = this.vectorPathCache.get(node.id) if (cached) return cached - const path = vectorNetworkToPath(this.ck, node.vectorNetwork) - this.vectorPathCache.set(node.id, path) - return path + const paths = vectorNetworkToPath(this.ck, node.vectorNetwork) + this.vectorPathCache.set(node.id, paths) + return paths + } + + private getFillGeometry(node: SceneNode): Path[] | null { + if (node.fillGeometry.length === 0) return null + const cached = this.fillGeometryCache.get(node.id) + if (cached) return cached + const paths = node.fillGeometry.map((g) => + geometryBlobToPath(this.ck, g.commandsBlob, g.windingRule) + ) + this.fillGeometryCache.set(node.id, paths) + return paths + } + + private getStrokeGeometry(node: SceneNode): Path[] | null { + if (node.strokeGeometry.length === 0) return null + const cached = this.strokeGeometryCache.get(node.id) + if (cached) return cached + const paths = node.strokeGeometry.map((g) => + geometryBlobToPath(this.ck, g.commandsBlob, g.windingRule) + ) + this.strokeGeometryCache.set(node.id, paths) + return paths } invalidateVectorPath(nodeId: string): void { const old = this.vectorPathCache.get(nodeId) if (old) { - old.delete() + for (const p of old) p.delete() this.vectorPathCache.delete(nodeId) } + for (const cache of [this.fillGeometryCache, this.strokeGeometryCache]) { + const oldGeom = cache.get(nodeId) + if (oldGeom) { + for (const p of oldGeom) p.delete() + cache.delete(nodeId) + } + } } private strokeNodeShape(canvas: Canvas, node: SceneNode, paint: Paint): void { @@ -1178,8 +1209,10 @@ export class SkiaRenderer { canvas.drawOval(rect, paint) return case 'VECTOR': { - const vp = this.getVectorPath(node) - if (vp) canvas.drawPath(vp, paint) + const vps = this.getVectorPaths(node) + if (vps) { + for (const vp of vps) canvas.drawPath(vp, paint) + } return } case 'LINE': @@ -1548,10 +1581,51 @@ export class SkiaRenderer { } // Strokes + const sg = node.type === 'VECTOR' ? this.getStrokeGeometry(node) : null + const vectorPaths = !sg && node.type === 'VECTOR' ? this.getVectorPaths(node) : null for (let si = 0; si < node.strokes.length; si++) { const stroke = node.strokes[si]! if (!stroke.visible) continue const sc = this.resolveStrokeColor(stroke, si, node, graph) + + if (sg) { + this.fillPaint.setColor(this.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) + this.fillPaint.setAlphaf(stroke.opacity) + this.fillPaint.setShader(null) + for (const p of sg) canvas.drawPath(p, this.fillPaint) + continue + } + + if (vectorPaths) { + const capMap: Record = { + NONE: this.ck.StrokeCap.Butt, + ROUND: this.ck.StrokeCap.Round, + SQUARE: this.ck.StrokeCap.Square + } + const joinMap: Record = { + MITER: this.ck.StrokeJoin.Miter, + ROUND: this.ck.StrokeJoin.Round, + BEVEL: this.ck.StrokeJoin.Bevel + } + const strokeOpts = { + width: stroke.weight, + miter_limit: 4, + cap: capMap[stroke.cap ?? 'NONE'] ?? this.ck.StrokeCap.Butt, + join: joinMap[stroke.join ?? 'MITER'] ?? this.ck.StrokeJoin.Miter + } + this.fillPaint.setColor(this.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) + this.fillPaint.setAlphaf(stroke.opacity) + this.fillPaint.setShader(null) + for (const vp of vectorPaths) { + const outline = vp.copy().stroke(strokeOpts) + if (outline) { + canvas.drawPath(outline, this.fillPaint) + outline.delete() + } + } + continue + } + this.strokePaint.setColor(this.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) this.strokePaint.setStrokeWidth(stroke.weight) this.strokePaint.setAlphaf(stroke.opacity) @@ -1597,8 +1671,15 @@ export class SkiaRenderer { ): void { switch (node.type) { case 'VECTOR': { - const vp = this.getVectorPath(node) - if (vp) canvas.drawPath(vp, this.fillPaint) + const fg = this.getFillGeometry(node) + if (fg) { + for (const p of fg) canvas.drawPath(p, this.fillPaint) + } else { + const vps = this.getVectorPaths(node) + if (vps) { + for (const vp of vps) canvas.drawPath(vp, this.fillPaint) + } + } break } case 'ELLIPSE': @@ -1638,8 +1719,10 @@ export class SkiaRenderer { ): void { switch (node.type) { case 'VECTOR': { - const vp = this.getVectorPath(node) - if (vp) canvas.drawPath(vp, this.strokePaint) + const vps = this.getVectorPaths(node) + if (vps) { + for (const vp of vps) canvas.drawPath(vp, this.strokePaint) + } break } case 'ELLIPSE': @@ -1762,8 +1845,10 @@ export class SkiaRenderer { path.addOval(rect) break case 'VECTOR': { - const vp = this.getVectorPath(node) - if (vp) path.addPath(vp) + const vps = this.getVectorPaths(node) + if (vps) { + for (const vp of vps) path.addPath(vp) + } break } case 'POLYGON': @@ -2977,8 +3062,12 @@ export class SkiaRenderer { for (const img of this.imageCache.values()) img.delete() this.imageCache.clear() - for (const p of this.vectorPathCache.values()) p.delete() - this.vectorPathCache.clear() + for (const cache of [this.vectorPathCache, this.fillGeometryCache, this.strokeGeometryCache]) { + for (const paths of cache.values()) { + for (const p of paths) p.delete() + } + cache.clear() + } this.fillPaint.delete() this.strokePaint.delete() this.selectionPaint.delete() diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 86c65538e..43fd71d17 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -32,6 +32,11 @@ export interface VectorNetwork { regions: VectorRegion[] } +export interface GeometryPath { + windingRule: WindingRule + commandsBlob: Uint8Array +} + export type NodeType = | 'CANVAS' | 'FRAME' @@ -227,6 +232,8 @@ export interface SceneNode { layoutAlignSelf: 'AUTO' | 'STRETCH' vectorNetwork: VectorNetwork | null + fillGeometry: GeometryPath[] + strokeGeometry: GeometryPath[] arcData: ArcData | null @@ -357,6 +364,8 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}): layoutGrow: 0, layoutAlignSelf: 'AUTO', vectorNetwork: null, + fillGeometry: [], + strokeGeometry: [], arcData: null, textAlignVertical: 'TOP', textAutoResize: 'NONE', diff --git a/packages/core/src/vector.ts b/packages/core/src/vector.ts index 8ce6159fd..550407d27 100644 --- a/packages/core/src/vector.ts +++ b/packages/core/src/vector.ts @@ -172,43 +172,48 @@ export function encodeVectorNetworkBlob(network: VectorNetwork): Uint8Array { return new Uint8Array(buf) } -export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path { - const path = new ck.Path() +export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path[] { const { vertices, segments, regions } = network if (regions.length > 0) { + const paths: Path[] = [] for (const region of regions) { + const regionPath = new ck.Path() for (const loop of region.loops) { - addLoopToPath(path, loop, segments, vertices) + addLoopToPath(regionPath, loop, segments, vertices) } - path.setFillType(region.windingRule === 'EVENODD' ? ck.FillType.EvenOdd : ck.FillType.Winding) + regionPath.setFillType( + region.windingRule === 'EVENODD' ? ck.FillType.EvenOdd : ck.FillType.Winding + ) + paths.push(regionPath) } - } else { - // No regions — draw all segments as open paths - const visited = new Set() - const chains = buildChains(segments, vertices.length) + return paths + } - for (const chain of chains) { - if (chain.length === 0) continue - const firstSeg = segments[chain[0]] - path.moveTo(vertices[firstSeg.start].x, vertices[firstSeg.start].y) + // No regions — draw all segments as open paths + const path = new ck.Path() + const visited = new Set() + const chains = buildChains(segments, vertices.length) - for (const segIdx of chain) { - visited.add(segIdx) - addSegmentToPath(path, segments[segIdx], vertices) - } - } + for (const chain of chains) { + if (chain.length === 0) continue + const firstSeg = segments[chain[0]] + path.moveTo(vertices[firstSeg.start].x, vertices[firstSeg.start].y) - // Any remaining disconnected segments - for (let i = 0; i < segments.length; i++) { - if (visited.has(i)) continue - const seg = segments[i] - path.moveTo(vertices[seg.start].x, vertices[seg.start].y) - addSegmentToPath(path, seg, vertices) + for (const segIdx of chain) { + visited.add(segIdx) + addSegmentToPath(path, segments[segIdx], vertices) } } - return path + for (let i = 0; i < segments.length; i++) { + if (visited.has(i)) continue + const seg = segments[i] + path.moveTo(vertices[seg.start].x, vertices[seg.start].y) + addSegmentToPath(path, seg, vertices) + } + + return [path] } function addLoopToPath( @@ -331,3 +336,57 @@ export function computeVectorBounds(network: VectorNetwork): { return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } } + +const CMD_CLOSE = 0 +const CMD_MOVE_TO = 1 +const CMD_LINE_TO = 2 +const CMD_CUBIC_TO = 4 + +export function geometryBlobToPath( + ck: CanvasKit, + blob: Uint8Array, + windingRule: WindingRule +): Path { + const path = new ck.Path() + const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength) + let o = 0 + + while (o < blob.length) { + const cmd = blob[o++] + switch (cmd) { + case CMD_CLOSE: + path.close() + break + case CMD_MOVE_TO: { + const x = dv.getFloat32(o, true) + const y = dv.getFloat32(o + 4, true) + o += 8 + path.moveTo(x, y) + break + } + case CMD_LINE_TO: { + const x = dv.getFloat32(o, true) + const y = dv.getFloat32(o + 4, true) + o += 8 + path.lineTo(x, y) + break + } + case CMD_CUBIC_TO: { + const x1 = dv.getFloat32(o, true) + const y1 = dv.getFloat32(o + 4, true) + const x2 = dv.getFloat32(o + 8, true) + const y2 = dv.getFloat32(o + 12, true) + const x = dv.getFloat32(o + 16, true) + const y = dv.getFloat32(o + 20, true) + o += 24 + path.cubicTo(x1, y1, x2, y2, x, y) + break + } + default: + return path + } + } + + path.setFillType(windingRule === 'EVENODD' ? ck.FillType.EvenOdd : ck.FillType.Winding) + return path +}