From eeefd069e6814077bdd8a4399f004ddc7618fbc6 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 3 Aug 2026 20:17:32 +0300 Subject: [PATCH] fix(figma-api): expose node transforms - Return rotation and reflection matrices for relative and absolute transforms - Preserve authoritative imported FIG transforms until geometry is edited - Cover nested rotation and reflected vector behavior --- CHANGELOG.md | 2 +- .../core/src/figma-api/accessors/basic.ts | 55 ++++++++++++++--- packages/core/src/figma-api/proxy.ts | 1 + .../figma/api/absolute/position.test.ts | 59 +++++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e42262cb..32a612a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ ### Fixed - Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely. -- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444) +- Match Figma Plugin API vector path and network editing, including bounds, transforms, winding rules, region fills, validation, and handle mirroring. (#444) - Let AI and MCP tools create arbitrary vectors from SVG path data, validating input without leaving blank layers behind. (#440) - Improve AI design accuracy by exposing every supported shape, including visible stroke colors and weights in visual descriptions, and accepting supported inline SVG attributes without false warnings. (#445, #447, #448) - Restore Anthropic AI connections in the web app instead of failing with a browser endpoint error. (#438) diff --git a/packages/core/src/figma-api/accessors/basic.ts b/packages/core/src/figma-api/accessors/basic.ts index c04286367..de29a24a7 100644 --- a/packages/core/src/figma-api/accessors/basic.ts +++ b/packages/core/src/figma-api/accessors/basic.ts @@ -1,4 +1,4 @@ -import type { SceneNode } from '@open-pencil/scene-graph' +import { getNodeLocalMatrix, getWorldMatrix, type SceneNode } from '@open-pencil/scene-graph' import type { Rect } from '@open-pencil/scene-graph/primitives' import { @@ -10,6 +10,31 @@ import { } from '#core/figma-api/accessor-utils' import type { NodeProxyHost } from '#core/figma-api/proxy' +type FigmaTransform = [[number, number, number], [number, number, number]] + +const TRANSFORM_FIELDS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY']) + +function preservesRawTransform(node: SceneNode): boolean { + return !node.source.editedFields.some((field) => TRANSFORM_FIELDS.has(field)) +} + +function cleanTransformValue(value: number): number { + if (Math.abs(value) < 1e-12) return 0 + const nearestInteger = Math.round(value) + return Math.abs(value - nearestInteger) < 1e-12 ? nearestInteger : value +} + +function figmaTransform(matrix: number[]): FigmaTransform { + return [ + [ + cleanTransformValue(matrix[0]), + cleanTransformValue(matrix[1]), + cleanTransformValue(matrix[2]) + ], + [cleanTransformValue(matrix[3]), cleanTransformValue(matrix[4]), cleanTransformValue(matrix[5])] + ] +} + export function installBasicNodeProxyAccessors( prototype: object, internals: NodeProxyInternals @@ -66,19 +91,33 @@ export function installBasicNodeProxyAccessors( }, rotation: { get(this: ProxyThis): number { - return raw(this, internals).rotation + const node = raw(this, internals) + const sourceTransform = node.source.fig.rawTransform + if (sourceTransform && preservesRawTransform(node)) { + return Math.atan2(-sourceTransform.m10, sourceTransform.m00) * (180 / Math.PI) + } + return node.rotation }, set(this: ProxyThis, value: number) { graph(this, internals).updateNode(nodeId(this, internals), { rotation: value }) } }, + relativeTransform: { + get(this: ProxyThis): FigmaTransform { + const node = raw(this, internals) + const sourceTransform = node.source.fig.rawTransform + if (sourceTransform && preservesRawTransform(node)) { + return [ + [sourceTransform.m00, sourceTransform.m01, sourceTransform.m02], + [sourceTransform.m10, sourceTransform.m11, sourceTransform.m12] + ] + } + return figmaTransform(getNodeLocalMatrix(node)) + } + }, absoluteTransform: { - get(this: ProxyThis): [[number, number, number], [number, number, number]] { - const pos = graph(this, internals).getAbsolutePosition(nodeId(this, internals)) - return [ - [1, 0, pos.x], - [0, 1, pos.y] - ] + get(this: ProxyThis): FigmaTransform { + return figmaTransform(getWorldMatrix(raw(this, internals), graph(this, internals))) } }, absoluteBoundingBox: { diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 4a311c92b..1b81726ec 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -62,6 +62,7 @@ export class FigmaNodeProxy { declare readonly width: number declare readonly height: number declare rotation: number + declare readonly relativeTransform: [[number, number, number], [number, number, number]] declare resize: (width: number, height: number) => void declare resizeWithoutConstraints: (width: number, height: number) => void declare readonly absoluteTransform: [[number, number, number], [number, number, number]] diff --git a/tests/engine/figma/api/absolute/position.test.ts b/tests/engine/figma/api/absolute/position.test.ts index f93e0dc9f..e83dd2795 100644 --- a/tests/engine/figma/api/absolute/position.test.ts +++ b/tests/engine/figma/api/absolute/position.test.ts @@ -3,6 +3,65 @@ import { describe, expect, test } from 'bun:test' import { createAPI } from '../helpers' describe('absolute position', () => { + test('relative and absolute transforms include rotation and reflection', () => { + const api = createAPI() + const parent = api.createFrame() + parent.x = 100 + parent.y = 200 + const child = api.createRectangle() + parent.appendChild(child) + child.x = 10 + child.y = 20 + child.resize(50, 30) + child.rotation = 90 + + expect(child.relativeTransform).toEqual([ + [0, -1, 50], + [1, 0, 10] + ]) + expect(child.absoluteTransform).toEqual([ + [0, -1, 150], + [1, 0, 210] + ]) + }) + + test('exposes preserved FIG reflection transforms and Figma rotation', () => { + const api = createAPI() + const node = api.createVector() + const raw = api.graph.getNode(node.id) + expect(raw).toBeDefined() + if (!raw) return + api.graph.updateNode(node.id, { + x: 5, + y: 8, + width: 14, + height: 8, + rotation: -180, + flipX: true, + source: { + ...raw.source, + format: 'fig', + id: '94:5463', + fig: { + ...raw.source.fig, + rawTransform: { m00: 1, m01: 0, m02: 5, m10: 0, m11: -1, m12: 16 } + } + } + }) + + expect(node.x).toBe(5) + expect(node.y).toBe(8) + expect(node.rotation).toBe(-0) + expect(node.relativeTransform).toEqual([ + [1, 0, 5], + [0, -1, 16] + ]) + expect(node.absoluteTransform).toEqual([ + [1, 0, 5], + [0, -1, 16] + ]) + }) + test('absoluteBoundingBox accounts for nesting', () => { const api = createAPI() const parent = api.createFrame()