Merge branch 'fix-vector-rendering' into compatibility-fixes

This commit is contained in:
Danila Poyarkov 2026-03-04 19:25:45 +03:00
commit 8a6746fa9d
10 changed files with 311 additions and 48 deletions

View file

@ -218,7 +218,8 @@ export function importClipboardNodes(
populateAndApplyOverrides(
graph,
guidMap as unknown as Map<string, InstanceNodeChange>,
created
created,
blobs
)
for (const figmaId of internalTopLevel) {

View file

@ -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

View file

@ -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++) {

View file

@ -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

View file

@ -182,7 +182,8 @@ export function importNodeChanges(
populateAndApplyOverrides(
graph,
changeMap as unknown as Map<string, InstanceNodeChange>,
guidToNodeId
guidToNodeId,
blobs
)
// Ensure at least one page exists

View file

@ -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<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>
guidToNodeId: Map<string, string>,
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<SceneNode> = {}
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)
}
}
}
}

View file

@ -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<string, string> {
const bindings: Record<string, string> = {}
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<string, number> | undefined),
strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap,
strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin,

View file

@ -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<string, CKImage>()
private vectorPathCache = new Map<string, Path>()
private vectorPathCache = new Map<string, Path[]>()
private fillGeometryCache = new Map<string, Path[]>()
private strokeGeometryCache = new Map<string, Path[]>()
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<string, EmbindEnumEntity> = {
NONE: this.ck.StrokeCap.Butt,
ROUND: this.ck.StrokeCap.Round,
SQUARE: this.ck.StrokeCap.Square
}
const joinMap: Record<string, EmbindEnumEntity> = {
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()

View file

@ -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<SceneNode> = {}):
layoutGrow: 0,
layoutAlignSelf: 'AUTO',
vectorNetwork: null,
fillGeometry: [],
strokeGeometry: [],
arcData: null,
textAlignVertical: 'TOP',
textAutoResize: 'NONE',

View file

@ -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<number>()
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<number>()
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
}