Merge pull request #465 from zackarychapple/codex/scale-target-aspect-ratio-instances

fix(fig): scale target-aspect instance subtrees
This commit is contained in:
Danila Poyarkov 2026-08-05 18:16:59 +03:00 committed by GitHub
commit e6ba419ed1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 176 additions and 11 deletions

View file

@ -49,6 +49,7 @@
### Fixed
- Scale proportion-constrained `.fig` instance geometry through fixed wrapper layers so imported logos and icons retain their intended size.
- 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, 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)

View file

@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-gr
import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
import { constrainedChildRect } from '@open-pencil/scene-graph/resize'
import { readEffectiveFigmaRawField } from '../source-metadata'
import { isFieldProtected } from './patches'
import { buildClonesMap } from './sync'
import type { OverrideContext } from './types'
@ -9,6 +10,19 @@ import { overrideCandidates } from './utils'
const MAX_CLONE_CHAIN_DEPTH = 10
interface ScaleDescendantAxes {
horizontal: boolean
vertical: boolean
}
interface InstanceScale {
basis: SceneNode
scaleThroughFixedWrappers: boolean
sx: number
sy: number
useCurrentChildAsSource: boolean
}
/**
* Apply SCALE constraint resizing to children of instances whose size
* differs from their component's original size, then propagate the
@ -22,14 +36,13 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
if (node.type !== 'INSTANCE' || !node.componentId) continue
const comp = graph.getNode(node.componentId)
if (!comp || comp.width <= 0 || comp.height <= 0) continue
const basis = resolveScaleBasis(graph, node, comp)
if (!basis) continue
const scale = resolveInstanceScale(graph, node, comp)
if (!scale) continue
positionPinnedAbsoluteChildren(ctx, node, basis)
positionPinnedAbsoluteChildren(ctx, node, scale.basis)
if (node.layoutMode !== 'NONE') continue
const sx = node.width / basis.width
const sy = node.height / basis.height
const { sx, sy } = scale
if (Math.abs(sx - 1) < 0.001 && Math.abs(sy - 1) < 0.001) continue
const figmaId = ctx.nodeIdToGuid.get(node.id)
@ -42,14 +55,53 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
sy,
scaled,
ctx.geometryOverrideNodes,
basis !== comp,
strokeScale
scale.useCurrentChildAsSource,
strokeScale,
scale.scaleThroughFixedWrappers
)
}
if (scaled.size > 0) propagateScaling(ctx, scaled)
}
function resolveInstanceScale(
graph: SceneGraph,
instance: SceneNode,
component: SceneNode
): InstanceScale | null {
const targetAspectRatio = resolveTargetAspectRatio(instance)
const resolvedBasis = resolveScaleBasis(graph, instance, component)
if (!targetAspectRatio && !resolvedBasis) return null
const basis = resolvedBasis ?? component
const scaleThroughFixedWrappers = targetAspectRatio !== null
return {
basis,
scaleThroughFixedWrappers,
sx: instance.width / basis.width,
sy: instance.height / basis.height,
useCurrentChildAsSource: basis !== component
}
}
function resolveTargetAspectRatio(instance: SceneNode): { width: number; height: number } | null {
const rawTarget = readEffectiveFigmaRawField(instance, 'targetAspectRatio')
if (!rawTarget || typeof rawTarget !== 'object' || !('value' in rawTarget)) return null
const value = rawTarget.value
if (!value || typeof value !== 'object' || !('x' in value) || !('y' in value)) return null
const { x, y } = value
if (
typeof x !== 'number' ||
typeof y !== 'number' ||
!Number.isFinite(x) ||
!Number.isFinite(y) ||
x <= 0 ||
y <= 0
) {
return null
}
return { width: x, height: y }
}
function isCloneOfSource(graph: SceneGraph, child: SceneNode, sourceId: string): boolean {
let current: SceneNode | undefined = child
for (let depth = 0; depth < MAX_CLONE_CHAIN_DEPTH && current?.componentId; depth++) {
@ -221,6 +273,40 @@ function scaledGeometryUpdates(
return updates
}
function scaleDescendantAxes(
graph: SceneGraph,
node: SceneNode,
cache: Map<string, ScaleDescendantAxes>
): ScaleDescendantAxes {
const cached = cache.get(node.id)
if (cached) return cached
const result: ScaleDescendantAxes = { horizontal: false, vertical: false }
for (const child of graph.getChildren(node.id)) {
const nested = scaleDescendantAxes(graph, child, cache)
result.horizontal ||= child.horizontalConstraint === 'SCALE' || nested.horizontal
result.vertical ||= child.verticalConstraint === 'SCALE' || nested.vertical
if (result.horizontal && result.vertical) break
}
cache.set(node.id, result)
return result
}
function childScaleAxes(
graph: SceneGraph,
child: SceneNode,
scaleThroughFixedWrappers: boolean,
cache: Map<string, ScaleDescendantAxes>
): ScaleDescendantAxes {
const descendantAxes = scaleDescendantAxes(graph, child, cache)
return {
horizontal:
child.horizontalConstraint === 'SCALE' ||
(scaleThroughFixedWrappers && descendantAxes.horizontal),
vertical:
child.verticalConstraint === 'SCALE' || (scaleThroughFixedWrappers && descendantAxes.vertical)
}
}
function scaleChildren(
graph: SceneGraph,
instance: SceneNode,
@ -230,7 +316,9 @@ function scaleChildren(
scaled: Set<string>,
geometryOverrideNodes: Set<string>,
useCurrentChildAsSource = false,
strokeScale?: number
strokeScale?: number,
scaleThroughFixedWrappers = false,
descendantScaleCache = new Map<string, ScaleDescendantAxes>()
): void {
const len = Math.min(instance.childIds.length, comp.childIds.length)
for (let i = 0; i < len; i++) {
@ -238,8 +326,9 @@ function scaleChildren(
const compChild = graph.getNode(comp.childIds[i])
if (!child || !compChild) continue
const hScale = child.horizontalConstraint === 'SCALE'
const vScale = child.verticalConstraint === 'SCALE'
const scaleAxes = childScaleAxes(graph, child, scaleThroughFixedWrappers, descendantScaleCache)
const hScale = scaleAxes.horizontal
const vScale = scaleAxes.vertical
if (!hScale && !vScale) continue
const updates: Partial<SceneNode> = {}
@ -272,7 +361,9 @@ function scaleChildren(
scaled,
geometryOverrideNodes,
useCurrentChildAsSource,
strokeScale
strokeScale,
scaleThroughFixedWrappers,
descendantScaleCache
)
}
}

View file

@ -156,6 +156,79 @@ describe('@open-pencil/fig instance interpretation', () => {
expect(placeholder).toMatchObject({ x: 16, y: 10, text: 'Placeholder' })
})
test('scales target-aspect instance geometry through fixed wrappers', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const component = graph.createNode('COMPONENT', pageId, { width: 310, height: 62 })
const wrapper = graph.createNode('FRAME', component.id, { width: 310, height: 61.214 })
const inset = graph.createNode('RECTANGLE', wrapper.id, {
x: 250,
y: 10,
width: 20,
height: 20,
horizontalConstraint: 'MAX',
verticalConstraint: 'MIN'
})
const vector = graph.createNode('VECTOR', wrapper.id, {
width: 56.392,
height: 61.214,
horizontalConstraint: 'SCALE',
verticalConstraint: 'SCALE'
})
const instance = graph.createNode('INSTANCE', pageId, {
width: 100,
height: 20,
componentId: component.id
})
instance.source.fig.rawNodeFields.targetAspectRatio = { value: { x: 310, y: 62 } }
populateAndApplyOverrides(graph, new Map(), new Map())
const scaledWrapper = graph.getChildren(instance.id)[0]
const scaledShape = graph.getChildren(scaledWrapper.id)[1]
const preservedInset = graph.getChildren(scaledWrapper.id)[0]
expect(scaledWrapper.width).toBeCloseTo(100)
expect(scaledWrapper.height).toBeCloseTo((61.214 * 20) / 62)
expect(preservedInset).toMatchObject({
x: inset.x,
y: inset.y,
width: inset.width,
height: inset.height
})
expect(scaledShape.width).toBeCloseTo((vector.width * 100) / 310)
expect(scaledShape.height).toBeCloseTo((vector.height * 20) / 62)
})
test('uses target-aspect metadata only to reach scale-constrained descendants', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const component = graph.createNode('COMPONENT', pageId, { width: 24, height: 24 })
const vector = graph.createNode('VECTOR', component.id, {
x: 9,
y: 3,
width: 6,
height: 6,
horizontalConstraint: 'SCALE',
verticalConstraint: 'SCALE'
})
const instance = graph.createNode('INSTANCE', pageId, {
width: 14,
height: 14,
componentId: component.id
})
instance.source.fig.rawNodeFields.targetAspectRatio = { value: { x: 32, y: 32 } }
populateAndApplyOverrides(graph, new Map(), new Map())
const scaledVector = graph.getChildren(instance.id)[0]
expect(scaledVector).toMatchObject({
x: (vector.x * 14) / 24,
y: (vector.y * 14) / 24,
width: (vector.width * 14) / 24,
height: (vector.height * 14) / 24
})
})
test('limits lazy population to required global propagation scans', () => {
const graph = new SceneGraph()
const activePage = graph.getPages()[0]