fix(fig): preserve paint variable overrides

- Carry paint bindings through symbol overrides and clone synchronization

- Keep explicit instance strokes and resolved variable opacity intact
This commit is contained in:
Danila Poyarkov 2026-07-22 15:51:46 +03:00
parent 8c31d5ed87
commit 8dfed26d16
10 changed files with 146 additions and 19 deletions

View file

@ -66,7 +66,9 @@ function buildKiwiPropertyNodes(
(nc.cornerRadius !== undefined || nc.rectangleCornerRadiiIndependent !== undefined) &&
node.cornerRadius !== comp.cornerRadius
const hasDiffVisible = nc.visible === false && comp.visible
if (hasDiffRadius || hasDiffVisible) result.add(nodeId)
const hasDiffFills = nc.fillPaints !== undefined && !isEqual(node.fills, comp.fills)
const hasDiffStrokes = nc.strokePaints !== undefined && !isEqual(node.strokes, comp.strokes)
if (hasDiffRadius || hasDiffVisible || hasDiffFills || hasDiffStrokes) result.add(nodeId)
}
return result
}

View file

@ -38,6 +38,9 @@ export function applyOverridePatch(ctx: OverrideContext, patch: OverridePatch):
const target = ctx.graph.getNode(patch.targetId)
if (target) {
const props = patch.props
if (props.boundVariables) {
props.boundVariables = { ...target.boundVariables, ...props.boundVariables }
}
preserveStrokeShapeProps(target, props)
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(patch.targetId, props))
protectPatchProps(ctx.protectedFields, patch.targetId, props)

View file

@ -10,7 +10,8 @@ import {
mapArcData,
importStyleRuns,
convertStrokes,
convertEffects
convertEffects,
extractBoundVariables
} from '@open-pencil/fig/node-change'
import type { NodeChange, Paint, Effect as KiwiEffect } from '@open-pencil/kiwi/fig/codec'
import type { SceneNode, ArcData, TextAutoResize } from '@open-pencil/scene-graph'
@ -31,6 +32,10 @@ function applyOverridePaints(ov: Record<string, unknown>, updates: Partial<Scene
ov.strokeWeight as number | undefined,
ov.strokeAlign as string | undefined
)
if (ov.fillPaints != null || ov.strokePaints != null) {
const bindings = extractBoundVariables(ov as NodeChange)
if (Object.keys(bindings).length > 0) updates.boundVariables = bindings
}
if (ov.effects != null) updates.effects = convertEffects(ov.effects as KiwiEffect[])
if (ov.visible != null) updates.visible = ov.visible as boolean
if (ov.opacity != null) updates.opacity = ov.opacity as number

View file

@ -1,3 +1,5 @@
import { omit } from 'es-toolkit/object'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import {
copyEffects,
@ -94,10 +96,27 @@ function assignCopiedUpdate(
}
}
function syncPaintBindings(
key: 'fills' | 'strokes',
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>
): void {
const prefix = `${key}/`
const currentBindings = updates.boundVariables ?? target.boundVariables
const paintFields = Object.keys(currentBindings).filter((field) => field.startsWith(prefix))
const bindings: Record<string, string> = omit(currentBindings, paintFields)
for (const [field, variableId] of Object.entries(source.boundVariables)) {
if (field.startsWith(prefix)) bindings[field] = variableId
}
updates.boundVariables = bindings
}
function copiedSync(key: CopiedSyncKey, field: ProtectedField): SyncFn {
return (source, target, updates, protections) => {
if (!hasSameCopySource(source[key], target[key]) && canSync(protections, target.id, field)) {
assignCopiedUpdate(key, source, updates)
if (key === 'fills' || key === 'strokes') syncPaintBindings(key, source, target, updates)
}
}
}

View file

@ -83,6 +83,7 @@ export interface InstanceNodeChange {
componentPropDefs?: ComponentPropDef[]
styleType?: string
fillPaints?: NodeChange['fillPaints']
strokePaints?: NodeChange['strokePaints']
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeWeight?: number

View file

@ -47,11 +47,21 @@ function resolveColorVar(paint: Paint): Color | undefined {
return variableColorResolver(alias) ?? undefined
}
function resolvedPaintColor(paint: Paint): { color: Color; opacity: number } {
const resolved = resolveColorVar(paint)
if (!resolved) return { color: convertColor(paint.color), opacity: paint.opacity ?? 1 }
return {
color: { ...resolved, a: paint.color?.a ?? 1 },
opacity: paint.opacity ?? resolved.a
}
}
function convertBaseFill(p: Paint): Fill {
const { color, opacity } = resolvedPaintColor(p)
return {
type: p.type as FillType,
color: convertColor(resolveColorVar(p) ?? p.color),
opacity: p.opacity ?? 1,
color,
opacity,
visible: p.visible ?? true,
blendMode: (p.blendMode ?? 'NORMAL') as BlendMode
}
@ -119,16 +129,19 @@ export function convertStrokes(
if (align === 'INSIDE') strokeAlign = 'INSIDE'
else if (align === 'OUTSIDE') strokeAlign = 'OUTSIDE'
return paints.map((p) => ({
color: convertColor(resolveColorVar(p) ?? p.color),
return paints.map((p) => {
const { color, opacity } = resolvedPaintColor(p)
return {
color,
weight: weight ?? 1,
opacity: p.opacity ?? 1,
opacity,
visible: p.visible ?? true,
align: strokeAlign,
cap: cap ?? 'NONE',
join: join ?? 'MITER',
dashPattern: dashPattern ?? []
}))
}
})
}
export function convertEffects(effects?: KiwiEffect[]): Effect[] {

View file

@ -8,9 +8,11 @@ import {
convertFontFeatures,
convertLetterSpacing,
convertLineHeight,
convertStrokes,
decodeVectorNetworkBlob,
encodeVectorNetworkBlob,
mapTextDecoration
mapTextDecoration,
setVariableColorResolver
} from '../src/node-change'
describe('@open-pencil/fig NodeChange policy', () => {
@ -45,6 +47,27 @@ describe('@open-pencil/fig NodeChange policy', () => {
})
})
test('keeps resolved variable alpha in paint opacity', () => {
setVariableColorResolver(() => ({ r: 1, g: 0, b: 0, a: 0.4 }))
try {
const paint = {
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
colorVar: { value: { alias: { guid: { sessionID: 1, localID: 2 } } } }
}
expect(convertFills([paint])[0]).toMatchObject({
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 0.4
})
expect(convertStrokes([paint])[0]).toMatchObject({
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 0.4
})
} finally {
setVariableColorResolver(null)
}
})
test('round-trips vector network blobs with handle mirroring', () => {
const network = {
vertices: [

View file

@ -341,6 +341,23 @@ describe('edge cases', () => {
guidPath: { guids: [{ sessionID: 90, localID: 61 }] },
overriddenSymbolID: { sessionID: 1, localID: 3 }
},
{
guidPath: {
guids: [
{ sessionID: 90, localID: 61 },
{ sessionID: 90, localID: 31 }
]
},
fillPaints: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
colorVar: {
value: { alias: { guid: { sessionID: 2, localID: 5 } } }
}
}
]
},
{
guidPath: {
guids: [
@ -395,6 +412,9 @@ describe('edge cases', () => {
const iconChildren = graph.getChildren(iconClone.id)
expect(iconChildren).toHaveLength(3)
expect(iconChildren.map((c) => c.name).sort()).toEqual(['Icon label', 'PathB1', 'PathB2'])
expect(iconChildren.find((child) => child.name === 'PathB1')?.boundVariables).toMatchObject({
'fills/0/color': '2:5'
})
expect(iconChildren.find((child) => child.name === 'Icon label')?.text).toBe(
'Changed after swap'
)

View file

@ -1,10 +1,12 @@
import { describe, expect, test } from 'bun:test'
import { importNodeChanges } from '@open-pencil/core'
import {
protectField,
syncNodeProps,
type ProtectionMap
} from '@open-pencil/fig/instance-overrides'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { Fill, Stroke } from '@open-pencil/scene-graph'
@ -51,15 +53,50 @@ const blueStroke: Stroke = {
}
describe('fig import override field protection', () => {
test('explicit instance strokes survive component synchronization', () => {
const graph = importNodeChanges([
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document' } as NodeChange,
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page'
} as NodeChange,
{
guid: { sessionID: 1, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'Button',
strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1 }]
} as NodeChange,
{
guid: { sessionID: 1, localID: 2 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'INSTANCE',
name: 'Button instance',
symbolData: { symbolID: { sessionID: 1, localID: 1 } },
strokePaints: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.4 }]
} as NodeChange
])
const instance = graph
.getChildren(graph.getPages()[0].id)
.find((node) => node.type === 'INSTANCE')
expect(instance?.strokes[0]?.color).toEqual({ r: 1, g: 0, b: 0, a: 1 })
expect(instance?.strokes[0]?.opacity).toBe(0.4)
})
test('protected text still inherits fills', () => {
const graph = new SceneGraph()
const source = graph.createNode('TEXT', pageId(graph), {
text: 'Source',
fills: [redFill]
fills: [redFill],
boundVariables: { 'fills/0/color': 'source-color' }
})
const target = graph.createNode('TEXT', pageId(graph), {
text: 'Override',
fills: [blueFill]
fills: [blueFill],
boundVariables: { 'fills/0/color': 'target-color', width: 'target-width' }
})
const protections: ProtectionMap = new Map()
protectField(protections, target.id, 'text')
@ -69,6 +106,10 @@ describe('fig import override field protection', () => {
const synced = graph.getNode(target.id)
expect(synced?.text).toBe('Override')
expect(synced?.fills[0]?.color).toEqual(redFill.color)
expect(synced?.boundVariables).toEqual({
'fills/0/color': 'source-color',
width: 'target-width'
})
})
test('protected strokes still inherit visibility', () => {

View file

@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [
thumbnailHeight: 239,
imageCount: 3,
figKiwiVersion: 101,
g1ExportSize: 596992,
g2ExportSize: 596992
g1ExportSize: 596973,
g2ExportSize: 596973
}
]