From e02782d1b558006a7812cea8dcd0d89a35740924 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 26 May 2026 20:25:00 +0300 Subject: [PATCH] test(canvas): record Figma visual oracle diffs --- packages/core/src/canvas/fills.ts | 13 +++- packages/docs/development/roadmap.md | 2 +- scripts/visual-compare.ts | 6 +- .../canvas/schema-fill-fallback.test.ts | 5 +- .../visual-comparison-report.json | 64 +++++++++++++++++++ 5 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/figma-oracles/visual-comparison-report.json diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index da8d7865e..f014cc095 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -162,6 +162,15 @@ function recordPatternSource( return picture } +function resolvePatternSource(graph: SceneGraph, sourceId: string): SceneNode | null { + const direct = graph.getNode(sourceId) + if (direct) return direct + for (const node of graph.getAllNodes()) { + if (node.source.id === sourceId) return node + } + return null +} + function applyPatternFill( r: SkiaRenderer, fill: Fill, @@ -169,8 +178,8 @@ function applyPatternFill( graph: SceneGraph ): boolean { const sourceId = fill.sourceNodeId - if (!sourceId || sourceId === node.id) return false - const source = graph.getNode(sourceId) + if (!sourceId || sourceId === node.id || sourceId === node.source.id) return false + const source = resolvePatternSource(graph, sourceId) if (!source || source.width <= 0 || source.height <= 0) return false const layout = patternTileLayout(source, fill) diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 595bcaa58..91786c2cb 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -19,7 +19,7 @@ OpenPencil is moving toward production-grade Figma compatibility while keeping d ### Figma fidelity - Preserve and round-trip more Figma metadata safely. -- Add visual regression coverage for full multi-page `.fig` documents. `scripts/export-fixture-visuals.ts` exports current smoke fixture pages to `/tmp` for manual comparison without committing large images. +- Add visual regression coverage for full multi-page `.fig` documents. `scripts/export-fixture-visuals.ts` exports current smoke fixture pages to `/tmp` for manual comparison without committing large images; `tests/fixtures/figma-oracles/visual-comparison-report.json` records the current Figma-vs-OpenPencil oracle diff findings. - Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures. - Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations. diff --git a/scripts/visual-compare.ts b/scripts/visual-compare.ts index 6858081b8..f99b8a24c 100644 --- a/scripts/visual-compare.ts +++ b/scripts/visual-compare.ts @@ -23,7 +23,7 @@ import { SkiaRenderer } from '@open-pencil/core/canvas' import { renderNodesToImage, initCanvasKit } from '@open-pencil/core/io' import { computeAllLayouts } from '@open-pencil/core/layout' import { SceneGraph } from '@open-pencil/core/scene-graph' -import { loadFont } from '@open-pencil/core/text' +import { fontManager } from '@open-pencil/core/text' import { parseFigmaClipboard, importClipboardNodes } from '#core/clipboard' @@ -81,7 +81,7 @@ async function runWithNodeId(nodeId: string) { console.log('📋 Exporting clipboard data from Figma…') // Select the node, copy, read clipboard, render with our engine - await $`figma-use eval ${`(() => { const n = figma.getNodeById('${nodeId}'); if (n) { figma.currentPage.selection = [n]; } })()`}`.quiet() + await $`figma-use eval ${`(() => { const n = figma.getNodeById('${nodeId}'); if (!n) return; let page = n.parent; while (page && page.type !== 'PAGE') page = page.parent; if (page) { figma.currentPage = page; page.selection = [n]; } })()`}`.quiet() await Bun.sleep(200) await $`osascript -e 'tell application "Figma" to activate'`.quiet() await Bun.sleep(300) @@ -118,7 +118,7 @@ async function renderOurs(html: string) { if (node.fontFamily) families.add(node.fontFamily) } for (const family of families) { - await loadFont(family) + await fontManager.loadFont(family) } const ck = await initCanvasKit() diff --git a/tests/engine/render/canvas/schema-fill-fallback.test.ts b/tests/engine/render/canvas/schema-fill-fallback.test.ts index 230aa26b1..557722c4d 100644 --- a/tests/engine/render/canvas/schema-fill-fallback.test.ts +++ b/tests/engine/render/canvas/schema-fill-fallback.test.ts @@ -40,7 +40,7 @@ function createRenderer() { } as SkiaRenderer } -const node = { id: '1:2', width: 100, height: 100 } as SceneNode +const node = { id: '1:2', source: { id: '' }, width: 100, height: 100 } as SceneNode describe('schema fill fallback rendering', () => { test('renders pattern fills from referenced source nodes', () => { @@ -58,10 +58,11 @@ describe('schema fill fallback rendering', () => { } ] }) + source.source.id = '12:34' const renderer = createRenderer() const fill: Fill = { type: 'PATTERN', - sourceNodeId: source.id, + sourceNodeId: '12:34', patternSpacing: { x: 0.25, y: 0.4 }, color: { r: 0.2, g: 0.3, b: 0.4, a: 0.8 }, opacity: 1, diff --git a/tests/fixtures/figma-oracles/visual-comparison-report.json b/tests/fixtures/figma-oracles/visual-comparison-report.json new file mode 100644 index 000000000..6f03d5561 --- /dev/null +++ b/tests/fixtures/figma-oracles/visual-comparison-report.json @@ -0,0 +1,64 @@ +{ + "source": { + "tool": "scripts/visual-compare.ts", + "captured": "2026-05-22", + "outputRoot": "/tmp/open-pencil-oracles" + }, + "comparisons": [ + { + "name": "rich-text-decorations", + "nodeId": "296488:2238", + "output": "/tmp/open-pencil-oracles/rich-text/decorations-scale1", + "figmaSize": "493x41", + "openPencilSizeBeforeResize": "493x23", + "differentPixels": 10611, + "differentPercent": 52.5, + "finding": "OpenPencil imports style-run decoration metadata, but exact Figma line metrics/leading trim still differ substantially." + }, + { + "name": "rich-text-offset-skip-ink", + "nodeId": "296619:2252", + "output": "/tmp/open-pencil-oracles/rich-text/offset-skipink-scale1", + "figmaSize": "212x32", + "openPencilSizeBeforeResize": "212x39", + "differentPixels": 4154, + "differentPercent": 61.23, + "finding": "Underline offset and skip-ink now round-trip, but CanvasKit rendering does not expose matching underline offset/skip-ink controls." + }, + { + "name": "nested-luminance-mask", + "nodeId": "296616:2249", + "output": "/tmp/open-pencil-oracles/masks/nested-scale1", + "figmaSize": "240x120", + "openPencilSizeBeforeResize": "240x120", + "differentPixels": 12944, + "differentPercent": 44.94, + "finding": "Visual output is structurally close, but colors/antialiasing differ enough for a high raw AE score." + }, + { + "name": "pattern-target-without-source", + "nodeId": "296519:2245", + "output": "/tmp/open-pencil-oracles/pattern-scale1", + "figmaSize": "160x100", + "openPencilSizeBeforeResize": "160x100", + "differentPixels": 16000, + "differentPercent": 100, + "finding": "Copying/exporting only a pattern-filled target does not include its source node in clipboard data; OpenPencil must fall back without whole-document context." + }, + { + "name": "pattern-frame-with-hidden-source", + "nodeId": "296731:2254", + "output": "/tmp/open-pencil-oracles/pattern-frame-scale1-after-sourceid", + "figmaSize": "180x100", + "openPencilSizeBeforeResize": "180x100", + "differentPixels": 9102, + "differentPercent": 50.57, + "finding": "Resolving pattern sources by imported Figma source id works in OpenPencil, but this hidden-source frame oracle renders blank in Figma and is not a parity baseline." + } + ], + "nextActions": [ + "Create a Figma pattern oracle where the source is included in the copied subtree and Figma still renders the pattern target.", + "Use perceptual/color-tolerant image diff for mask comparisons instead of raw absolute-error counts.", + "Tune rich text line metrics before treating underline placement diffs as actionable." + ] +}