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
This commit is contained in:
parent
2ec58ae3eb
commit
eeefd069e6
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue