diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index 0d7516eff..8eb569a55 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -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 } diff --git a/packages/fig/src/instance-overrides/patches/apply.ts b/packages/fig/src/instance-overrides/patches/apply.ts index 913badecb..4a54211bd 100644 --- a/packages/fig/src/instance-overrides/patches/apply.ts +++ b/packages/fig/src/instance-overrides/patches/apply.ts @@ -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) diff --git a/packages/fig/src/instance-overrides/symbol/props.ts b/packages/fig/src/instance-overrides/symbol/props.ts index 6a282ac06..631644f31 100644 --- a/packages/fig/src/instance-overrides/symbol/props.ts +++ b/packages/fig/src/instance-overrides/symbol/props.ts @@ -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, updates: Partial 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 diff --git a/packages/fig/src/instance-overrides/sync/fields.ts b/packages/fig/src/instance-overrides/sync/fields.ts index 0a0ce9807..102fa931f 100644 --- a/packages/fig/src/instance-overrides/sync/fields.ts +++ b/packages/fig/src/instance-overrides/sync/fields.ts @@ -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 +): void { + const prefix = `${key}/` + const currentBindings = updates.boundVariables ?? target.boundVariables + const paintFields = Object.keys(currentBindings).filter((field) => field.startsWith(prefix)) + const bindings: Record = 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) } } } diff --git a/packages/fig/src/instance-overrides/types.ts b/packages/fig/src/instance-overrides/types.ts index c1b6668b7..830c9c8c5 100644 --- a/packages/fig/src/instance-overrides/types.ts +++ b/packages/fig/src/instance-overrides/types.ts @@ -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 diff --git a/packages/fig/src/node-change/paint.ts b/packages/fig/src/node-change/paint.ts index a23eb4933..a1cb25d82 100644 --- a/packages/fig/src/node-change/paint.ts +++ b/packages/fig/src/node-change/paint.ts @@ -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), - weight: weight ?? 1, - opacity: p.opacity ?? 1, - visible: p.visible ?? true, - align: strokeAlign, - cap: cap ?? 'NONE', - join: join ?? 'MITER', - dashPattern: dashPattern ?? [] - })) + return paints.map((p) => { + const { color, opacity } = resolvedPaintColor(p) + return { + color, + weight: weight ?? 1, + opacity, + visible: p.visible ?? true, + align: strokeAlign, + cap: cap ?? 'NONE', + join: join ?? 'MITER', + dashPattern: dashPattern ?? [] + } + }) } export function convertEffects(effects?: KiwiEffect[]): Effect[] { diff --git a/packages/fig/tests/node-change.test.ts b/packages/fig/tests/node-change.test.ts index 0f3d9894e..5d8755780 100644 --- a/packages/fig/tests/node-change.test.ts +++ b/packages/fig/tests/node-change.test.ts @@ -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: [ diff --git a/tests/engine/io/fig/import/edge-cases.test.ts b/tests/engine/io/fig/import/edge-cases.test.ts index b705f1003..e4eaa4aaa 100644 --- a/tests/engine/io/fig/import/edge-cases.test.ts +++ b/tests/engine/io/fig/import/edge-cases.test.ts @@ -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' ) diff --git a/tests/engine/io/fig/import/field-protection.test.ts b/tests/engine/io/fig/import/field-protection.test.ts index a4682dd75..688eeb3d4 100644 --- a/tests/engine/io/fig/import/field-protection.test.ts +++ b/tests/engine/io/fig/import/field-protection.test.ts @@ -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', () => { diff --git a/tests/engine/io/fig/roundtrip/exhaustive.test.ts b/tests/engine/io/fig/roundtrip/exhaustive.test.ts index eda3c7306..9ee63e06f 100644 --- a/tests/engine/io/fig/roundtrip/exhaustive.test.ts +++ b/tests/engine/io/fig/roundtrip/exhaustive.test.ts @@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [ thumbnailHeight: 239, imageCount: 3, figKiwiVersion: 101, - g1ExportSize: 596992, - g2ExportSize: 596992 + g1ExportSize: 596973, + g2ExportSize: 596973 } ]