From 8f740ce09332f9f3f888c5aba36b3a0a5d8df58d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 4 Mar 2026 17:43:33 +0300 Subject: [PATCH] Fix vector rendering with pre-computed geometry and stroke outlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use fillGeometry/strokeGeometry blobs from .fig files for pixel-perfect vector rendering. These pre-computed outlines account for stroke alignment, caps, joins, and miter limits — eliminating white gaps between adjacent stroked shapes. For clipboard paste (where geometry blobs are unavailable), convert vectorNetwork paths to filled stroke outlines via CanvasKit path.stroke(). Apply derivedSymbolData transforms, sizes, and geometry during import so instance children render at the correct scale and position. When derivedSymbolData provides size but no geometry, scale inherited blobs proportionally. --- packages/core/src/clipboard.ts | 3 +- packages/core/src/index.ts | 2 + packages/core/src/kiwi-serialize.ts | 15 +++ packages/core/src/kiwi/codec.ts | 2 + packages/core/src/kiwi/fig-import.ts | 3 +- packages/core/src/kiwi/instance-overrides.ts | 65 +++++++++- packages/core/src/kiwi/kiwi-convert.ts | 30 ++++- packages/core/src/renderer.ts | 123 ++++++++++++++++--- packages/core/src/scene-graph.ts | 9 ++ packages/core/src/vector.ts | 107 ++++++++++++---- 10 files changed, 311 insertions(+), 48 deletions(-) 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 +}