From fcfe259f26ad9d523be57d01732036a0f56d6dcb Mon Sep 17 00:00:00 2001 From: Zack Chapple Date: Mon, 3 Aug 2026 16:09:02 -0400 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 5 +- .../fig/src/instance-overrides/constraints.ts | 71 ++++++++++++++++--- packages/fig/tests/instance-overrides.test.ts | 28 ++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a612a28..1d7243a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/fig/src/instance-overrides/constraints.ts b/packages/fig/src/instance-overrides/constraints.ts index 7f1b87613..f9ac802b5 100644 --- a/packages/fig/src/instance-overrides/constraints.ts +++ b/packages/fig/src/instance-overrides/constraints.ts @@ -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, geometryOverrideNodes: Set, 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 = {} @@ -272,7 +320,8 @@ function scaleChildren( scaled, geometryOverrideNodes, useCurrentChildAsSource, - strokeScale + strokeScale, + scaleEntireSubtree ) } } diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index c034ab972..c98ff8d0d 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -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] From 77263c1d48df5dabf64afe87685c5530b0b87168 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 5 Aug 2026 18:00:52 +0300 Subject: [PATCH 2/2] fix(fig): limit target-aspect scaling - Traverse fixed wrappers only to reach descendants with SCALE constraints - Preserve fixed siblings and use component geometry as the scale basis - Cover target-aspect icons without changing the Preline geometry baseline --- CHANGELOG.md | 4 +- .../fig/src/instance-overrides/constraints.ts | 64 +++++++++++++++---- packages/fig/tests/instance-overrides.test.ts | 53 +++++++++++++-- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7243a6a..3ab1f11df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,8 +50,8 @@ ### 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, 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 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) - 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/fig/src/instance-overrides/constraints.ts b/packages/fig/src/instance-overrides/constraints.ts index f9ac802b5..d14a06c1e 100644 --- a/packages/fig/src/instance-overrides/constraints.ts +++ b/packages/fig/src/instance-overrides/constraints.ts @@ -10,9 +10,14 @@ import { overrideCandidates } from './utils' const MAX_CLONE_CHAIN_DEPTH = 10 +interface ScaleDescendantAxes { + horizontal: boolean + vertical: boolean +} + interface InstanceScale { basis: SceneNode - scaleEntireSubtree: boolean + scaleThroughFixedWrappers: boolean sx: number sy: number useCurrentChildAsSource: boolean @@ -52,7 +57,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void { ctx.geometryOverrideNodes, scale.useCurrentChildAsSource, strokeScale, - scale.scaleEntireSubtree + scale.scaleThroughFixedWrappers ) } @@ -68,13 +73,13 @@ function resolveInstanceScale( const resolvedBasis = resolveScaleBasis(graph, instance, component) if (!targetAspectRatio && !resolvedBasis) return null const basis = resolvedBasis ?? component - const scaleEntireSubtree = targetAspectRatio !== null + const scaleThroughFixedWrappers = targetAspectRatio !== null return { basis, - scaleEntireSubtree, - sx: instance.width / (targetAspectRatio?.width ?? basis.width), - sy: instance.height / (targetAspectRatio?.height ?? basis.height), - useCurrentChildAsSource: scaleEntireSubtree || basis !== component + scaleThroughFixedWrappers, + sx: instance.width / basis.width, + sy: instance.height / basis.height, + useCurrentChildAsSource: basis !== component } } @@ -268,6 +273,40 @@ function scaledGeometryUpdates( return updates } +function scaleDescendantAxes( + graph: SceneGraph, + node: SceneNode, + cache: Map +): 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 +): 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, @@ -278,7 +317,8 @@ function scaleChildren( geometryOverrideNodes: Set, useCurrentChildAsSource = false, strokeScale?: number, - scaleEntireSubtree = false + scaleThroughFixedWrappers = false, + descendantScaleCache = new Map() ): void { const len = Math.min(instance.childIds.length, comp.childIds.length) for (let i = 0; i < len; i++) { @@ -286,8 +326,9 @@ function scaleChildren( const compChild = graph.getNode(comp.childIds[i]) if (!child || !compChild) continue - const hScale = scaleEntireSubtree || child.horizontalConstraint === 'SCALE' - const vScale = scaleEntireSubtree || 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 = {} @@ -321,7 +362,8 @@ function scaleChildren( geometryOverrideNodes, useCurrentChildAsSource, strokeScale, - scaleEntireSubtree + scaleThroughFixedWrappers, + descendantScaleCache ) } } diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index c98ff8d0d..d00ed6616 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -161,7 +161,15 @@ describe('@open-pencil/fig instance interpretation', () => { 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, { + 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', @@ -177,11 +185,48 @@ describe('@open-pencil/fig instance interpretation', () => { populateAndApplyOverrides(graph, new Map(), new Map()) const scaledWrapper = graph.getChildren(instance.id)[0] - const scaledShape = graph.getChildren(scaledWrapper.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(scaledShape.width).toBeCloseTo((56.392 * 100) / 310) - expect(scaledShape.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', () => {