* feat: show controls in the demo and tidy the component panels The demo gains a Controls page: a switch, checkbox, slider, tabs, text field, and a button with interaction states, written as Reka-named design JSX, and a settings card of their instances to try in preview. Go to main component and Detach instance move into the instance's panel header as icon buttons. The Behaviour section's "Still needed" line is a status rather than a button that only moved focus, and the rows it names are marked instead. The DOM projection writes flex-start for auto layout's start alignment: CSS stretches children by default, so a hugging button in a column filled its card in preview and HTML export. The paint page snapshot catches up with the frame names #930 draws. * perf: open the demo as a prebuilt .fig instead of generating it /demo built every page in the browser and laid them all out in one task, which froze the page for about six seconds. The demo's content moves to tools/generate/demo, which builds public/demo.fig headlessly, before Vite serves or bundles the app and only when its inputs changed; /demo opens that file like any other, off the main thread and behind the loader. The document is now lint-clean: small captions are 12 px, and the badge and disabled button text meet contrast. Opening the demo from .fig showed three bugs in saving and reading files, fixed here: - A layer's blend mode was never written, so Multiply and Screen reopened as pass-through. - Underlines drawn from saved glyphs ran to the text box edge; glyphs now keep their advance, read from and written to .fig, and the underline ends where the text does. - A frame laid out on its own because of a saved hug size ignored the width its parent stretched it to, so centred content moved to the start. * feat(design-jsx): write Reka elements in TSX The Reka element names were only part of the JSX string grammar, so code that builds trees in TypeScript had to assemble strings by hand, unchecked. @open-pencil/design-jsx now exports each Reka namespace as typed element functions (Switch.Root, Slider.Thumb, Tabs.Trigger, …), derived from the same table the string renderer reads, with props typed as the behaviour props a root binds. The demo's controls are TSX built with them. * fix(canvas): draw a component set's editing border at the live zoom A component set without a stroke got its dashed border in the scene, sized by dividing by the zoom. The scene is cached as retained pictures and scaled while navigating, so the border was recorded at one zoom and grew into thick dashes when zooming in, until the page was redrawn. It is editor chrome, so it now comes from the overlay pass, which draws every frame at the live zoom, as slot outlines do. The label cache tracks every component set on the page for it, and image export no longer includes the border. * test: fit the P3 paint page to the canvas size it is captured at The P3 test zoomed to the paint page while the wide-gamut notice still took space, then dismissed it, so the first capture fit a shorter canvas than the second and the two only matched by timing. focusPaintEffects waits for the canvas to take a window resize, the test focuses again after the notice goes, and the snapshot shows the demo as it now opens. * refactor(canvas): fill sections and sets through one helper Without its own fallback border, a component set fills and strokes its rounded bounds the way a section does; fillRoundedBounds fills them for both. * test: check the demo controls on their own page and type pixel reads The demo test built and round-tripped the whole four-page document to check the controls, which took six seconds on CI and timed out; it now builds the Controls page alone through the same measured-text setup the build uses. The underline test reads CanvasKit pixels as any number array, since readPixels may return floats. * fix(canvas): measure long text extents without spreading glyphs Spreading every glyph into Math.min/max overflows the argument limit on long texts. * fix(demo): keep a tab touched while the demo loads and report a failed load A file opened or a layer drawn during the fetch keeps the tab; a failed fetch shows the open-file error instead of an unhandled rejection. * test: check a component set border stays one pixel wide when zoomed
986 lines
35 KiB
TypeScript
986 lines
35 KiB
TypeScript
import type { DerivedSymbolOverride } from '#fig/instance-overrides/types'
|
|
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '#fig/source-metadata'
|
|
|
|
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
|
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
|
|
import type {
|
|
ComponentPropertyDefinition,
|
|
ComponentPropertyReferenceField,
|
|
SceneGraph,
|
|
SceneNode
|
|
} from '@open-pencil/scene-graph'
|
|
import {
|
|
DEFAULT_STROKE_MITER_LIMIT,
|
|
DEFAULT_STROKE_WEIGHT,
|
|
OPEN_PENCIL_PLUGIN_DATA,
|
|
withPluginData
|
|
} from '@open-pencil/scene-graph'
|
|
import { siblingOrderKeys } from '@open-pencil/scene-graph/order-keys'
|
|
import type { GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
|
|
|
|
/* eslint-disable max-lines */
|
|
import { bytesToHex } from '../bytes'
|
|
import { exportCanvasGuides } from '../canvas-guides'
|
|
import { snapshotInstanceGeometry } from '../instance/geometry'
|
|
import {
|
|
applyExportSettingsPluginData,
|
|
applyLibrarySourcePluginData,
|
|
applyTextPathBoxPluginData,
|
|
mergePluginData,
|
|
serializePluginRelaunchData
|
|
} from '../plugin-data'
|
|
import {
|
|
applyColorVariableBinding,
|
|
createFillPaints,
|
|
createStrokePaints,
|
|
getOrCreateNodeGuid,
|
|
instanceGuidResolver,
|
|
getOrCreatePropertyGuid,
|
|
parseGuidOrNull,
|
|
resolveInstanceComponentId,
|
|
type KiwiNodeChange,
|
|
type KiwiSymbolOverridePayload,
|
|
type SceneNodeToKiwiContext
|
|
} from './context'
|
|
import { mergeOverrides, serializeRuntimePropertyOverrides } from './override-claims'
|
|
import { nodeWithResolvedBindings } from './resolved-bindings'
|
|
import { slotContentAssignment, slotDefinitionFields } from './slots'
|
|
|
|
export type { KiwiNodeChange, SceneNodeToKiwiContext } from './context'
|
|
|
|
const siblingOrderKeyCache = new WeakMap<object, Map<string, { keyById: Map<string, string> }>>()
|
|
|
|
/**
|
|
* A layer's `parentIndex.position`: its imported key where that still orders it after the
|
|
* previous sibling, otherwise a key between its neighbours, so a round trip keeps the keys
|
|
* Figma wrote instead of renumbering every sibling.
|
|
*
|
|
* Canvas children are excluded. Shared styles and variable records are written to the
|
|
* internal canvas by other passes that continue the keys already there, and an imported
|
|
* key would collide with them, so those keep the caller's running index.
|
|
*/
|
|
function exportOrderKey(
|
|
context: SceneNodeToKiwiContext,
|
|
node: SceneNode,
|
|
childIndex: number
|
|
): string {
|
|
const parentId = node.parentId
|
|
if (!parentId) return context.fractionalPosition(childIndex)
|
|
const parent = context.graph.getNode(parentId)
|
|
if (!parent || parent.type === 'CANVAS') return context.fractionalPosition(childIndex)
|
|
let cache = siblingOrderKeyCache.get(context)
|
|
if (!cache) {
|
|
cache = new Map()
|
|
siblingOrderKeyCache.set(context, cache)
|
|
}
|
|
let entry = cache.get(parentId)
|
|
if (!entry) {
|
|
const siblings = context.graph.getChildren(parentId).filter((child) => !child.internalOnly)
|
|
const keys = siblingOrderKeys(siblings.map((sibling) => sibling.source.orderKey))
|
|
// Keyed by id: this runs once per child, so a scan per child would cost the parent O(n²).
|
|
entry = { keyById: new Map(siblings.map((sibling, index) => [sibling.id, keys[index]])) }
|
|
cache.set(parentId, entry)
|
|
}
|
|
return entry.keyById.get(node.id) ?? context.fractionalPosition(childIndex)
|
|
}
|
|
|
|
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>
|
|
|
|
function toKiwiBooleanOperation(operation: SceneNode['booleanOperation']): KiwiBooleanOperation {
|
|
return operation === 'EXCLUDE' ? 'XOR' : (operation ?? 'UNION')
|
|
}
|
|
|
|
/** Resolve effect variable asset refs when the Kiwi effect schema requires GUID aliases. */
|
|
export function buildAssetRefToVarGuidMap(
|
|
graph: SceneGraph,
|
|
varIdToGuid: Map<string, GUID>
|
|
): Map<string, GUID> {
|
|
const map = new Map<string, GUID>()
|
|
for (const [varId, variable] of graph.variables) {
|
|
if (!variable.key) continue
|
|
const guid = varIdToGuid.get(varId) ?? stringToGuid(varId)
|
|
map.set(variable.key, guid)
|
|
if (variable.version) map.set(`${variable.key}@${variable.version}`, guid)
|
|
}
|
|
return map
|
|
}
|
|
|
|
function componentPropertyTypeForKiwi(type: string) {
|
|
if (type === 'BOOLEAN') return 'BOOL'
|
|
return type
|
|
}
|
|
|
|
function componentPropertyValue(
|
|
type: string,
|
|
value: string,
|
|
context: SceneNodeToKiwiContext,
|
|
localIdCounter: { value: number }
|
|
) {
|
|
if (type === 'BOOLEAN') return { boolValue: value === 'true' }
|
|
if (type === 'INSTANCE_SWAP') {
|
|
const target = context.graph.getNode(value)
|
|
const guid = target
|
|
? getOrCreateNodeGuid(context, target.id, localIdCounter)
|
|
: parseGuidOrNull(value)
|
|
return guid ? { guidValue: guid } : { textValue: { characters: value } }
|
|
}
|
|
return { textValue: { characters: value } }
|
|
}
|
|
|
|
function componentPropertyVariableValue(
|
|
type: string,
|
|
value: string,
|
|
context: SceneNodeToKiwiContext,
|
|
localIdCounter: { value: number }
|
|
) {
|
|
const legacy = componentPropertyValue(type, value, context, localIdCounter)
|
|
if (type === 'BOOLEAN')
|
|
return {
|
|
value: { boolValue: value === 'true' },
|
|
dataType: 'BOOLEAN',
|
|
resolvedDataType: 'BOOLEAN'
|
|
}
|
|
if (type === 'INSTANCE_SWAP' && 'guidValue' in legacy) {
|
|
return {
|
|
value: { symbolIdValue: { guid: legacy.guidValue } },
|
|
dataType: 'SYMBOL_ID',
|
|
resolvedDataType: 'SYMBOL_ID'
|
|
}
|
|
}
|
|
return { value: { textValue: value }, dataType: 'STRING', resolvedDataType: 'STRING' }
|
|
}
|
|
|
|
function serializeVariableModes(
|
|
node: SceneNode,
|
|
variableIdToGuid?: Map<string, GUID>,
|
|
modeIdToGuid?: Map<string, GUID>
|
|
): NonNullable<KiwiNodeChange['variableModeBySetMap']> | undefined {
|
|
const entries = Object.entries(node.variableModes).flatMap(([collectionId, modeId]) => {
|
|
const collectionGuid = variableIdToGuid?.get(collectionId) ?? parseGuidOrNull(collectionId)
|
|
const modeGuid = modeIdToGuid?.get(modeId) ?? parseGuidOrNull(modeId)
|
|
if (!collectionGuid || !modeGuid) return []
|
|
return [{ variableSetID: { guid: collectionGuid }, variableModeID: modeGuid }]
|
|
})
|
|
return entries.length > 0 ? { entries } : undefined
|
|
}
|
|
|
|
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
|
|
'variableConsumptionMap',
|
|
'parameterConsumptionMap'
|
|
])
|
|
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
|
|
|
|
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
|
'BOOLEAN',
|
|
'FLOAT',
|
|
'STRING',
|
|
'ALIAS',
|
|
'COLOR',
|
|
'SYMBOL_ID',
|
|
'TEXT_DATA',
|
|
'PROP_REF'
|
|
])
|
|
|
|
interface FigmaPayloadVariableMap {
|
|
entries?: unknown[]
|
|
}
|
|
|
|
interface FigmaPayloadVariableMapEntry {
|
|
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
|
|
}
|
|
|
|
interface ColorVarCarrier {
|
|
colorVar?: {
|
|
value?: {
|
|
alias?: {
|
|
guid?: GUID
|
|
assetRef?: { key: string; version?: string }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isFigmaPayloadVariableMap(value: unknown): value is FigmaPayloadVariableMap {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value) && 'entries' in value
|
|
}
|
|
|
|
function isFigmaPayloadVariableMapEntry(value: unknown): value is FigmaPayloadVariableMapEntry {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function isSupportedVariableMapEntry(value: unknown): boolean {
|
|
if (!isFigmaPayloadVariableMapEntry(value)) return false
|
|
const entry = value
|
|
const dataType = entry.variableData?.dataType
|
|
return (
|
|
(typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) ||
|
|
!!entry.variableData?.value?.propRefValue
|
|
)
|
|
}
|
|
|
|
function isPropRefVariableMapEntry(value: unknown): boolean {
|
|
if (!isFigmaPayloadVariableMapEntry(value)) return false
|
|
const entry = value
|
|
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
|
|
}
|
|
|
|
function materializeSafeVariableMap(
|
|
value: unknown,
|
|
blobs: Uint8Array[],
|
|
options: MaterializeFigmaPayloadOptions,
|
|
predicate: (value: unknown) => boolean
|
|
): unknown {
|
|
if (!isFigmaPayloadVariableMap(value)) return undefined
|
|
const entries = value.entries?.filter(predicate) ?? []
|
|
if (entries.length === 0) return undefined
|
|
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) }
|
|
}
|
|
|
|
interface MaterializeFigmaPayloadOptions {
|
|
blobIndexByHex?: Map<string, number>
|
|
includePaintVariables?: boolean
|
|
includeVariableMaps?: boolean
|
|
}
|
|
|
|
function materializeFigmaBlob(
|
|
value: { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
|
|
blobs: Uint8Array[],
|
|
options: MaterializeFigmaPayloadOptions
|
|
): number {
|
|
const blob = value.__openPencilFigmaBlob
|
|
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob ?? {}))
|
|
const key = bytesToHex(bytes)
|
|
const existing = options.blobIndexByHex?.get(key)
|
|
if (existing !== undefined) return existing
|
|
const index = blobs.length
|
|
blobs.push(bytes)
|
|
options.blobIndexByHex?.set(key, index)
|
|
return index
|
|
}
|
|
|
|
function normalizeFigmaPayloadValue(key: string, value: unknown): unknown {
|
|
if (key === 'stackCounterAlignItems' && value === 'STRETCH') return 'MIN'
|
|
if (
|
|
(key === 'stackJustify' ||
|
|
key === 'stackPrimaryAlignItems' ||
|
|
key === 'stackCounterAlign' ||
|
|
key === 'stackCounterAlignItems') &&
|
|
value === 'SPACE_EVENLY'
|
|
) {
|
|
return 'SPACE_BETWEEN'
|
|
}
|
|
return value
|
|
}
|
|
|
|
function materializeFigmaPayload(
|
|
value: unknown,
|
|
blobs: Uint8Array[],
|
|
options: MaterializeFigmaPayloadOptions = {}
|
|
): unknown {
|
|
if (value instanceof Uint8Array) return value
|
|
if (Array.isArray(value))
|
|
return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
|
if (!value || typeof value !== 'object') return value
|
|
if ('__openPencilFigmaBlob' in value) {
|
|
return materializeFigmaBlob(
|
|
value as { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
|
|
blobs,
|
|
options
|
|
)
|
|
}
|
|
|
|
const materialized: Record<string, unknown> = {}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key) && !options.includePaintVariables) continue
|
|
if (FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
|
|
const variableMap = materializeSafeVariableMap(
|
|
child,
|
|
blobs,
|
|
options,
|
|
options.includeVariableMaps ? isSupportedVariableMapEntry : isPropRefVariableMapEntry
|
|
)
|
|
if (variableMap !== undefined) materialized[key] = variableMap
|
|
continue
|
|
}
|
|
materialized[key] = normalizeFigmaPayloadValue(
|
|
key,
|
|
materializeFigmaPayload(child, blobs, options)
|
|
)
|
|
}
|
|
return materialized
|
|
}
|
|
|
|
/**
|
|
* Fields that are ALWAYS set by explicit serialization and must NOT be
|
|
* overwritten by rawNodeFields (which may contain stale Figma defaults).
|
|
* rawNodeFields is a fallback for fields NOT covered by the explicit path.
|
|
*
|
|
* Additionally, applyRawFigmaNodeFields skips any key already present on `nc`,
|
|
* so conditionally-set fields (fontVariations, derivedTextData, strokeJoin,
|
|
* strokeWeight, miterLimit, etc.) are automatically protected when set.
|
|
*
|
|
* NOTE: fillGeometry, strokeGeometry, and vectorData are deliberately NOT
|
|
* listed here. When nodeForGeometryExport suppresses explicit serialization
|
|
* (because raw geometry exists), rawNodeFields must supply these fields.
|
|
*/
|
|
const RAW_FIELDS_OVERRIDE_BLOCKLIST = new Set([
|
|
// Fields that are structurally dangerous if overwritten by stale raw data:
|
|
'pageType',
|
|
'derivedSymbolData',
|
|
'derivedSymbolDataLayoutVersion',
|
|
'sourceLibraryKey',
|
|
'minSize',
|
|
'maxSize',
|
|
// Variable consumption maps: explicit serialization always sets these when
|
|
// bindings exist, and our VARIABLE_BINDING_FIELDS mapping may produce different
|
|
// kiwi field names than the original raw data for library variable references.
|
|
'variableConsumptionMap',
|
|
'parameterConsumptionMap'
|
|
])
|
|
|
|
/**
|
|
* Resize reflowed this path-text node (glyphs regenerated, strokeGeometry
|
|
* cleared). The raw strokeGeometry silhouettes are still at the pre-resize
|
|
* size, so exporting them would paint stale full-size outlines.
|
|
*/
|
|
function isReflowedStrokedPathText(node: SceneNode): boolean {
|
|
if (node.type !== 'TEXT' || node.textPathData === null) return false
|
|
if ((node.derivedTextGlyphs?.length ?? 0) === 0 || node.textPathBox === null) return false
|
|
if (node.strokeGeometry.length !== 0) return false
|
|
// Only when the node has stroke paint but its baked silhouettes were
|
|
// cleared by reflow (see resize.ts). Fill-only path text (no stroke paint,
|
|
// so strokeGeometry is always empty) is untouched and keeps its raw
|
|
// derivedTextData verbatim.
|
|
//
|
|
// This must key off live node.strokes, not rawNodeFields.strokeGeometry:
|
|
// clearResizedRawGeometry (resize.ts) deletes that raw field on every
|
|
// resize commit, so it's already gone by the time a reflowed node reaches
|
|
// export and can never be used to detect reflow here.
|
|
return node.strokes.length > 0
|
|
}
|
|
|
|
/**
|
|
* An imported path-text node that was edited after import — moving/rotating
|
|
* clears rawTransform (see clearEditedSourceMetadata), so the transform + size
|
|
* are recomputed from the node's post-expand box (exportNodeTransform /
|
|
* exportNodeSize). But the raw derivedTextData + baked silhouettes are still the
|
|
* PRE-expand (un-shifted) payload — exporting them against the shifted box
|
|
* re-triggers the import expand on reimport and drifts the node. Rebuild
|
|
* derivedTextData from the live (shifted) glyphs and re-derive silhouettes,
|
|
* exactly like the reflow path. rawTransform === null is the "edited" signal;
|
|
* pristine nodes keep rawTransform and their raw payload verbatim.
|
|
*/
|
|
function isEditedPathText(node: SceneNode): boolean {
|
|
return (
|
|
node.type === 'TEXT' &&
|
|
node.textPathData !== null &&
|
|
// "Edited" = the raw transform no longer backs the node. Editing does not
|
|
// clear source.fig.rawTransform directly; effectiveFigmaSourcePayload
|
|
// derives it from source.editedFields, so ask that, not the raw field.
|
|
effectiveFigmaSourcePayload(node).rawTransform === null &&
|
|
(node.derivedTextGlyphs?.length ?? 0) > 0
|
|
)
|
|
}
|
|
|
|
function applyRawFigmaNodeFields(
|
|
context: SceneNodeToKiwiContext,
|
|
node: SceneNode,
|
|
nc: KiwiNodeChange
|
|
): void {
|
|
let rawFields = effectiveFigmaRawNodeFields(node)
|
|
if (isReflowedStrokedPathText(node) || isEditedPathText(node)) {
|
|
// Strip before materializing so the stale blobs never enter the file:
|
|
// silhouettes are re-derived from glyphs by Figma/reimport, and
|
|
// derivedTextData was rebuilt from the reflowed glyphs by
|
|
// serializeTextProps (raw would clobber the new positions).
|
|
rawFields = { ...rawFields }
|
|
delete rawFields.strokeGeometry
|
|
delete rawFields.derivedTextData
|
|
}
|
|
const materialized = materializeFigmaPayload(rawFields, context.blobs, {
|
|
blobIndexByHex: context.blobIndexByHex,
|
|
includePaintVariables: true,
|
|
includeVariableMaps: true
|
|
}) as Partial<KiwiNodeChange>
|
|
for (const key of Object.keys(materialized) as (keyof KiwiNodeChange)[]) {
|
|
if (RAW_FIELDS_OVERRIDE_BLOCKLIST.has(String(key))) continue
|
|
// For paint arrays on imported nodes, the raw NC data preserves the
|
|
// original opacity/color.a split (e.g. opacity=0 for invisible strokes).
|
|
// The scene model may lose this distinction for instance children whose
|
|
// strokes are resolved from component overrides. Prefer the raw data.
|
|
if ((key === 'fillPaints' || key === 'strokePaints') && node.source.id) {
|
|
const paints = materialized[key]
|
|
nc[key] = paints?.map((paint, index) =>
|
|
applyColorVariableBinding(
|
|
context,
|
|
node,
|
|
paint,
|
|
`${key === 'fillPaints' ? 'fills' : 'strokes'}/${index}/color`
|
|
)
|
|
)
|
|
continue
|
|
}
|
|
if (
|
|
key === 'effects' &&
|
|
node.source.id &&
|
|
context.assetRefToVarGuid &&
|
|
context.assetRefToVarGuid.size > 0
|
|
) {
|
|
nc[key] = convertColorVarAssetRefs(materialized[key], context.assetRefToVarGuid)
|
|
continue
|
|
}
|
|
if (key === 'derivedTextData' && node.source.id) {
|
|
nc.derivedTextData = materialized.derivedTextData
|
|
continue
|
|
}
|
|
if (key === 'textDecorationFillPaints' && node.source.id) {
|
|
nc.textDecorationFillPaints = materialized.textDecorationFillPaints
|
|
continue
|
|
}
|
|
// Skip any key already set on nc — explicit serialization takes priority
|
|
if (key in nc) continue
|
|
nc[key] = materialized[key]
|
|
}
|
|
}
|
|
|
|
/** Convert asset refs only for payloads whose Kiwi schema rejects asset-ref aliases. */
|
|
function convertColorVarAssetRefs<T>(values: T, assetRefToVarGuid: Map<string, GUID>): T {
|
|
if (!Array.isArray(values)) return values
|
|
const converted = values.map((value: ColorVarCarrier) => {
|
|
const colorVar = value.colorVar
|
|
const alias = colorVar?.value?.alias
|
|
if (!colorVar || !alias || alias.guid || !alias.assetRef?.key) return value
|
|
const assetRef = alias.assetRef
|
|
const lookupKey = assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
|
|
const guid = assetRefToVarGuid.get(lookupKey) ?? assetRefToVarGuid.get(assetRef.key)
|
|
if (!guid) return value
|
|
return {
|
|
...value,
|
|
colorVar: {
|
|
...colorVar,
|
|
value: { ...colorVar.value, alias: { guid } }
|
|
}
|
|
}
|
|
})
|
|
return converted.some((value, index) => value !== values[index]) ? (converted as T) : values
|
|
}
|
|
|
|
function applyInstancePayload(
|
|
context: SceneNodeToKiwiContext,
|
|
node: SceneNode,
|
|
nc: KiwiNodeChange,
|
|
localIdCounter: { value: number }
|
|
): void {
|
|
if (node.type !== 'INSTANCE' || !node.componentId) return
|
|
const symbolID = getOrCreateNodeGuid(
|
|
context,
|
|
resolveInstanceComponentId(context, node.componentId),
|
|
localIdCounter
|
|
)
|
|
if (symbolID) {
|
|
const symbolData: Record<string, unknown> = { symbolID }
|
|
const symbolOverrides: KiwiSymbolOverridePayload[] = []
|
|
if (node.source.fig.symbolOverrides.length > 0) {
|
|
symbolOverrides.push(
|
|
...(materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
|
|
blobIndexByHex: context.blobIndexByHex,
|
|
includePaintVariables: true,
|
|
includeVariableMaps: true
|
|
}) as KiwiSymbolOverridePayload[])
|
|
)
|
|
}
|
|
mergeOverrides(
|
|
symbolOverrides,
|
|
serializeRuntimePropertyOverrides(context, node, localIdCounter)
|
|
)
|
|
if (symbolOverrides.length > 0) symbolData.symbolOverrides = symbolOverrides
|
|
if (!Number.isFinite(node.componentScale) || node.componentScale <= 0)
|
|
throw new Error('Invalid instance uniform scale')
|
|
if (node.componentScale !== 1 || node.source.fig.uniformScaleFactor != null) {
|
|
symbolData.uniformScaleFactor = node.componentScale
|
|
}
|
|
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
|
|
}
|
|
if (
|
|
node.source.fig.componentPropAssignments.length > 0 &&
|
|
!node.source.editedFields.includes('componentPropertyAssignments')
|
|
) {
|
|
nc.componentPropAssignments = materializeFigmaPayload(
|
|
node.source.fig.componentPropAssignments,
|
|
context.blobs,
|
|
{
|
|
blobIndexByHex: context.blobIndexByHex,
|
|
includePaintVariables: true,
|
|
includeVariableMaps: true
|
|
}
|
|
)
|
|
}
|
|
const retainedGeometry = materializeFigmaPayload(
|
|
node.source.fig.derivedSymbolData,
|
|
context.blobs,
|
|
{
|
|
blobIndexByHex: context.blobIndexByHex,
|
|
includePaintVariables: true,
|
|
includeVariableMaps: true
|
|
}
|
|
) as DerivedSymbolOverride[]
|
|
nc.derivedSymbolData = snapshotInstanceGeometry(
|
|
context.graph,
|
|
node,
|
|
instanceGuidResolver(context, localIdCounter),
|
|
retainedGeometry,
|
|
(target) => ({ size: exportNodeSize(target), transform: exportNodeTransform(context, target) })
|
|
)
|
|
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
|
|
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
|
|
}
|
|
}
|
|
|
|
function componentPropertyPreferredValues(
|
|
definition: ComponentPropertyDefinition,
|
|
context: SceneNodeToKiwiContext
|
|
) {
|
|
if (
|
|
(definition.type === 'INSTANCE_SWAP' || definition.type === 'SLOT') &&
|
|
definition.preferredValues?.length
|
|
) {
|
|
return {
|
|
instanceSwapValues: definition.preferredValues.map((value) => {
|
|
const target = context.graph.getNode(value)
|
|
const key = target?.componentKey || target?.sourceLibraryKey || value
|
|
return { type: 'COMPONENT', key }
|
|
})
|
|
}
|
|
}
|
|
if (definition.type === 'VARIANT' && definition.variantOptions?.length) {
|
|
return { stringValues: [...definition.variantOptions] }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function componentPropertyNodeField(field: ComponentPropertyReferenceField): string {
|
|
if (field === 'TEXT') return 'TEXT_DATA'
|
|
if (field === 'INSTANCE_SWAP') return 'OVERRIDDEN_SYMBOL_ID'
|
|
if (field === 'SLOT_CONTENT') return 'SLOT_CONTENT_ID'
|
|
return 'VISIBLE'
|
|
}
|
|
|
|
export function buildComponentPropIndex(
|
|
graph: SceneGraph
|
|
): ReadonlyMap<string, ComponentPropertyDefinition> {
|
|
const definitions = new Map<string, ComponentPropertyDefinition>()
|
|
for (const candidate of graph.getAllNodes()) {
|
|
for (const definition of candidate.componentPropertyDefinitions) {
|
|
if (!definitions.has(definition.id)) definitions.set(definition.id, definition)
|
|
}
|
|
}
|
|
return definitions
|
|
}
|
|
|
|
function shouldSerializeRawBackedField(
|
|
node: SceneNode,
|
|
rawField: string,
|
|
hasValue: boolean,
|
|
alreadySerialized = false
|
|
): boolean {
|
|
return hasValue && !(rawField in effectiveFigmaRawNodeFields(node)) && !alreadySerialized
|
|
}
|
|
|
|
interface ExportedPropertyReference {
|
|
defID: GUID
|
|
componentPropNodeField: string
|
|
}
|
|
interface ExportedParameterEntry {
|
|
variableField?: string
|
|
variableData?: {
|
|
value: { propRefValue: { defId: GUID } }
|
|
dataType: string
|
|
resolvedDataType: string
|
|
}
|
|
}
|
|
function mergeParameterBindings(nc: KiwiNodeChange): void {
|
|
const parameters = nc.parameterConsumptionMap as
|
|
| { entries?: Array<{ variableField?: string }> }
|
|
| undefined
|
|
const entries = parameters?.entries ?? []
|
|
const fields = new Set(entries.map((entry) => entry.variableField))
|
|
const variables = nc.variableConsumptionMap?.entries ?? []
|
|
if (!variables.length) return
|
|
nc.parameterConsumptionMap = {
|
|
entries: [...variables.filter((entry) => !fields.has(entry.variableField)), ...entries]
|
|
}
|
|
}
|
|
|
|
function applyParameterReferences(nc: KiwiNodeChange, refs: ExportedPropertyReference[]): void {
|
|
if (!refs.length) return
|
|
const existing = nc.parameterConsumptionMap as { entries?: ExportedParameterEntry[] } | undefined
|
|
const fields = new Set(refs.map((ref) => ref.componentPropNodeField))
|
|
const entries = (existing?.entries ?? []).filter(
|
|
(entry) => !fields.has(entry.variableField ?? '')
|
|
)
|
|
const types: Record<string, string> = {
|
|
VISIBLE: 'BOOLEAN',
|
|
TEXT_DATA: 'STRING',
|
|
OVERRIDDEN_SYMBOL_ID: 'SYMBOL_ID',
|
|
SLOT_CONTENT_ID: 'SLOT_CONTENT_ID'
|
|
}
|
|
for (const ref of refs)
|
|
entries.push({
|
|
variableField: ref.componentPropNodeField,
|
|
variableData: {
|
|
value: { propRefValue: { defId: ref.defID } },
|
|
dataType: 'PROP_REF',
|
|
resolvedDataType: types[ref.componentPropNodeField]
|
|
}
|
|
})
|
|
nc.parameterConsumptionMap = { entries }
|
|
}
|
|
|
|
function applyComponentMetadata(
|
|
context: SceneNodeToKiwiContext,
|
|
node: SceneNode,
|
|
nc: KiwiNodeChange,
|
|
localIdCounter: { value: number }
|
|
): void {
|
|
if (node.componentKey) nc.componentKey = node.componentKey
|
|
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
|
|
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
|
|
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
|
|
if (publishId) nc.publishID = publishId
|
|
if (overrideKey) nc.overrideKey = overrideKey
|
|
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
|
|
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
|
|
if (node.type === 'COMPONENT_SET' || node.isPublishable) nc.isPublishable = node.isPublishable
|
|
if (node.type === 'COMPONENT' || node.isSymbolPublishable) {
|
|
nc.isSymbolPublishable = node.isSymbolPublishable
|
|
}
|
|
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
|
|
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
|
|
const componentPropDefs = node.componentPropertyDefinitions.map((def) => {
|
|
const record: Record<string, unknown> = {
|
|
id: getOrCreatePropertyGuid(context, def.id, localIdCounter),
|
|
name: def.name,
|
|
type: componentPropertyTypeForKiwi(def.type),
|
|
preferredValues: componentPropertyPreferredValues(def, context)
|
|
}
|
|
if (def.type === 'SLOT') Object.assign(record, slotDefinitionFields(def))
|
|
else {
|
|
record.initialValue = componentPropertyValue(
|
|
def.type,
|
|
def.defaultValue,
|
|
context,
|
|
localIdCounter
|
|
)
|
|
record.varValue = componentPropertyVariableValue(
|
|
def.type,
|
|
def.defaultValue,
|
|
context,
|
|
localIdCounter
|
|
)
|
|
}
|
|
if (def.description) record.description = def.description
|
|
return record
|
|
})
|
|
if (shouldSerializeRawBackedField(node, 'componentPropDefs', componentPropDefs.length > 0)) {
|
|
nc.componentPropDefs = componentPropDefs
|
|
}
|
|
|
|
const parameterRefs = node.componentPropertyReferences.map((ref) => ({
|
|
defID: getOrCreatePropertyGuid(context, ref.propertyId, localIdCounter),
|
|
componentPropNodeField: componentPropertyNodeField(ref.field)
|
|
}))
|
|
// Figma binds a slot frame only through its parameter map, never a legacy property ref.
|
|
const componentPropRefs = parameterRefs.filter(
|
|
(ref) => ref.componentPropNodeField !== 'SLOT_CONTENT_ID'
|
|
)
|
|
if (shouldSerializeRawBackedField(node, 'componentPropRefs', componentPropRefs.length > 0)) {
|
|
nc.componentPropRefs = componentPropRefs
|
|
}
|
|
|
|
applyParameterReferences(nc, parameterRefs)
|
|
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
|
|
.map(([propertyId, value]) => {
|
|
const definition = context.componentPropertyDefinitionsById.get(propertyId)
|
|
if (!definition) return null
|
|
if (definition.type === 'SLOT')
|
|
return slotContentAssignment(
|
|
context,
|
|
node,
|
|
propertyId,
|
|
getOrCreatePropertyGuid(context, propertyId, localIdCounter),
|
|
localIdCounter
|
|
)
|
|
return {
|
|
defID: getOrCreatePropertyGuid(context, propertyId, localIdCounter),
|
|
value: componentPropertyValue(definition.type, value, context, localIdCounter),
|
|
varValue: componentPropertyVariableValue(definition.type, value, context, localIdCounter)
|
|
}
|
|
})
|
|
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null)
|
|
if (
|
|
shouldSerializeRawBackedField(
|
|
node,
|
|
'componentPropAssignments',
|
|
componentPropAssignments.length > 0,
|
|
Boolean(nc.componentPropAssignments)
|
|
)
|
|
) {
|
|
nc.componentPropAssignments = componentPropAssignments
|
|
}
|
|
|
|
const variantPropSpecs = node.variantPropSpecs.map((spec) => ({
|
|
propDefId: getOrCreatePropertyGuid(context, spec.propDefId, localIdCounter),
|
|
value: spec.value
|
|
}))
|
|
if (shouldSerializeRawBackedField(node, 'variantPropSpecs', variantPropSpecs.length > 0)) {
|
|
nc.variantPropSpecs = variantPropSpecs
|
|
}
|
|
}
|
|
|
|
function exportNodeSize(node: SceneNode): Vector {
|
|
// rawSize and rawTransform are a matched pair describing the ORIGINAL Figma
|
|
// box. Once the transform no longer backs the node, exportNodeTransform
|
|
// recomputes it from node dims via computeExportTransform; pairing that with
|
|
// an un-expanded rawSize disagrees about the box (for rotation, a different
|
|
// centre) and the node drifts on reimport. Use rawSize only while the
|
|
// transform still backs it.
|
|
const payload = effectiveFigmaSourcePayload(node)
|
|
return payload.rawSize && payload.rawTransform
|
|
? { ...payload.rawSize }
|
|
: { x: node.width, y: node.height }
|
|
}
|
|
|
|
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
|
const rawTransform = effectiveFigmaSourcePayload(node).rawTransform
|
|
return rawTransform ? { ...rawTransform } : context.computeExportTransform(node)
|
|
}
|
|
|
|
function hasRawGeometryPayload(node: SceneNode): boolean {
|
|
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
|
return 'fillGeometry' in rawNodeFields || 'strokeGeometry' in rawNodeFields
|
|
}
|
|
|
|
function hasRawVectorPayload(node: SceneNode): boolean {
|
|
return 'vectorData' in effectiveFigmaRawNodeFields(node)
|
|
}
|
|
|
|
const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
|
|
'DROP_SHADOW',
|
|
'INNER_SHADOW',
|
|
'LAYER_BLUR',
|
|
'BACKGROUND_BLUR',
|
|
'FOREGROUND_BLUR'
|
|
])
|
|
|
|
function hasRawUnsupportedEffects(node: SceneNode): boolean {
|
|
const effects = effectiveFigmaRawNodeFields(node).effects
|
|
return (
|
|
Array.isArray(effects) &&
|
|
effects.some(
|
|
(effect) =>
|
|
effect &&
|
|
typeof effect === 'object' &&
|
|
'type' in effect &&
|
|
!SUPPORTED_NORMALIZED_EFFECT_TYPES.has(String(effect.type))
|
|
)
|
|
)
|
|
}
|
|
|
|
function nodeForGeometryExport(node: SceneNode): SceneNode {
|
|
if (!hasRawGeometryPayload(node) && !hasRawVectorPayload(node)) return node
|
|
return {
|
|
...node,
|
|
fillGeometry: hasRawGeometryPayload(node) ? [] : node.fillGeometry,
|
|
strokeGeometry: hasRawGeometryPayload(node) ? [] : node.strokeGeometry,
|
|
vectorNetwork: hasRawVectorPayload(node) ? null : node.vectorNetwork
|
|
}
|
|
}
|
|
|
|
function applySharedStyleProps(
|
|
context: SceneNodeToKiwiContext,
|
|
node: SceneNode,
|
|
nc: KiwiNodeChange
|
|
): void {
|
|
const reference = (id: string) => ({ guid: context.nodeIdToGuid?.get(id) ?? stringToGuid(id) })
|
|
if (node.fillStyleId) nc.styleIdForFill = reference(node.fillStyleId)
|
|
if (node.strokeStyleId) nc.styleIdForStrokeFill = reference(node.strokeStyleId)
|
|
if (node.textStyleId) nc.styleIdForText = reference(node.textStyleId)
|
|
if (node.effectStyleId) nc.styleIdForEffect = reference(node.effectStyleId)
|
|
if (node.gridStyleId) nc.styleIdForGrid = reference(node.gridStyleId)
|
|
if (node.layoutGrids.length > 0) nc.layoutGrids = structuredClone(node.layoutGrids)
|
|
if (node.guides.length > 0) nc.guides = exportCanvasGuides(node.guides)
|
|
}
|
|
|
|
function applyNodeVisualProps(
|
|
context: SceneNodeToKiwiContext,
|
|
node: SceneNode,
|
|
nc: KiwiNodeChange
|
|
): void {
|
|
if (node.independentStrokeWeights) {
|
|
nc.borderStrokeWeightsIndependent = true
|
|
nc.borderTopWeight = node.borderTopWeight
|
|
nc.borderRightWeight = node.borderRightWeight
|
|
nc.borderBottomWeight = node.borderBottomWeight
|
|
nc.borderLeftWeight = node.borderLeftWeight
|
|
}
|
|
|
|
if (node.fills.length > 0) nc.fillPaints = createFillPaints(context, node)
|
|
|
|
context.serializeCornerRadii(node, nc)
|
|
|
|
if (node.effects.length > 0 && !hasRawUnsupportedEffects(node)) {
|
|
nc.effects = node.effects.map((effect) => ({
|
|
type: effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR' : effect.type,
|
|
color: context.safeColor(effect.color),
|
|
offset: effect.offset,
|
|
radius: effect.radius,
|
|
spread: effect.spread,
|
|
visible: effect.visible,
|
|
blendMode: effect.blendMode ?? 'NORMAL',
|
|
showShadowBehindNode: effect.showShadowBehindNode
|
|
}))
|
|
}
|
|
|
|
if (node.type === 'TEXT') {
|
|
context.serializeTextProps(
|
|
node,
|
|
nc,
|
|
context.graph,
|
|
context.fontDigestMap,
|
|
context.blobs,
|
|
context.glyphBlobMap
|
|
)
|
|
}
|
|
|
|
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
|
|
applySharedStyleProps(context, node, nc)
|
|
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
|
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
|
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
|
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
|
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in rawNodeFields) {
|
|
nc.strokeJoin = node.strokeJoin
|
|
}
|
|
if (node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT || 'miterLimit' in rawNodeFields) {
|
|
nc.miterLimit = node.strokeMiterLimit
|
|
}
|
|
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
|
|
if (node.arcData) {
|
|
nc.arcData = {
|
|
startingAngle: node.arcData.startingAngle,
|
|
endingAngle: node.arcData.endingAngle,
|
|
innerRadius: node.arcData.innerRadius
|
|
}
|
|
}
|
|
if (!node.autoRename) nc.autoRename = false
|
|
}
|
|
|
|
/**
|
|
* Import maps TEXT_PATH → TEXT. Re-emit Kiwi type 41 only while path fidelity
|
|
* remains (materialized path data + baked glyphs). After an edit that cannot
|
|
* reflow glyphs, invalidation clears the path data so export falls back to TEXT.
|
|
*/
|
|
function exportKiwiNodeType(node: SceneNode, context: SceneNodeToKiwiContext): string {
|
|
const isPathText =
|
|
node.textPathData !== null && node.type === 'TEXT' && (node.derivedTextGlyphs?.length ?? 0) > 0
|
|
return isPathText ? 'TEXT_PATH' : context.mapToFigmaType(node.type)
|
|
}
|
|
|
|
export function sceneNodeToKiwiWithContext(
|
|
source: SceneNode,
|
|
parentGuid: GUID,
|
|
childIndex: number,
|
|
localIdCounter: { value: number },
|
|
context: SceneNodeToKiwiContext
|
|
): KiwiNodeChange[] {
|
|
const node = nodeWithResolvedBindings(context.graph, source)
|
|
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
|
|
sessionID: 1,
|
|
localID: localIdCounter.value++
|
|
}
|
|
|
|
const strokePaints = createStrokePaints(context, node)
|
|
|
|
const exportType = exportKiwiNodeType(node, context)
|
|
|
|
const nc: KiwiNodeChange = {
|
|
guid,
|
|
parentIndex: {
|
|
guid: parentGuid,
|
|
position: exportOrderKey(context, node, childIndex)
|
|
},
|
|
type: exportType,
|
|
name: node.name,
|
|
visible: node.visible,
|
|
opacity: node.opacity,
|
|
phase: 'CREATED',
|
|
size: exportNodeSize(node),
|
|
transform: exportNodeTransform(context, node)
|
|
}
|
|
if (node.sharedStyleType) nc.styleType = node.sharedStyleType
|
|
// Readers take a missing blend mode as pass-through, the layer default.
|
|
if (node.blendMode !== 'PASS_THROUGH') nc.blendMode = node.blendMode
|
|
if (node.type === 'GROUP') {
|
|
nc.resizeToFit = true
|
|
}
|
|
// With strokes, their geometry is the node's. Without, the node keeps its own weight and
|
|
// alignment, written when set or when the source file carried them.
|
|
if (node.strokes.length > 0) {
|
|
nc.strokeWeight = node.strokes[0].weight
|
|
nc.strokeAlign = node.strokes[0].align
|
|
} else {
|
|
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
|
if (node.strokeWeight !== DEFAULT_STROKE_WEIGHT || 'strokeWeight' in rawNodeFields) {
|
|
nc.strokeWeight = node.strokeWeight
|
|
}
|
|
// Kiwi reads a missing alignment as centered.
|
|
if (node.strokeAlign !== 'CENTER' || 'strokeAlign' in rawNodeFields) {
|
|
nc.strokeAlign = node.strokeAlign
|
|
}
|
|
}
|
|
if (node.locked) nc.locked = true
|
|
|
|
applyNodeVisualProps(context, node, nc)
|
|
applyComponentMetadata(context, node, nc, localIdCounter)
|
|
applyInstancePayload(context, node, nc, localIdCounter)
|
|
if (node.type === 'COMPONENT_SET')
|
|
node.pluginData = withPluginData(node.pluginData, OPEN_PENCIL_PLUGIN_DATA.nodeType, node.type)
|
|
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
|
if (node.type === 'BOOLEAN_OPERATION')
|
|
nc.booleanOperation = toKiwiBooleanOperation(node.booleanOperation)
|
|
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
|
|
|
context.serializeLayoutProps(node, nc)
|
|
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
|
|
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
|
|
mergeParameterBindings(nc)
|
|
applyRawFigmaNodeFields(context, node, nc)
|
|
const variableModeBySetMap = serializeVariableModes(
|
|
node,
|
|
context.varIdToGuid,
|
|
context.modeIdToGuid
|
|
)
|
|
if (variableModeBySetMap) nc.variableModeBySetMap = variableModeBySetMap
|
|
|
|
applyExportSettingsPluginData(node)
|
|
applyLibrarySourcePluginData(node)
|
|
applyTextPathBoxPluginData(node)
|
|
const pluginData = mergePluginData(node.pluginData)
|
|
if (pluginData.length > 0) nc.pluginData = pluginData
|
|
if (node.pluginRelaunchData.length > 0) {
|
|
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
|
|
}
|
|
|
|
const result: KiwiNodeChange[] = [nc]
|
|
const children =
|
|
node.type === 'INSTANCE'
|
|
? []
|
|
: context.graph
|
|
.getChildren(node.id)
|
|
.filter((child) => !child.internalOnly && child.sharedStyleType === null)
|
|
for (let i = 0; i < children.length; i++) {
|
|
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
|
|
}
|
|
return result
|
|
}
|