fix(fig): scale target-aspect instance subtrees
- Use preserved targetAspectRatio metadata as the instance scale basis - Scale nested geometry through fixed wrapper layers - Cover proportion-constrained logo imports with a regression test
This commit is contained in:
parent
85abfd5d21
commit
fcfe259f26
|
|
@ -49,8 +49,9 @@
|
|||
|
||||
### 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, transforms, winding rules, region fills, validation, and handle mirroring. (#444)
|
||||
- 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, 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)
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -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,14 @@ import { overrideCandidates } from './utils'
|
|||
|
||||
const MAX_CLONE_CHAIN_DEPTH = 10
|
||||
|
||||
interface InstanceScale {
|
||||
basis: SceneNode
|
||||
scaleEntireSubtree: 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 +31,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 +50,53 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
|
|||
sy,
|
||||
scaled,
|
||||
ctx.geometryOverrideNodes,
|
||||
basis !== comp,
|
||||
strokeScale
|
||||
scale.useCurrentChildAsSource,
|
||||
strokeScale,
|
||||
scale.scaleEntireSubtree
|
||||
)
|
||||
}
|
||||
|
||||
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 scaleEntireSubtree = targetAspectRatio !== null
|
||||
return {
|
||||
basis,
|
||||
scaleEntireSubtree,
|
||||
sx: instance.width / (targetAspectRatio?.width ?? basis.width),
|
||||
sy: instance.height / (targetAspectRatio?.height ?? basis.height),
|
||||
useCurrentChildAsSource: scaleEntireSubtree || 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++) {
|
||||
|
|
@ -230,7 +277,8 @@ function scaleChildren(
|
|||
scaled: Set<string>,
|
||||
geometryOverrideNodes: Set<string>,
|
||||
useCurrentChildAsSource = false,
|
||||
strokeScale?: number
|
||||
strokeScale?: number,
|
||||
scaleEntireSubtree = false
|
||||
): void {
|
||||
const len = Math.min(instance.childIds.length, comp.childIds.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
|
|
@ -238,8 +286,8 @@ function scaleChildren(
|
|||
const compChild = graph.getNode(comp.childIds[i])
|
||||
if (!child || !compChild) continue
|
||||
|
||||
const hScale = child.horizontalConstraint === 'SCALE'
|
||||
const vScale = child.verticalConstraint === 'SCALE'
|
||||
const hScale = scaleEntireSubtree || child.horizontalConstraint === 'SCALE'
|
||||
const vScale = scaleEntireSubtree || child.verticalConstraint === 'SCALE'
|
||||
if (!hScale && !vScale) continue
|
||||
|
||||
const updates: Partial<SceneNode> = {}
|
||||
|
|
@ -272,7 +320,8 @@ function scaleChildren(
|
|||
scaled,
|
||||
geometryOverrideNodes,
|
||||
useCurrentChildAsSource,
|
||||
strokeScale
|
||||
strokeScale,
|
||||
scaleEntireSubtree
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,34 @@ 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 })
|
||||
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)[0]
|
||||
expect(scaledWrapper.width).toBeCloseTo(100)
|
||||
expect(scaledWrapper.height).toBeCloseTo((61.214 * 20) / 62)
|
||||
expect(scaledShape.width).toBeCloseTo((56.392 * 100) / 310)
|
||||
expect(scaledShape.height).toBeCloseTo((61.214 * 20) / 62)
|
||||
})
|
||||
|
||||
test('limits lazy population to required global propagation scans', () => {
|
||||
const graph = new SceneGraph()
|
||||
const activePage = graph.getPages()[0]
|
||||
|
|
|
|||
Loading…
Reference in a new issue