openpencil/src/engine/scene-graph.ts
Danila Poyarkov e2e0e75173 Tier 1 rendering parity: gradients, images, effects, strokes, arcs
Data model:
- Gradient fills (LINEAR, RADIAL, ANGULAR, DIAMOND) with stops + transform
- Image fills with hash reference, scale mode, transform
- Blend modes on fills, nodes, effects
- Stroke cap, join, dash pattern, independent border weights
- Arc data for partial ellipses/donuts
- Text: vertical align, auto-resize, case, decoration, max lines
- Constraints: horizontal/vertical
- New node types: ROUNDED_RECTANGLE, COMPONENT, COMPONENT_SET,
  INSTANCE, CONNECTOR, SHAPE_WITH_TEXT

Import:
- All paint types imported (not just SOLID)
- Image hashes extracted from .fig ZIP images/ directory
- Font weight mapping (Thin→100 through Black→900)
- Arc data, stroke options, constraints, text properties

Renderer:
- Gradient shaders via CanvasKit (linear, radial, sweep)
- Image fills with FILL/FIT scale modes and shader matrix
- Drop shadow and inner shadow effects
- Arc/donut rendering for ellipses with arcData
- Stroke cap/join/dash pattern support
- Image cache with cleanup on destroy

Export:
- Gradient/image fills round-trip through Kiwi
- New node types mapped to Figma equivalents

UI:
- Fill section shows gradient/image type labels for non-SOLID fills
2026-02-28 16:40:34 +03:00

544 lines
14 KiB
TypeScript

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 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 } 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 interface GradientTransform {
m00: number
m01: number
m02: number
m10: number
m11: number
m12: number
}
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 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 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
textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
textAlignVertical: TextAlignVertical
textAutoResize: TextAutoResize
textCase: TextCase
textDecoration: TextDecoration
lineHeight: number | null
letterSpacing: number
maxLines: number | null
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
arcData: ArcData | null
strokeCap: StrokeCap
strokeJoin: StrokeJoin
dashPattern: number[]
borderTopWeight: number
borderRightWeight: number
borderBottomWeight: number
borderLeftWeight: number
independentStrokeWeights: boolean
}
let nextLocalID = 1
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: 'Inter',
fontWeight: 400,
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,
arcData: null,
textAlignVertical: 'TOP',
textAutoResize: 'NONE',
textCase: 'ORIGINAL',
textDecoration: 'NONE',
maxLines: null,
horizontalConstraint: 'MIN',
verticalConstraint: 'MIN',
strokeCap: 'NONE',
strokeJoin: 'MITER',
dashPattern: [],
borderTopWeight: 0,
borderRightWeight: 0,
borderBottomWeight: 0,
borderLeftWeight: 0,
independentStrokeWeights: false,
...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>()
rootId: string
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(): SceneNode[] {
return this.getChildren(this.rootId).filter((n) => n.type === 'CANVAS')
}
getNode(id: string): SceneNode | undefined {
return this.nodes.get(id)
}
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
}
getAbsolutePosition(id: string): { x: number; y: number } {
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
}
return { x: ax, y: ay }
}
getAbsoluteBounds(id: string): { x: number; y: number; width: number; height: number } {
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
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
// 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)
}
private hitTestChildren(
px: number,
py: number,
parentId: string,
offsetX: number,
offsetY: number
): 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
// Check children first (deeper hit)
if (CONTAINER_TYPES.has(child.type)) {
const deepHit = this.hitTestChildren(px, py, childId, ax, ay)
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
}
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
}
}