openpencil/packages/core/src/scene-graph.ts
Danila Poyarkov 8f740ce093 Fix vector rendering with pre-computed geometry and stroke outlines
Use fillGeometry/strokeGeometry blobs from .fig files for pixel-perfect
vector rendering. These pre-computed outlines account for stroke alignment,
caps, joins, and miter limits — eliminating white gaps between adjacent
stroked shapes.

For clipboard paste (where geometry blobs are unavailable), convert
vectorNetwork paths to filled stroke outlines via CanvasKit path.stroke().

Apply derivedSymbolData transforms, sizes, and geometry during import
so instance children render at the correct scale and position. When
derivedSymbolData provides size but no geometry, scale inherited blobs
proportionally.
2026-03-04 19:21:18 +03:00

1089 lines
29 KiB
TypeScript

import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from './constants'
export type { GUID, Color } from './types'
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
export type WindingRule = 'NONZERO' | 'EVENODD'
export interface VectorVertex {
x: number
y: number
strokeCap?: string
strokeJoin?: string
cornerRadius?: number
handleMirroring?: HandleMirroring
}
export interface VectorSegment {
start: number
end: number
tangentStart: { x: number; y: number }
tangentEnd: { x: number; y: number }
}
export interface VectorRegion {
windingRule: WindingRule
loops: number[][]
}
export interface VectorNetwork {
vertices: VectorVertex[]
segments: VectorSegment[]
regions: VectorRegion[]
}
export interface GeometryPath {
windingRule: WindingRule
commandsBlob: Uint8Array
}
export type NodeType =
| 'CANVAS'
| 'FRAME'
| 'RECTANGLE'
| 'ROUNDED_RECTANGLE'
| 'ELLIPSE'
| 'TEXT'
| 'LINE'
| 'STAR'
| 'POLYGON'
| 'VECTOR'
| 'GROUP'
| 'SECTION'
| 'COMPONENT'
| 'COMPONENT_SET'
| 'INSTANCE'
| 'CONNECTOR'
| 'SHAPE_WITH_TEXT'
import type { Color, Matrix, Rect } from './types'
export type FillType =
| 'SOLID'
| 'GRADIENT_LINEAR'
| 'GRADIENT_RADIAL'
| 'GRADIENT_ANGULAR'
| 'GRADIENT_DIAMOND'
| 'IMAGE'
export type BlendMode =
| 'NORMAL'
| 'DARKEN'
| 'MULTIPLY'
| 'COLOR_BURN'
| 'LIGHTEN'
| 'SCREEN'
| 'COLOR_DODGE'
| 'OVERLAY'
| 'SOFT_LIGHT'
| 'HARD_LIGHT'
| 'DIFFERENCE'
| 'EXCLUSION'
| 'HUE'
| 'SATURATION'
| 'COLOR'
| 'LUMINOSITY'
| 'PASS_THROUGH'
export type ImageScaleMode = 'FILL' | 'FIT' | 'CROP' | 'TILE'
export interface GradientStop {
color: Color
position: number
}
export type GradientTransform = Matrix
export interface Fill {
type: FillType
color: Color
opacity: number
visible: boolean
blendMode?: BlendMode
gradientStops?: GradientStop[]
gradientTransform?: GradientTransform
imageHash?: string
imageScaleMode?: ImageScaleMode
imageTransform?: GradientTransform
}
export type StrokeCap = 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL'
export type StrokeJoin = 'MITER' | 'BEVEL' | 'ROUND'
export type MaskType = 'ALPHA' | 'VECTOR' | 'LUMINANCE'
export interface Stroke {
color: Color
weight: number
opacity: number
visible: boolean
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
cap?: StrokeCap
join?: StrokeJoin
dashPattern?: number[]
}
export interface Effect {
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR'
color: Color
offset: { x: number; y: number }
radius: number
spread: number
visible: boolean
blendMode?: BlendMode
}
export type ConstraintType = 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'SCALE'
export type TextAutoResize = 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE'
export type TextAlignVertical = 'TOP' | 'CENTER' | 'BOTTOM'
export type TextCase = 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE'
export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
export interface CharacterStyleOverride {
fontWeight?: number
italic?: boolean
textDecoration?: TextDecoration
fontSize?: number
fontFamily?: string
letterSpacing?: number
lineHeight?: number | null
}
export interface StyleRun {
start: number
length: number
style: CharacterStyleOverride
}
export interface ArcData {
startingAngle: number
endingAngle: number
innerRadius: number
}
export type LayoutMode = 'NONE' | 'HORIZONTAL' | 'VERTICAL'
export type LayoutSizing = 'FIXED' | 'HUG' | 'FILL'
export type LayoutAlign = 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
export type LayoutCounterAlign = 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'BASELINE'
export type LayoutWrap = 'NO_WRAP' | 'WRAP'
export interface SceneNode {
id: string
type: NodeType
name: string
parentId: string | null
childIds: string[]
x: number
y: number
width: number
height: number
rotation: number
fills: Fill[]
strokes: Stroke[]
effects: Effect[]
opacity: number
cornerRadius: number
topLeftRadius: number
topRightRadius: number
bottomRightRadius: number
bottomLeftRadius: number
independentCorners: boolean
cornerSmoothing: number
visible: boolean
locked: boolean
clipsContent: boolean
blendMode: BlendMode
text: string
fontSize: number
fontFamily: string
fontWeight: number
italic: boolean
textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
textAlignVertical: TextAlignVertical
textAutoResize: TextAutoResize
textCase: TextCase
textDecoration: TextDecoration
lineHeight: number | null
letterSpacing: number
maxLines: number | null
styleRuns: StyleRun[]
horizontalConstraint: ConstraintType
verticalConstraint: ConstraintType
layoutMode: LayoutMode
layoutWrap: LayoutWrap
primaryAxisAlign: LayoutAlign
counterAxisAlign: LayoutCounterAlign
primaryAxisSizing: LayoutSizing
counterAxisSizing: LayoutSizing
itemSpacing: number
counterAxisSpacing: number
paddingTop: number
paddingRight: number
paddingBottom: number
paddingLeft: number
layoutPositioning: 'AUTO' | 'ABSOLUTE'
layoutGrow: number
layoutAlignSelf: 'AUTO' | 'STRETCH'
vectorNetwork: VectorNetwork | null
fillGeometry: GeometryPath[]
strokeGeometry: GeometryPath[]
arcData: ArcData | null
strokeCap: StrokeCap
strokeJoin: StrokeJoin
dashPattern: number[]
borderTopWeight: number
borderRightWeight: number
borderBottomWeight: number
borderLeftWeight: number
independentStrokeWeights: boolean
strokeMiterLimit: number
minWidth: number | null
maxWidth: number | null
minHeight: number | null
maxHeight: number | null
isMask: boolean
maskType: MaskType
counterAxisAlignContent: 'AUTO' | 'SPACE_BETWEEN'
itemReverseZIndex: boolean
strokesIncludedInLayout: boolean
expanded: boolean
textTruncation: 'DISABLED' | 'ENDING'
autoRename: boolean
pointCount: number
starInnerRadius: number
componentId: string | null
overrides: Record<string, unknown>
boundVariables: Record<string, string>
internalOnly: boolean
flipX: boolean
flipY: boolean
textPicture: Uint8Array | null
}
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
export type VariableValue = Color | number | string | boolean | { aliasId: string }
export interface Variable {
id: string
name: string
type: VariableType
collectionId: string
valuesByMode: Record<string, VariableValue>
description: string
hiddenFromPublishing: boolean
}
export interface VariableCollectionMode {
modeId: string
name: string
}
export interface VariableCollection {
id: string
name: string
modes: VariableCollectionMode[]
defaultModeId: string
variableIds: string[]
}
let nextLocalID = 1
export function generateId(): string {
return `0:${nextLocalID++}`
}
function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}): SceneNode {
return {
id: generateId(),
type,
name: type.charAt(0) + type.slice(1).toLowerCase(),
parentId: null,
childIds: [],
x: 0,
y: 0,
width: 100,
height: 100,
rotation: 0,
fills: [],
strokes: [],
effects: [],
opacity: 1,
cornerRadius: 0,
topLeftRadius: 0,
topRightRadius: 0,
bottomRightRadius: 0,
bottomLeftRadius: 0,
independentCorners: false,
cornerSmoothing: 0,
visible: true,
locked: false,
clipsContent: false,
text: '',
fontSize: 14,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: 400,
italic: false,
textAlignHorizontal: 'LEFT',
lineHeight: null,
letterSpacing: 0,
layoutMode: 'NONE',
layoutWrap: 'NO_WRAP',
primaryAxisAlign: 'MIN',
counterAxisAlign: 'MIN',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 0,
counterAxisSpacing: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
blendMode: 'PASS_THROUGH',
layoutPositioning: 'AUTO',
layoutGrow: 0,
layoutAlignSelf: 'AUTO',
vectorNetwork: null,
fillGeometry: [],
strokeGeometry: [],
arcData: null,
textAlignVertical: 'TOP',
textAutoResize: 'NONE',
textCase: 'ORIGINAL',
textDecoration: 'NONE',
maxLines: null,
styleRuns: [],
horizontalConstraint: 'MIN',
verticalConstraint: 'MIN',
strokeCap: 'NONE',
strokeJoin: 'MITER',
dashPattern: [],
borderTopWeight: 0,
borderRightWeight: 0,
borderBottomWeight: 0,
borderLeftWeight: 0,
independentStrokeWeights: false,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT,
minWidth: null,
maxWidth: null,
minHeight: null,
maxHeight: null,
isMask: false,
maskType: 'ALPHA',
counterAxisAlignContent: 'AUTO',
itemReverseZIndex: false,
strokesIncludedInLayout: false,
expanded: true,
textTruncation: 'DISABLED',
autoRename: true,
pointCount: 5,
starInnerRadius: 0.38,
componentId: null,
overrides: {},
boundVariables: {},
internalOnly: false,
flipX: false,
flipY: false,
textPicture: null,
...overrides
}
}
const CONTAINER_TYPES = new Set<NodeType>([
'CANVAS',
'FRAME',
'GROUP',
'SECTION',
'COMPONENT',
'COMPONENT_SET',
'INSTANCE'
])
export class SceneGraph {
nodes = new Map<string, SceneNode>()
images = new Map<string, Uint8Array>()
variables = new Map<string, Variable>()
variableCollections = new Map<string, VariableCollection>()
activeMode = new Map<string, string>()
rootId: string
private absPosCache = new Map<string, { x: number; y: number }>()
constructor() {
const root = createDefaultNode('FRAME', {
name: 'Document',
width: 0,
height: 0
})
this.rootId = root.id
this.nodes.set(root.id, root)
this.addPage('Page 1')
}
addPage(name: string): SceneNode {
return this.createNode('CANVAS', this.rootId, { name, width: 0, height: 0 })
}
getPages(includeInternal = false): SceneNode[] {
return this.getChildren(this.rootId).filter(
(n) => n.type === 'CANVAS' && (includeInternal || !n.internalOnly)
)
}
getAllNodes(): Iterable<SceneNode> {
return this.nodes.values()
}
getNode(id: string): SceneNode | undefined {
return this.nodes.get(id)
}
// --- Variables ---
addVariable(variable: Variable): void {
this.variables.set(variable.id, variable)
const collection = this.variableCollections.get(variable.collectionId)
if (collection && !collection.variableIds.includes(variable.id)) {
collection.variableIds.push(variable.id)
}
}
removeVariable(id: string): void {
const variable = this.variables.get(id)
if (!variable) return
this.variables.delete(id)
const collection = this.variableCollections.get(variable.collectionId)
if (collection) {
collection.variableIds = collection.variableIds.filter((vid) => vid !== id)
}
for (const node of this.nodes.values()) {
for (const [field, varId] of Object.entries(node.boundVariables)) {
if (varId === id) delete node.boundVariables[field]
}
}
}
addCollection(collection: VariableCollection): void {
this.variableCollections.set(collection.id, collection)
if (!this.activeMode.has(collection.id)) {
this.activeMode.set(collection.id, collection.defaultModeId)
}
}
createVariable(
name: string,
type: VariableType,
collectionId: string,
value?: VariableValue
): Variable {
const collection = this.variableCollections.get(collectionId)
if (!collection) throw new Error(`Collection "${collectionId}" not found`)
const id = generateId()
const defaultValue =
value ??
(type === 'COLOR' ? { ...BLACK } : type === 'FLOAT' ? 0 : type === 'BOOLEAN' ? false : '')
const valuesByMode: Record<string, VariableValue> = {}
for (const mode of collection.modes) {
valuesByMode[mode.modeId] = structuredClone(defaultValue)
}
const variable: Variable = {
id,
name,
type,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
}
this.addVariable(variable)
return variable
}
createCollection(name: string): VariableCollection {
const id = generateId()
const modeId = generateId()
const collection: VariableCollection = {
id,
name,
modes: [{ modeId, name: 'Mode 1' }],
defaultModeId: modeId,
variableIds: []
}
this.addCollection(collection)
return collection
}
removeCollection(id: string): void {
const collection = this.variableCollections.get(id)
if (collection) {
for (const varId of [...collection.variableIds]) {
this.removeVariable(varId)
}
}
this.variableCollections.delete(id)
this.activeMode.delete(id)
}
getActiveModeId(collectionId: string): string {
const mode = this.activeMode.get(collectionId)
if (mode) return mode
const collection = this.variableCollections.get(collectionId)
return collection?.defaultModeId ?? ''
}
setActiveMode(collectionId: string, modeId: string): void {
this.activeMode.set(collectionId, modeId)
}
resolveVariable(
variableId: string,
modeId?: string,
visited?: Set<string>
): VariableValue | undefined {
if (visited?.has(variableId)) return undefined
const variable = this.variables.get(variableId)
if (!variable) return undefined
const resolvedModeId = modeId ?? this.getActiveModeId(variable.collectionId)
const value = variable.valuesByMode[resolvedModeId]
if (value === undefined) return undefined
if (typeof value === 'object' && value !== null && 'aliasId' in value) {
const seen = visited ?? new Set<string>()
seen.add(variableId)
return this.resolveVariable(value.aliasId, undefined, seen)
}
return value
}
resolveColorVariable(variableId: string): Color | undefined {
const value = this.resolveVariable(variableId)
if (value && typeof value === 'object' && 'r' in value) return value as Color
return undefined
}
resolveNumberVariable(variableId: string): number | undefined {
const value = this.resolveVariable(variableId)
return typeof value === 'number' ? value : undefined
}
getVariablesForCollection(collectionId: string): Variable[] {
const collection = this.variableCollections.get(collectionId)
if (!collection) return []
return collection.variableIds
.map((id) => this.variables.get(id))
.filter((v): v is Variable => v !== undefined)
}
getVariablesByType(type: VariableType): Variable[] {
return [...this.variables.values()].filter((v) => v.type === type)
}
bindVariable(nodeId: string, field: string, variableId: string): void {
const node = this.nodes.get(nodeId)
if (node) node.boundVariables[field] = variableId
}
unbindVariable(nodeId: string, field: string): void {
const node = this.nodes.get(nodeId)
if (node) delete node.boundVariables[field]
}
getChildren(id: string): SceneNode[] {
const node = this.nodes.get(id)
if (!node) return []
return node.childIds
.map((cid) => this.nodes.get(cid))
.filter((n): n is SceneNode => n !== undefined)
}
isContainer(id: string): boolean {
const node = this.nodes.get(id)
return node ? CONTAINER_TYPES.has(node.type) : false
}
isDescendant(childId: string, ancestorId: string): boolean {
let current = this.nodes.get(childId)
while (current) {
if (current.id === ancestorId) return true
current = current.parentId ? this.nodes.get(current.parentId) : undefined
}
return false
}
clearAbsPosCache(): void {
this.absPosCache.clear()
}
getAbsolutePosition(id: string): { x: number; y: number } {
const cached = this.absPosCache.get(id)
if (cached) return cached
let ax = 0
let ay = 0
let current = this.nodes.get(id)
while (current && current.id !== this.rootId && current.type !== 'CANVAS') {
ax += current.x
ay += current.y
current = current.parentId ? this.nodes.get(current.parentId) : undefined
}
const result = { x: ax, y: ay }
this.absPosCache.set(id, result)
return result
}
getAbsoluteBounds(id: string): Rect {
const pos = this.getAbsolutePosition(id)
const node = this.nodes.get(id)
return {
x: pos.x,
y: pos.y,
width: node?.width ?? 0,
height: node?.height ?? 0
}
}
createNode(type: NodeType, parentId: string, overrides: Partial<SceneNode> = {}): SceneNode {
const node = createDefaultNode(type, overrides)
node.parentId = parentId
this.nodes.set(node.id, node)
const parent = this.nodes.get(parentId)
if (parent) {
parent.childIds.push(node.id)
}
return node
}
updateNode(id: string, changes: Partial<SceneNode>): void {
const node = this.nodes.get(id)
if (!node) return
this.absPosCache.clear()
Object.assign(node, changes)
}
reparentNode(nodeId: string, newParentId: string): void {
const node = this.nodes.get(nodeId)
if (!node || nodeId === this.rootId) return
if (this.isDescendant(newParentId, nodeId)) return
const oldParent = node.parentId ? this.nodes.get(node.parentId) : undefined
const newParent = this.nodes.get(newParentId)
if (!newParent) return
if (node.parentId === newParentId) return
this.absPosCache.clear()
// Convert absolute position
const absPos = this.getAbsolutePosition(nodeId)
const newParentNode = this.nodes.get(newParentId)
const newParentAbs =
newParentId === this.rootId || newParentNode?.type === 'CANVAS'
? { x: 0, y: 0 }
: this.getAbsolutePosition(newParentId)
// Remove from old parent
if (oldParent) {
oldParent.childIds = oldParent.childIds.filter((cid) => cid !== nodeId)
}
// Add to new parent
node.parentId = newParentId
newParent.childIds.push(nodeId)
// Adjust position so node stays in same visual place
node.x = absPos.x - newParentAbs.x
node.y = absPos.y - newParentAbs.y
}
reorderChild(nodeId: string, parentId: string, insertIndex: number): void {
const node = this.nodes.get(nodeId)
if (!node) return
const oldParent = node.parentId ? this.nodes.get(node.parentId) : undefined
const newParent = this.nodes.get(parentId)
if (!newParent) return
// Remove from old parent
if (oldParent) {
oldParent.childIds = oldParent.childIds.filter((cid) => cid !== nodeId)
}
// If same parent, adjust index since we removed the item
let idx = insertIndex
if (
oldParent === newParent &&
idx > (oldParent.childIds.indexOf(nodeId) === -1 ? idx : oldParent.childIds.length)
) {
// Already removed above, no adjustment needed
}
node.parentId = parentId
idx = Math.min(idx, newParent.childIds.length)
newParent.childIds.splice(idx, 0, nodeId)
}
deleteNode(id: string): void {
const node = this.nodes.get(id)
if (!node || id === this.rootId) return
if (node.parentId) {
const parent = this.nodes.get(node.parentId)
if (parent) {
parent.childIds = parent.childIds.filter((cid) => cid !== id)
}
}
for (const childId of Array.from(node.childIds)) {
this.deleteNode(childId)
}
this.nodes.delete(id)
}
hitTest(px: number, py: number, scopeId?: string): SceneNode | null {
const scope = scopeId ?? this.rootId
return this.hitTestChildren(px, py, scope, 0, 0, false)
}
hitTestDeep(px: number, py: number, scopeId?: string): SceneNode | null {
const scope = scopeId ?? this.rootId
return this.hitTestChildren(px, py, scope, 0, 0, true)
}
private static readonly OPAQUE_CONTAINER_TYPES = new Set<NodeType>(['COMPONENT', 'INSTANCE'])
private hitTestChildren(
px: number,
py: number,
parentId: string,
offsetX: number,
offsetY: number,
deep = false
): SceneNode | null {
const parent = this.nodes.get(parentId)
if (!parent) return null
// Reverse order = topmost first
for (let i = parent.childIds.length - 1; i >= 0; i--) {
const childId = parent.childIds[i]
const child = this.nodes.get(childId)
if (!child || !child.visible) continue
const ax = offsetX + child.x
const ay = offsetY + child.y
// Components/instances: don't recurse unless in deep mode (double-click)
if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
return child
}
continue
}
// Check children first (deeper hit)
if (CONTAINER_TYPES.has(child.type)) {
const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep)
if (deepHit) return deepHit
}
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
return child
}
}
return null
}
hitTestFrame(
px: number,
py: number,
excludeIds: Set<string>,
scopeId?: string
): SceneNode | null {
return this.hitTestFrameChildren(px, py, scopeId ?? this.rootId, 0, 0, excludeIds)
}
private hitTestFrameChildren(
px: number,
py: number,
parentId: string,
offsetX: number,
offsetY: number,
excludeIds: Set<string>
): SceneNode | null {
const parent = this.nodes.get(parentId)
if (!parent) return null
// Deepest matching frame wins
let best: SceneNode | null = null
for (const childId of parent.childIds) {
if (excludeIds.has(childId)) continue
const child = this.nodes.get(childId)
if (!child || !child.visible) continue
const ax = offsetX + child.x
const ay = offsetY + child.y
if (!CONTAINER_TYPES.has(child.type)) continue
if (px < ax || px > ax + child.width || py < ay || py > ay + child.height) continue
best = child
const deeper = this.hitTestFrameChildren(px, py, childId, ax, ay, excludeIds)
if (deeper) best = deeper
}
return best
}
cloneTree(
sourceId: string,
parentId: string,
overrides: Partial<SceneNode> = {}
): SceneNode | null {
const src = this.nodes.get(sourceId)
if (!src) return null
const { id: _srcId, parentId: _srcParent, childIds: _srcChildren, ...rest } = src
const clone = this.createNode(src.type, parentId, { ...rest, ...overrides })
for (const childId of src.childIds) {
this.cloneTree(childId, clone.id)
}
return clone
}
private static readonly INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [
'width',
'height',
'fills',
'strokes',
'effects',
'opacity',
'cornerRadius',
'topLeftRadius',
'topRightRadius',
'bottomRightRadius',
'bottomLeftRadius',
'independentCorners',
'layoutMode',
'layoutWrap',
'primaryAxisAlign',
'counterAxisAlign',
'primaryAxisSizing',
'counterAxisSizing',
'itemSpacing',
'counterAxisSpacing',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'clipsContent',
'independentStrokeWeights',
'borderTopWeight',
'borderRightWeight',
'borderBottomWeight',
'borderLeftWeight'
]
private static copyProp<K extends keyof SceneNode>(
target: Partial<SceneNode> | SceneNode,
source: SceneNode,
key: K
): void {
const val = source[key]
target[key] = (Array.isArray(val) ? structuredClone(val) : val) as SceneNode[K]
}
createInstance(
componentId: string,
parentId: string,
overrides: Partial<SceneNode> = {}
): SceneNode | null {
const component = this.nodes.get(componentId)
if (!component || component.type !== 'COMPONENT') return null
const props: Partial<SceneNode> = { name: component.name, componentId }
for (const key of SceneGraph.INSTANCE_SYNC_PROPS) {
SceneGraph.copyProp(props, component, key)
}
const instance = this.createNode('INSTANCE', parentId, { ...props, ...overrides })
this.cloneChildrenWithMapping(component.id, instance.id)
return instance
}
populateInstanceChildren(instanceId: string, componentId: string): void {
const instance = this.nodes.get(instanceId)
const component = this.nodes.get(componentId)
if (!instance || !component || instance.type !== 'INSTANCE') return
this.cloneChildrenWithMapping(componentId, instanceId)
}
private cloneChildrenWithMapping(sourceParentId: string, destParentId: string): void {
const sourceParent = this.nodes.get(sourceParentId)
if (!sourceParent) return
for (const childId of sourceParent.childIds) {
const src = this.nodes.get(childId)
if (!src) continue
const { id: _, parentId: _p, childIds: _c, ...rest } = src
const clone = this.createNode(src.type, destParentId, {
...rest,
componentId: childId
})
if (src.childIds.length > 0) {
this.cloneChildrenWithMapping(childId, clone.id)
}
}
}
syncInstances(componentId: string): void {
const component = this.nodes.get(componentId)
if (!component || component.type !== 'COMPONENT') return
for (const instance of this.getInstances(componentId)) {
// Sync instance-level props (unless overridden)
for (const key of SceneGraph.INSTANCE_SYNC_PROPS) {
if (key in instance.overrides) continue
SceneGraph.copyProp(instance, component, key)
}
// Sync children: match by componentId
this.syncChildren(component.id, instance.id, instance.overrides)
}
}
private syncChildren(
compParentId: string,
instParentId: string,
overrides: Record<string, unknown>
): void {
const compParent = this.nodes.get(compParentId)
const instParent = this.nodes.get(instParentId)
if (!compParent || !instParent) return
const instChildMap = new Map<string, SceneNode>()
for (const childId of instParent.childIds) {
const child = this.nodes.get(childId)
if (child?.componentId) instChildMap.set(child.componentId, child)
}
// Add new children from component that don't exist in instance
for (const compChildId of compParent.childIds) {
if (!instChildMap.has(compChildId)) {
const src = this.nodes.get(compChildId)
if (!src) continue
const { id: _, parentId: _p, childIds: _c, ...rest } = src
const clone = this.createNode(src.type, instParentId, {
...rest,
componentId: compChildId
})
if (src.childIds.length > 0) {
this.cloneChildrenWithMapping(compChildId, clone.id)
}
instChildMap.set(compChildId, clone)
}
}
// Sync existing children
for (const compChildId of compParent.childIds) {
const compChild = this.nodes.get(compChildId)
const instChild = instChildMap.get(compChildId)
if (!compChild || !instChild) continue
for (const key of SceneGraph.INSTANCE_SYNC_PROPS) {
const overrideKey = `${instChild.id}:${key}`
if (overrideKey in overrides) continue
SceneGraph.copyProp(instChild, compChild, key)
}
for (const key of ['name', 'text', 'fontSize', 'fontWeight', 'fontFamily'] as const) {
const overrideKey = `${instChild.id}:${key}`
if (overrideKey in overrides) continue
SceneGraph.copyProp(instChild, compChild, key)
}
if (compChild.childIds.length > 0) {
this.syncChildren(compChildId, instChild.id, overrides)
}
}
// Reorder instance children to match component order
const compChildOrder = compParent.childIds
instParent.childIds.sort((a, b) => {
const nodeA = this.nodes.get(a)
const nodeB = this.nodes.get(b)
const idxA = nodeA?.componentId ? compChildOrder.indexOf(nodeA.componentId) : -1
const idxB = nodeB?.componentId ? compChildOrder.indexOf(nodeB.componentId) : -1
return idxA - idxB
})
}
detachInstance(instanceId: string): void {
const node = this.nodes.get(instanceId)
if (!node || node.type !== 'INSTANCE') return
node.type = 'FRAME'
node.componentId = null
node.overrides = {}
}
getMainComponent(instanceId: string): SceneNode | undefined {
const node = this.nodes.get(instanceId)
if (!node?.componentId) return undefined
return this.nodes.get(node.componentId)
}
getInstances(componentId: string): SceneNode[] {
const instances: SceneNode[] = []
for (const node of this.nodes.values()) {
if (node.type === 'INSTANCE' && node.componentId === componentId) {
instances.push(node)
}
}
return instances
}
flattenTree(parentId?: string, depth = 0): Array<{ node: SceneNode; depth: number }> {
const id = parentId ?? this.rootId
const parent = this.nodes.get(id)
if (!parent) return []
const result: Array<{ node: SceneNode; depth: number }> = []
for (const childId of parent.childIds) {
const child = this.nodes.get(childId)
if (!child) continue
result.push({ node: child, depth })
if (child.childIds.length > 0) {
result.push(...this.flattenTree(childId, depth + 1))
}
}
return result
}
}