diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts new file mode 100644 index 000000000..2e50be8d1 --- /dev/null +++ b/packages/core/src/geometry.ts @@ -0,0 +1,73 @@ +import type { Vector } from './types' + +export function degToRad(degrees: number): number { + return (degrees * Math.PI) / 180 +} + +export function radToDeg(radians: number): number { + return (radians * 180) / Math.PI +} + +export function rotatePoint( + px: number, + py: number, + cx: number, + cy: number, + rad: number +): Vector { + const cos = Math.cos(rad) + const sin = Math.sin(rad) + return { + x: cx + (px - cx) * cos - (py - cy) * sin, + y: cy + (px - cx) * sin + (py - cy) * cos + } +} + +export function rotatedCorners( + cx: number, + cy: number, + hw: number, + hh: number, + rotationDeg: number +): [Vector, Vector, Vector, Vector] { + const rad = degToRad(rotationDeg) + const cos = Math.cos(rad) + const sin = Math.sin(rad) + return [ + { x: cx + -hw * cos - -hh * sin, y: cy + -hw * sin + -hh * cos }, + { x: cx + hw * cos - -hh * sin, y: cy + hw * sin + -hh * cos }, + { x: cx + hw * cos - hh * sin, y: cy + hw * sin + hh * cos }, + { x: cx + -hw * cos - hh * sin, y: cy + -hw * sin + hh * cos } + ] +} + +export function rotatedBBox( + x: number, + y: number, + w: number, + h: number, + rotationDeg: number +): { left: number; right: number; top: number; bottom: number; centerX: number; centerY: number } { + if (rotationDeg === 0) { + return { + left: x, + right: x + w, + top: y, + bottom: y + h, + centerX: x + w / 2, + centerY: y + h / 2 + } + } + const corners = rotatedCorners(x + w / 2, y + h / 2, w / 2, h / 2, rotationDeg) + let left = Infinity, + right = -Infinity, + top = Infinity, + bottom = -Infinity + for (const c of corners) { + left = Math.min(left, c.x) + right = Math.max(right, c.x) + top = Math.min(top, c.y) + bottom = Math.max(bottom, c.y) + } + return { left, right, top, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index de13bbe3a..4d7b6c7ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,7 +50,15 @@ export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api' export { ALL_TOOLS, defineTool, toolsToAI } from './tools' export type { ToolDef, ParamDef, ParamType } from './tools' export { SkiaRenderer, type RenderOverlays } from './renderer/index' -export { RenderProfiler } from './profiler' +export { + RenderProfiler, + FrameStats, + GPUTimer, + DrawCallCounter, + PhaseTimer, + CaptureStack, + toSpeedscopeJSON +} from './profiler' export type { FrameCapture, NodeProfile } from './profiler' export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout' export type { TextMeasurer } from './layout' @@ -81,6 +89,10 @@ export { computeSelectionBounds, computeSnap, type SnapGuide } from './snap' export { UndoManager } from './undo' export { TextEditor, type TextCaret, type TextEditorState } from './text-editor' export { + getStyleAt, + applyStyleToRange, + removeStyleFromRange, + selectionHasStyle, toggleBoldInRange, toggleItalicInRange, toggleDecorationInRange, diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index 7e16d50a7..5bb98a134 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -58,8 +58,8 @@ export const render = defineTool({ parent_id: { type: 'string', description: 'Parent node ID to render into' } }, execute: async (figma, args) => { - const { renderJsx } = await import('../render/render-jsx.js') - const result = await renderJsx(figma.graph, args.jsx, { + const { renderJSX } = await import('../render/render-jsx.js') + const result = await renderJSX(figma.graph, args.jsx, { parentId: args.parent_id ?? figma.currentPageId, x: args.x, y: args.y diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index f016a1229..72d19d0c0 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -1,3 +1,4 @@ -export { ALL_TOOLS, defineTool } from './schema' +export { ALL_TOOLS } from './registry' +export { defineTool, nodeToResult, nodeSummary } from './schema' export type { ToolDef, ParamDef, ParamType } from './schema' export { toolsToAI } from './ai-adapter' diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts index e0df1ede5..2a3cf0ef6 100644 --- a/packages/core/src/tools/schema.ts +++ b/packages/core/src/tools/schema.ts @@ -6,8 +6,6 @@ * and MCP (JSON Schema) are generated from these definitions. */ -import { parseColor } from '../color' -import { DEFAULT_SHADOW_COLOR } from '../constants' import type { FigmaAPI, FigmaNodeProxy } from '../figma-api' export type ParamType = 'string' | 'number' | 'boolean' | 'color' | 'string[]' @@ -65,1767 +63,3 @@ export function nodeToResult(node: FigmaNodeProxy): Record { export function nodeSummary(node: FigmaNodeProxy): { id: string; name: string; type: string } { return { id: node.id, name: node.name, type: node.type } } - -// ─── Read tools ─────────────────────────────────────────────── - -export const getSelection = defineTool({ - name: 'get_selection', - description: 'Get details about currently selected nodes.', - params: {}, - execute: (figma) => { - const sel = figma.currentPage.selection - return { selection: sel.map(nodeToResult) } - } -}) - -export const getPageTree = defineTool({ - name: 'get_page_tree', - description: - 'Get the node tree of the current page. Returns all nodes with hierarchy, types, positions, and sizes.', - params: {}, - execute: (figma) => { - const page = figma.currentPage - return { - page: page.name, - children: page.children.map(nodeToResult) - } - } -}) - -export const getNode = defineTool({ - name: 'get_node', - description: 'Get detailed properties of a node by ID.', - params: { - id: { type: 'string', description: 'Node ID', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - return nodeToResult(node) - } -}) - -export const findNodes = defineTool({ - name: 'find_nodes', - description: 'Find nodes by name pattern and/or type.', - params: { - name: { type: 'string', description: 'Name substring to match (case-insensitive)' }, - type: { - type: 'string', - description: 'Node type filter', - enum: [ - 'FRAME', - 'RECTANGLE', - 'ELLIPSE', - 'TEXT', - 'LINE', - 'STAR', - 'POLYGON', - 'SECTION', - 'GROUP', - 'COMPONENT', - 'INSTANCE', - 'VECTOR' - ] - } - }, - execute: (figma, args) => { - const page = figma.currentPage - const matches = page.findAll((node) => { - if (args.type && node.type !== args.type) return false - if (args.name && !node.name.toLowerCase().includes(args.name.toLowerCase())) return false - return true - }) - return { count: matches.length, nodes: matches.map(nodeSummary) } - } -}) - -// ─── Create tools ───────────────────────────────────────────── - -export const createShape = defineTool({ - name: 'create_shape', - description: - 'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.', - params: { - type: { - type: 'string', - description: 'Node type', - required: true, - enum: ['FRAME', 'RECTANGLE', 'ELLIPSE', 'TEXT', 'LINE', 'STAR', 'POLYGON', 'SECTION'] - }, - x: { type: 'number', description: 'X position', required: true }, - y: { type: 'number', description: 'Y position', required: true }, - width: { type: 'number', description: 'Width in pixels', required: true, min: 1 }, - height: { type: 'number', description: 'Height in pixels', required: true, min: 1 }, - name: { type: 'string', description: 'Node name shown in layers panel' }, - parent_id: { type: 'string', description: 'Parent node ID to nest inside' } - }, - execute: (figma, args) => { - const parentId = args.parent_id - const parent = parentId ? figma.getNodeById(parentId) : null - const createMap: Record FigmaNodeProxy> = { - FRAME: () => figma.createFrame(), - RECTANGLE: () => figma.createRectangle(), - ELLIPSE: () => figma.createEllipse(), - TEXT: () => figma.createText(), - LINE: () => figma.createLine(), - STAR: () => figma.createStar(), - POLYGON: () => figma.createPolygon(), - SECTION: () => figma.createSection() - } - const node = createMap[args.type]!() - node.x = args.x - node.y = args.y - node.resize(args.width, args.height) - if (args.name) node.name = args.name - if (parent) parent.appendChild(node) - return nodeSummary(node) - } -}) - -export const render = defineTool({ - name: 'render', - description: - 'Render JSX to design nodes. Primary creation tool — creates entire component trees in one call. Example: Title', - params: { - jsx: { type: 'string', description: 'JSX string to render', required: true }, - x: { type: 'number', description: 'X position of the root node' }, - y: { type: 'number', description: 'Y position of the root node' }, - parent_id: { type: 'string', description: 'Parent node ID to render into' } - }, - execute: async (figma, args) => { - const { renderJSX } = await import('../render/render-jsx.js') - const result = await renderJSX(figma.graph, args.jsx, { - parentId: args.parent_id ?? figma.currentPageId, - x: args.x, - y: args.y - }) - return { id: result.id, name: result.name, type: result.type, children: result.childIds } - } -}) - -// ─── Modify tools ───────────────────────────────────────────── - -export const setFill = defineTool({ - name: 'set_fill', - description: 'Set the fill color of a node. Accepts hex (#ff0000) or named color.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - color: { type: 'color', description: 'Color value (hex like #ff0000)', required: true } - }, - execute: (figma, { id, color }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - - const c = parseColor(color) - node.fills = [{ type: 'SOLID', color: c, opacity: 1, visible: true }] - return { id, color: c } - } -}) - -export const setStroke = defineTool({ - name: 'set_stroke', - description: 'Set the stroke (border) of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - color: { type: 'color', description: 'Stroke color (hex)', required: true }, - weight: { type: 'number', description: 'Stroke weight', default: 1, min: 0.1 }, - align: { - type: 'string', - description: 'Stroke alignment', - default: 'INSIDE', - enum: ['INSIDE', 'CENTER', 'OUTSIDE'] - } - }, - execute: (figma, { id, color, weight, align }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - - const c = parseColor(color) - node.strokes = [ - { - color: c, - weight: weight ?? 1, - opacity: 1, - visible: true, - align: (align ?? 'INSIDE') as 'INSIDE' | 'CENTER' | 'OUTSIDE' - } - ] - return { id, color: c, weight: weight ?? 1 } - } -}) - -export const setEffects = defineTool({ - name: 'set_effects', - description: - 'Set effects on a node (drop shadow, inner shadow, blur). Pass an array or a single effect.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - type: { - type: 'string', - description: 'Effect type', - required: true, - enum: ['DROP_SHADOW', 'INNER_SHADOW', 'FOREGROUND_BLUR', 'BACKGROUND_BLUR'] - }, - color: { type: 'color', description: 'Shadow color (hex). Ignored for blur.' }, - offset_x: { type: 'number', description: 'Shadow X offset', default: 0 }, - offset_y: { type: 'number', description: 'Shadow Y offset', default: 4 }, - radius: { type: 'number', description: 'Blur radius', default: 4, min: 0 }, - spread: { type: 'number', description: 'Shadow spread', default: 0 } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - - const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR' - const effect: Record = { - type: args.type, - visible: true, - radius: args.radius ?? 4 - } - - if (!isBlur) { - effect.color = args.color ? parseColor(args.color) : { ...DEFAULT_SHADOW_COLOR } - effect.offset = { x: args.offset_x ?? 0, y: args.offset_y ?? 4 } - effect.spread = args.spread ?? 0 - } - - node.effects = [...node.effects, effect as any] - return { id: args.id, effects: node.effects.length } - } -}) - -export const updateNode = defineTool({ - name: 'update_node', - description: - 'Update properties of an existing node: position, size, opacity, corner radius, visibility, text, font.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - x: { type: 'number', description: 'X position' }, - y: { type: 'number', description: 'Y position' }, - width: { type: 'number', description: 'Width', min: 1 }, - height: { type: 'number', description: 'Height', min: 1 }, - opacity: { type: 'number', description: 'Opacity (0-1)', min: 0, max: 1 }, - corner_radius: { type: 'number', description: 'Corner radius', min: 0 }, - visible: { type: 'boolean', description: 'Visibility' }, - text: { type: 'string', description: 'Text content (TEXT nodes)' }, - font_size: { type: 'number', description: 'Font size', min: 1 }, - font_weight: { type: 'number', description: 'Font weight (100-900)' }, - name: { type: 'string', description: 'Layer name' } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - const updated: string[] = [] - if (args.x !== undefined) { - node.x = args.x - updated.push('x') - } - if (args.y !== undefined) { - node.y = args.y - updated.push('y') - } - if (args.width !== undefined || args.height !== undefined) { - node.resize(args.width ?? node.width, args.height ?? node.height) - updated.push('size') - } - if (args.opacity !== undefined) { - node.opacity = args.opacity - updated.push('opacity') - } - if (args.corner_radius !== undefined) { - node.cornerRadius = args.corner_radius - updated.push('cornerRadius') - } - if (args.visible !== undefined) { - node.visible = args.visible - updated.push('visible') - } - if (args.name !== undefined) { - node.name = args.name - updated.push('name') - } - if (args.text !== undefined) { - figma.graph.updateNode(node.id, { text: args.text }) - updated.push('text') - } - if (args.font_size !== undefined) { - figma.graph.updateNode(node.id, { fontSize: args.font_size }) - updated.push('fontSize') - } - if (args.font_weight !== undefined) { - figma.graph.updateNode(node.id, { fontWeight: args.font_weight }) - updated.push('fontWeight') - } - return { id: args.id, updated } - } -}) - -export const setLayout = defineTool({ - name: 'set_layout', - description: 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, padding.', - params: { - id: { type: 'string', description: 'Frame node ID', required: true }, - direction: { - type: 'string', - description: 'Layout direction', - required: true, - enum: ['HORIZONTAL', 'VERTICAL'] - }, - spacing: { type: 'number', description: 'Gap between items', default: 0, min: 0 }, - padding: { type: 'number', description: 'Equal padding on all sides', min: 0 }, - padding_horizontal: { type: 'number', description: 'Horizontal padding', min: 0 }, - padding_vertical: { type: 'number', description: 'Vertical padding', min: 0 }, - align: { - type: 'string', - description: 'Primary axis alignment', - default: 'MIN', - enum: ['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN'] - }, - counter_align: { - type: 'string', - description: 'Cross axis alignment', - default: 'MIN', - enum: ['MIN', 'CENTER', 'MAX', 'STRETCH'] - } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - - node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL' - node.itemSpacing = args.spacing ?? 0 - node.primaryAxisAlignItems = (args.align ?? 'MIN') as any - node.counterAxisAlignItems = (args.counter_align ?? 'MIN') as any - - const ph = args.padding_horizontal ?? args.padding ?? 0 - const pv = args.padding_vertical ?? args.padding ?? 0 - node.paddingLeft = ph - node.paddingRight = ph - node.paddingTop = pv - node.paddingBottom = pv - - return { id: args.id, direction: args.direction, spacing: args.spacing ?? 0 } - } -}) - -export const setConstraints = defineTool({ - name: 'set_constraints', - description: 'Set resize constraints for a node within its parent.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - horizontal: { - type: 'string', - description: 'Horizontal constraint', - enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE'] - }, - vertical: { - type: 'string', - description: 'Vertical constraint', - enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE'] - } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - if (args.horizontal || args.vertical) { - node.constraints = { - horizontal: args.horizontal ?? node.constraints.horizontal, - vertical: args.vertical ?? node.constraints.vertical - } - } - return { id: args.id, constraints: node.constraints } - } -}) - -// ─── Structure tools ────────────────────────────────────────── - -export const deleteNode = defineTool({ - name: 'delete_node', - description: 'Delete a node by ID.', - params: { - id: { type: 'string', description: 'Node ID to delete', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.remove() - return { deleted: id } - } -}) - -export const cloneNode = defineTool({ - name: 'clone_node', - description: 'Clone (duplicate) a node.', - params: { - id: { type: 'string', description: 'Node ID to clone', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - const clone = node.clone() - return nodeSummary(clone) - } -}) - -export const renameNode = defineTool({ - name: 'rename_node', - description: 'Rename a node in the layers panel.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - name: { type: 'string', description: 'New name', required: true } - }, - execute: (figma, { id, name }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.name = name - return { id, name } - } -}) - -export const reparentNode = defineTool({ - name: 'reparent_node', - description: 'Move a node into a different parent.', - params: { - id: { type: 'string', description: 'Node ID to move', required: true }, - parent_id: { type: 'string', description: 'New parent node ID', required: true } - }, - execute: (figma, { id, parent_id }) => { - const node = figma.getNodeById(id) - const parent = figma.getNodeById(parent_id) - if (!node) return { error: `Node "${id}" not found` } - if (!parent) return { error: `Parent "${parent_id}" not found` } - parent.appendChild(node) - return { id, parent_id } - } -}) - -export const selectNodes = defineTool({ - name: 'select_nodes', - description: 'Select one or more nodes by ID.', - params: { - ids: { type: 'string[]', description: 'Node IDs to select', required: true } - }, - execute: (figma, { ids }) => { - figma.currentPage.selection = ids - .map((id) => figma.getNodeById(id)) - .filter((n): n is FigmaNodeProxy => n !== null) - return { selected: ids } - } -}) - -export const groupNodes = defineTool({ - name: 'group_nodes', - description: 'Group selected nodes.', - params: { - ids: { type: 'string[]', description: 'Node IDs to group', required: true } - }, - execute: (figma, { ids }) => { - const nodes = ids - .map((id) => figma.getNodeById(id)) - .filter((n): n is FigmaNodeProxy => n !== null) - if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' } - const parent = nodes[0]!.parent ?? figma.currentPage - const group = figma.group(nodes, parent) - return nodeSummary(group) - } -}) - -export const ungroupNode = defineTool({ - name: 'ungroup_node', - description: 'Ungroup a group node.', - params: { - id: { type: 'string', description: 'Group node ID', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - figma.ungroup(node) - return { ungrouped: id } - } -}) - -// ─── Component tools ────────────────────────────────────────── - -export const createComponent = defineTool({ - name: 'create_component', - description: 'Convert a frame/group into a component.', - params: { - id: { type: 'string', description: 'Node ID to convert', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - const comp = figma.createComponentFromNode(node) - return nodeSummary(comp) - } -}) - -export const createInstance = defineTool({ - name: 'create_instance', - description: 'Create an instance of a component.', - params: { - component_id: { type: 'string', description: 'Component node ID', required: true }, - x: { type: 'number', description: 'X position' }, - y: { type: 'number', description: 'Y position' } - }, - execute: (figma, args) => { - const comp = figma.getNodeById(args.component_id) - if (!comp) return { error: `Component "${args.component_id}" not found` } - const instance = comp.createInstance() - if (args.x !== undefined) instance.x = args.x - if (args.y !== undefined) instance.y = args.y - return nodeSummary(instance) - } -}) - -// ─── Page tools ─────────────────────────────────────────────── - -export const listPages = defineTool({ - name: 'list_pages', - description: 'List all pages in the document.', - params: {}, - execute: (figma) => { - const pages = figma.root.children - return { - current: figma.currentPage.name, - pages: pages.map((p) => ({ id: p.id, name: p.name })) - } - } -}) - -export const switchPage = defineTool({ - name: 'switch_page', - description: 'Switch to a different page by name or ID.', - params: { - page: { type: 'string', description: 'Page name or ID', required: true } - }, - execute: (figma, { page }) => { - const target = figma.root.children.find((p) => p.name === page) ?? figma.getNodeById(page) - if (!target) return { error: `Page "${page}" not found` } - figma.currentPage = target - return { page: target.name, id: target.id } - } -}) - -// ─── Variable tools ─────────────────────────────────────────── - -export const listVariables = defineTool({ - name: 'list_variables', - description: 'List all design variables (colors, numbers, strings, booleans).', - params: { - type: { - type: 'string', - description: 'Filter by variable type', - enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN'] - } - }, - execute: (figma, args) => { - const vars = figma.getLocalVariables(args.type) - return { count: vars.length, variables: vars } - } -}) - -export const listCollections = defineTool({ - name: 'list_collections', - description: 'List all variable collections.', - params: {}, - execute: (figma) => { - const cols = figma.getLocalVariableCollections() - return { count: cols.length, collections: cols } - } -}) - -// ─── Eval escape hatch ──────────────────────────────────────── - -export const evalCode = defineTool({ - name: 'eval', - description: - 'Execute JavaScript with full Figma Plugin API access. Use for operations not covered by other tools. The `figma` global is available.', - params: { - code: { type: 'string', description: 'JavaScript code to execute', required: true } - }, - execute: async (figma, { code }) => { - const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor - const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()` - const fn = new AsyncFunction('figma', wrapped) - const result = await fn(figma) - if (result && typeof result === 'object' && 'toJSON' in result) return result.toJSON() - return result ?? null - } -}) - -// ─── Granular set tools ─────────────────────────────────────── - -export const setRotation = defineTool({ - name: 'set_rotation', - description: 'Set rotation angle of a node in degrees.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - angle: { type: 'number', description: 'Rotation angle in degrees', required: true } - }, - execute: (figma, { id, angle }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.rotation = angle - return { id, rotation: angle } - } -}) - -export const setOpacity = defineTool({ - name: 'set_opacity', - description: 'Set opacity of a node (0-1).', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - value: { type: 'number', description: 'Opacity (0-1)', required: true, min: 0, max: 1 } - }, - execute: (figma, { id, value }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.opacity = value - return { id, opacity: value } - } -}) - -export const setRadius = defineTool({ - name: 'set_radius', - description: 'Set corner radius. Use individual corners for independent values.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - radius: { type: 'number', description: 'Corner radius for all corners', min: 0 }, - top_left: { type: 'number', description: 'Top-left radius', min: 0 }, - top_right: { type: 'number', description: 'Top-right radius', min: 0 }, - bottom_right: { type: 'number', description: 'Bottom-right radius', min: 0 }, - bottom_left: { type: 'number', description: 'Bottom-left radius', min: 0 } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - if (args.radius !== undefined) { - node.cornerRadius = args.radius - } - if (args.top_left !== undefined) node.topLeftRadius = args.top_left - if (args.top_right !== undefined) node.topRightRadius = args.top_right - if (args.bottom_right !== undefined) node.bottomRightRadius = args.bottom_right - if (args.bottom_left !== undefined) node.bottomLeftRadius = args.bottom_left - return { id: args.id, cornerRadius: node.cornerRadius } - } -}) - -export const setMinMax = defineTool({ - name: 'set_minmax', - description: 'Set min/max width and height constraints on a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - min_width: { type: 'number', description: 'Minimum width', min: 0 }, - max_width: { type: 'number', description: 'Maximum width', min: 0 }, - min_height: { type: 'number', description: 'Minimum height', min: 0 }, - max_height: { type: 'number', description: 'Maximum height', min: 0 } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - if (args.min_width !== undefined) node.minWidth = args.min_width - if (args.max_width !== undefined) node.maxWidth = args.max_width - if (args.min_height !== undefined) node.minHeight = args.min_height - if (args.max_height !== undefined) node.maxHeight = args.max_height - return { - id: args.id, - minWidth: node.minWidth, - maxWidth: node.maxWidth, - minHeight: node.minHeight, - maxHeight: node.maxHeight - } - } -}) - -export const setText = defineTool({ - name: 'set_text', - description: 'Set text content of a text node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - text: { type: 'string', description: 'Text content', required: true } - }, - execute: (figma, { id, text }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.characters = text - return { id, text } - } -}) - -export const setFont = defineTool({ - name: 'set_font', - description: 'Set font properties of a text node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - family: { type: 'string', description: 'Font family name' }, - size: { type: 'number', description: 'Font size', min: 1 }, - style: { type: 'string', description: 'Font style (e.g. "Bold", "Regular", "Bold Italic")' } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - if (args.size !== undefined) node.fontSize = args.size - if (args.family || args.style) { - const current = node.fontName - node.fontName = { - family: args.family ?? current.family, - style: args.style ?? current.style - } - } - return { id: args.id, fontName: node.fontName, fontSize: node.fontSize } - } -}) - -export const setFontRange = defineTool({ - name: 'set_font_range', - description: 'Set font properties for a text range.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - start: { type: 'number', description: 'Start character index', required: true, min: 0 }, - end: { type: 'number', description: 'End character index', required: true, min: 0 }, - family: { type: 'string', description: 'Font family name' }, - size: { type: 'number', description: 'Font size', min: 1 }, - style: { type: 'string', description: 'Font style' }, - color: { type: 'color', description: 'Text color (hex)' } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - const run: Record = { start: args.start, end: args.end } - if (args.family) run.fontFamily = args.family - if (args.size) run.fontSize = args.size - if (args.style) run.fontStyle = args.style - if (args.color) run.color = parseColor(args.color) - figma.graph.updateNode(node.id, { - styleRuns: [...(figma.graph.getNode(node.id)?.styleRuns ?? []), run as any] - }) - return { id: args.id, range: { start: args.start, end: args.end } } - } -}) - -export const setTextResize = defineTool({ - name: 'set_text_resize', - description: 'Set text auto-resize mode.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - mode: { - type: 'string', - description: 'Resize mode', - required: true, - enum: ['NONE', 'WIDTH_AND_HEIGHT', 'HEIGHT', 'TRUNCATE'] - } - }, - execute: (figma, { id, mode }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.textAutoResize = mode - return { id, textAutoResize: mode } - } -}) - -export const setVisible = defineTool({ - name: 'set_visible', - description: 'Set visibility of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - value: { type: 'boolean', description: 'Visible (true/false)', required: true } - }, - execute: (figma, { id, value }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.visible = value - return { id, visible: value } - } -}) - -export const setBlend = defineTool({ - name: 'set_blend', - description: 'Set blend mode of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - mode: { - type: 'string', - description: 'Blend mode', - required: true, - enum: [ - 'NORMAL', - 'DARKEN', - 'MULTIPLY', - 'COLOR_BURN', - 'LIGHTEN', - 'SCREEN', - 'COLOR_DODGE', - 'OVERLAY', - 'SOFT_LIGHT', - 'HARD_LIGHT', - 'DIFFERENCE', - 'EXCLUSION', - 'HUE', - 'SATURATION', - 'COLOR', - 'LUMINOSITY' - ] - } - }, - execute: (figma, { id, mode }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.blendMode = mode - return { id, blendMode: mode } - } -}) - -export const setLocked = defineTool({ - name: 'set_locked', - description: 'Set locked state of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - value: { type: 'boolean', description: 'Locked (true/false)', required: true } - }, - execute: (figma, { id, value }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.locked = value - return { id, locked: value } - } -}) - -export const setStrokeAlign = defineTool({ - name: 'set_stroke_align', - description: 'Set stroke alignment of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - align: { - type: 'string', - description: 'Stroke alignment', - required: true, - enum: ['INSIDE', 'CENTER', 'OUTSIDE'] - } - }, - execute: (figma, { id, align }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.strokeAlign = align - return { id, strokeAlign: align } - } -}) - -// ─── Node operation tools ───────────────────────────────────── - -export const nodeBounds = defineTool({ - name: 'node_bounds', - description: 'Get absolute bounding box of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - return { id, bounds: node.absoluteBoundingBox } - } -}) - -export const nodeMove = defineTool({ - name: 'node_move', - description: 'Move a node to new coordinates.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - x: { type: 'number', description: 'X position', required: true }, - y: { type: 'number', description: 'Y position', required: true } - }, - execute: (figma, { id, x, y }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.x = x - node.y = y - return { id, x, y } - } -}) - -export const nodeResize = defineTool({ - name: 'node_resize', - description: 'Resize a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - width: { type: 'number', description: 'Width', required: true, min: 1 }, - height: { type: 'number', description: 'Height', required: true, min: 1 } - }, - execute: (figma, { id, width, height }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - node.resize(width, height) - return { id, width, height } - } -}) - -export const nodeAncestors = defineTool({ - name: 'node_ancestors', - description: 'Get the ancestor chain from a node to the page root.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - depth: { type: 'number', description: 'Max depth to traverse' } - }, - execute: (figma, args) => { - let node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - const ancestors: { id: string; name: string; type: string }[] = [] - let current = node.parent - let d = 0 - while (current && (!args.depth || d < args.depth)) { - ancestors.push({ id: current.id, name: current.name, type: current.type }) - current = current.parent - d++ - } - return { id: args.id, ancestors } - } -}) - -export const nodeChildren = defineTool({ - name: 'node_children', - description: 'Get direct children of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true } - }, - execute: (figma, { id }) => { - const node = figma.getNodeById(id) - if (!node) return { error: `Node "${id}" not found` } - return { - id, - children: node.children.map((c) => ({ id: c.id, name: c.name, type: c.type })) - } - } -}) - -export const nodeTree = defineTool({ - name: 'node_tree', - description: 'Get a node tree with types and hierarchy.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - depth: { type: 'number', description: 'Max depth (default: unlimited)' } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - function buildTree(n: FigmaNodeProxy, d: number): Record { - const result: Record = { id: n.id, name: n.name, type: n.type } - if (args.depth === undefined || d < args.depth) { - const kids = n.children - if (kids.length > 0) result.children = kids.map((c) => buildTree(c, d + 1)) - } - return result - } - return buildTree(node, 0) - } -}) - -export const nodeBindings = defineTool({ - name: 'node_bindings', - description: 'Get variable bindings for a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true } - }, - execute: (figma, { id }) => { - const raw = figma.graph.getNode(id) - if (!raw) return { error: `Node "${id}" not found` } - return { id, bindings: raw.boundVariables } - } -}) - -export const nodeReplaceWith = defineTool({ - name: 'node_replace_with', - description: 'Replace a node with JSX content.', - params: { - id: { type: 'string', description: 'Node ID to replace', required: true }, - jsx: { type: 'string', description: 'JSX string for the replacement', required: true } - }, - execute: async (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - const parentId = node.parent?.id ?? figma.currentPageId - const x = node.x - const y = node.y - node.remove() - const { renderJSX } = await import('../render/render-jsx.js') - const result = await renderJSX(figma.graph, args.jsx, { parentId, x, y }) - return { id: result.id, name: result.name, type: result.type } - } -}) - -// ─── Variable CRUD tools ────────────────────────────────────── - -export const getVariable = defineTool({ - name: 'get_variable', - description: 'Get a variable by ID.', - params: { - id: { type: 'string', description: 'Variable ID', required: true } - }, - execute: (figma, { id }) => { - const v = figma.getVariableById(id) - if (!v) return { error: `Variable "${id}" not found` } - return v - } -}) - -export const findVariables = defineTool({ - name: 'find_variables', - description: 'Find variables by name pattern.', - params: { - query: { type: 'string', description: 'Name substring (case-insensitive)', required: true }, - type: { - type: 'string', - description: 'Filter by type', - enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN'] - } - }, - execute: (figma, args) => { - let vars = figma.getLocalVariables(args.type) - vars = vars.filter((v) => v.name.toLowerCase().includes(args.query.toLowerCase())) - return { count: vars.length, variables: vars } - } -}) - -export const createVariable = defineTool({ - name: 'create_variable', - description: 'Create a new variable in a collection.', - params: { - name: { type: 'string', description: 'Variable name', required: true }, - type: { - type: 'string', - description: 'Variable type', - required: true, - enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN'] - }, - collection_id: { type: 'string', description: 'Collection ID', required: true }, - value: { type: 'string', description: 'Initial value (hex for COLOR, number for FLOAT, etc.)' } - }, - execute: (figma, args) => { - let parsedValue: unknown - if (args.value !== undefined) { - if (args.type === 'COLOR') parsedValue = parseColor(args.value) - else if (args.type === 'FLOAT') parsedValue = Number(args.value) - else if (args.type === 'BOOLEAN') parsedValue = args.value === 'true' - else parsedValue = args.value - } - const v = figma.createVariable( - args.name, - args.type as any, - args.collection_id, - parsedValue as any - ) - return v - } -}) - -export const setVariable = defineTool({ - name: 'set_variable', - description: 'Set the value of a variable for a specific mode.', - params: { - id: { type: 'string', description: 'Variable ID', required: true }, - mode: { type: 'string', description: 'Mode ID', required: true }, - value: { - type: 'string', - description: 'Value (hex for COLOR, number for FLOAT, etc.)', - required: true - } - }, - execute: (figma, args) => { - const v = figma.getVariableById(args.id) - if (!v) return { error: `Variable "${args.id}" not found` } - let parsedValue: unknown - if (v.type === 'COLOR') parsedValue = parseColor(args.value) - else if (v.type === 'FLOAT') parsedValue = Number(args.value) - else if (v.type === 'BOOLEAN') parsedValue = args.value === 'true' - else parsedValue = args.value - figma.setVariableValue(args.id, args.mode, parsedValue as any) - return { id: args.id, mode: args.mode, value: parsedValue } - } -}) - -export const deleteVariable = defineTool({ - name: 'delete_variable', - description: 'Delete a variable.', - params: { - id: { type: 'string', description: 'Variable ID', required: true } - }, - execute: (figma, { id }) => { - figma.deleteVariable(id) - return { deleted: id } - } -}) - -export const bindVariable = defineTool({ - name: 'bind_variable', - description: 'Bind a variable to a node property (fills, strokes, opacity, width, height, etc.).', - params: { - node_id: { type: 'string', description: 'Node ID', required: true }, - field: { - type: 'string', - description: 'Property field (fills, strokes, opacity, width, height, etc.)', - required: true - }, - variable_id: { type: 'string', description: 'Variable ID', required: true } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.node_id) - if (!node) return { error: `Node "${args.node_id}" not found` } - const v = figma.getVariableById(args.variable_id) - if (!v) return { error: `Variable "${args.variable_id}" not found` } - figma.bindVariable(args.node_id, args.field, args.variable_id) - return { node_id: args.node_id, field: args.field, variable_id: args.variable_id } - } -}) - -// ─── Collection CRUD tools ──────────────────────────────────── - -export const getCollection = defineTool({ - name: 'get_collection', - description: 'Get a variable collection by ID.', - params: { - id: { type: 'string', description: 'Collection ID', required: true } - }, - execute: (figma, { id }) => { - const c = figma.getVariableCollectionById(id) - if (!c) return { error: `Collection "${id}" not found` } - return c - } -}) - -export const createCollection = defineTool({ - name: 'create_collection', - description: 'Create a new variable collection.', - params: { - name: { type: 'string', description: 'Collection name', required: true } - }, - execute: (figma, { name }) => { - return figma.createVariableCollection(name) - } -}) - -export const deleteCollection = defineTool({ - name: 'delete_collection', - description: 'Delete a variable collection and all its variables.', - params: { - id: { type: 'string', description: 'Collection ID', required: true } - }, - execute: (figma, { id }) => { - figma.deleteVariableCollection(id) - return { deleted: id } - } -}) - -// ─── Boolean operation tools ────────────────────────────────── - -export const booleanUnion = defineTool({ - name: 'boolean_union', - description: 'Union (combine) multiple nodes.', - params: { - ids: { type: 'string[]', description: 'Node IDs to union', required: true } - }, - execute: (figma, { ids }) => { - const result = figma.booleanOperation('UNION', ids) - return nodeSummary(result) - } -}) - -export const booleanSubtract = defineTool({ - name: 'boolean_subtract', - description: 'Subtract the second node from the first.', - params: { - ids: { type: 'string[]', description: 'Node IDs (first minus rest)', required: true } - }, - execute: (figma, { ids }) => { - const result = figma.booleanOperation('SUBTRACT', ids) - return nodeSummary(result) - } -}) - -export const booleanIntersect = defineTool({ - name: 'boolean_intersect', - description: 'Intersect multiple nodes.', - params: { - ids: { type: 'string[]', description: 'Node IDs to intersect', required: true } - }, - execute: (figma, { ids }) => { - const result = figma.booleanOperation('INTERSECT', ids) - return nodeSummary(result) - } -}) - -export const booleanExclude = defineTool({ - name: 'boolean_exclude', - description: 'Exclude (XOR) multiple nodes.', - params: { - ids: { type: 'string[]', description: 'Node IDs to exclude', required: true } - }, - execute: (figma, { ids }) => { - const result = figma.booleanOperation('EXCLUDE', ids) - return nodeSummary(result) - } -}) - -// ─── Vector path tools ──────────────────────────────────────── - -export const pathGet = defineTool({ - name: 'path_get', - description: 'Get vector path data of a node.', - params: { - id: { type: 'string', description: 'Node ID', required: true } - }, - execute: (figma, { id }) => { - const raw = figma.graph.getNode(id) - if (!raw) return { error: `Node "${id}" not found` } - if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` } - return { id, vectorNetwork: raw.vectorNetwork } - } -}) - -export const pathSet = defineTool({ - name: 'path_set', - description: 'Set vector path data on a node. Provide a VectorNetwork JSON.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - path: { type: 'string', description: 'VectorNetwork JSON', required: true } - }, - execute: (figma, args) => { - const raw = figma.graph.getNode(args.id) - if (!raw) return { error: `Node "${args.id}" not found` } - const network = JSON.parse(args.path) - figma.graph.updateNode(args.id, { vectorNetwork: network } as any) - return { id: args.id } - } -}) - -export const pathScale = defineTool({ - name: 'path_scale', - description: 'Scale vector path from center.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - factor: { type: 'number', description: 'Scale factor (e.g. 2 for double)', required: true } - }, - execute: (figma, { id, factor }) => { - const raw = figma.graph.getNode(id) - if (!raw) return { error: `Node "${id}" not found` } - if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` } - const vn = structuredClone(raw.vectorNetwork) - const cx = raw.width / 2 - const cy = raw.height / 2 - for (const v of vn.vertices) { - v.x = cx + (v.x - cx) * factor - v.y = cy + (v.y - cy) * factor - } - for (const s of vn.segments) { - if (s.tangentStart) { - s.tangentStart.x *= factor - s.tangentStart.y *= factor - } - if (s.tangentEnd) { - s.tangentEnd.x *= factor - s.tangentEnd.y *= factor - } - } - figma.graph.updateNode(id, { vectorNetwork: vn } as any) - return { id, factor } - } -}) - -export const pathFlip = defineTool({ - name: 'path_flip', - description: 'Flip vector path horizontally or vertically.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - axis: { - type: 'string', - description: 'Flip axis', - required: true, - enum: ['horizontal', 'vertical'] - } - }, - execute: (figma, { id, axis }) => { - const raw = figma.graph.getNode(id) - if (!raw) return { error: `Node "${id}" not found` } - if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` } - const vn = structuredClone(raw.vectorNetwork) - const w = raw.width - const h = raw.height - for (const v of vn.vertices) { - if (axis === 'horizontal') v.x = w - v.x - else v.y = h - v.y - } - for (const s of vn.segments) { - if (s.tangentStart) { - if (axis === 'horizontal') s.tangentStart.x = -s.tangentStart.x - else s.tangentStart.y = -s.tangentStart.y - } - if (s.tangentEnd) { - if (axis === 'horizontal') s.tangentEnd.x = -s.tangentEnd.x - else s.tangentEnd.y = -s.tangentEnd.y - } - } - figma.graph.updateNode(id, { vectorNetwork: vn } as any) - return { id, axis } - } -}) - -export const pathMove = defineTool({ - name: 'path_move', - description: 'Move all path points by an offset.', - params: { - id: { type: 'string', description: 'Node ID', required: true }, - dx: { type: 'number', description: 'X offset', required: true }, - dy: { type: 'number', description: 'Y offset', required: true } - }, - execute: (figma, { id, dx, dy }) => { - const raw = figma.graph.getNode(id) - if (!raw) return { error: `Node "${id}" not found` } - if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` } - const vn = structuredClone(raw.vectorNetwork) - for (const v of vn.vertices) { - v.x += dx - v.y += dy - } - figma.graph.updateNode(id, { vectorNetwork: vn } as any) - return { id, dx, dy } - } -}) - -// ─── Create tools (specific shapes) ────────────────────────── - -export const createPage = defineTool({ - name: 'create_page', - description: 'Create a new page.', - params: { - name: { type: 'string', description: 'Page name', required: true } - }, - execute: (figma, { name }) => { - const page = figma.createPage() - page.name = name - return { id: page.id, name } - } -}) - -export const createVector = defineTool({ - name: 'create_vector', - description: 'Create a vector node with optional path data.', - params: { - x: { type: 'number', description: 'X position', required: true }, - y: { type: 'number', description: 'Y position', required: true }, - name: { type: 'string', description: 'Node name' }, - path: { type: 'string', description: 'VectorNetwork JSON' }, - fill: { type: 'color', description: 'Fill color (hex)' }, - stroke: { type: 'color', description: 'Stroke color (hex)' }, - stroke_weight: { type: 'number', description: 'Stroke weight' }, - parent_id: { type: 'string', description: 'Parent node ID' } - }, - execute: (figma, args) => { - const node = figma.createVector() - node.x = args.x - node.y = args.y - if (args.name) node.name = args.name - if (args.path) { - figma.graph.updateNode(node.id, { vectorNetwork: JSON.parse(args.path) } as any) - } - if (args.fill) { - node.fills = [{ type: 'SOLID', color: parseColor(args.fill), opacity: 1, visible: true }] - } - if (args.stroke) { - node.strokes = [ - { - color: parseColor(args.stroke), - weight: args.stroke_weight ?? 1, - opacity: 1, - visible: true, - align: 'CENTER' - } - ] - } - if (args.parent_id) { - const parent = figma.getNodeById(args.parent_id) - if (parent) parent.appendChild(node) - } - return nodeSummary(node) - } -}) - -export const createSlice = defineTool({ - name: 'create_slice', - description: 'Create a slice (export region) on the canvas.', - params: { - x: { type: 'number', description: 'X position', required: true }, - y: { type: 'number', description: 'Y position', required: true }, - width: { type: 'number', description: 'Width', required: true, min: 1 }, - height: { type: 'number', description: 'Height', required: true, min: 1 }, - name: { type: 'string', description: 'Slice name' }, - parent_id: { type: 'string', description: 'Parent node ID' } - }, - execute: (figma, args) => { - const node = figma.createFrame() - node.x = args.x - node.y = args.y - node.resize(args.width, args.height) - node.name = args.name ?? 'Slice' - node.fills = [] - if (args.parent_id) { - const parent = figma.getNodeById(args.parent_id) - if (parent) parent.appendChild(node) - } - return nodeSummary(node) - } -}) - -// ─── Group tools ────────────────────────────────────────────── - -export const flattenNodes = defineTool({ - name: 'flatten_nodes', - description: 'Flatten nodes into a single vector.', - params: { - ids: { type: 'string[]', description: 'Node IDs to flatten', required: true } - }, - execute: (figma, { ids }) => { - const result = figma.flattenNode(ids) - return nodeSummary(result) - } -}) - -// ─── Viewport tools ─────────────────────────────────────────── - -export const viewportGet = defineTool({ - name: 'viewport_get', - description: 'Get current viewport position and zoom level.', - params: {}, - execute: (figma) => { - return figma.viewport - } -}) - -export const viewportSet = defineTool({ - name: 'viewport_set', - description: 'Set viewport position and zoom.', - params: { - x: { type: 'number', description: 'Center X', required: true }, - y: { type: 'number', description: 'Center Y', required: true }, - zoom: { type: 'number', description: 'Zoom level', required: true, min: 0.01 } - }, - execute: (figma, { x, y, zoom }) => { - figma.viewport = { center: { x, y }, zoom } - return { x, y, zoom } - } -}) - -export const viewportZoomToFit = defineTool({ - name: 'viewport_zoom_to_fit', - description: 'Zoom viewport to fit specified nodes.', - params: { - ids: { type: 'string[]', description: 'Node IDs to fit in view', required: true } - }, - execute: (figma, { ids }) => { - let minX = Infinity - let minY = Infinity - let maxX = -Infinity - let maxY = -Infinity - for (const id of ids) { - const node = figma.getNodeById(id) - if (!node) continue - const bounds = node.absoluteBoundingBox - minX = Math.min(minX, bounds.x) - minY = Math.min(minY, bounds.y) - maxX = Math.max(maxX, bounds.x + bounds.width) - maxY = Math.max(maxY, bounds.y + bounds.height) - } - if (minX === Infinity) return { error: 'No valid nodes found' } - const cx = (minX + maxX) / 2 - const cy = (minY + maxY) / 2 - figma.viewport = { center: { x: cx, y: cy }, zoom: 1 } - return { - center: { x: cx, y: cy }, - bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY } - } - } -}) - -export const pageBounds = defineTool({ - name: 'page_bounds', - description: 'Get bounding box of all objects on the current page.', - params: {}, - execute: (figma) => { - const page = figma.currentPage - let minX = Infinity - let minY = Infinity - let maxX = -Infinity - let maxY = -Infinity - for (const child of page.children) { - const bounds = child.absoluteBoundingBox - minX = Math.min(minX, bounds.x) - minY = Math.min(minY, bounds.y) - maxX = Math.max(maxX, bounds.x + bounds.width) - maxY = Math.max(maxY, bounds.y + bounds.height) - } - if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 } - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } - } -}) - -// ─── Font tools ─────────────────────────────────────────────── - -export const listFonts = defineTool({ - name: 'list_fonts', - description: 'List fonts used in the current page.', - params: { - family: { type: 'string', description: 'Filter by family name (substring)' } - }, - execute: (figma, args) => { - const fonts = new Map>() - const page = figma.currentPage - page.findAll((node) => { - if (node.type === 'TEXT') { - const raw = figma.graph.getNode(node.id) - if (raw) { - const key = raw.fontFamily - if (!fonts.has(key)) fonts.set(key, new Set()) - fonts.get(key)!.add(raw.fontWeight) - } - } - return false - }) - let result = [...fonts.entries()].map(([family, weights]) => ({ - family, - weights: [...weights].sort() - })) - if (args.family) { - const q = args.family.toLowerCase() - result = result.filter((f) => f.family.toLowerCase().includes(q)) - } - return { count: result.length, fonts: result } - } -}) - -// ─── Text properties ────────────────────────────────────────── - -export const setTextProperties = defineTool({ - name: 'set_text_properties', - description: - 'Set text layout properties: alignment, auto-resize, text case, decoration, truncation.', - params: { - id: { type: 'string', description: 'Text node ID', required: true }, - align_horizontal: { - type: 'string', - description: 'Horizontal text alignment', - enum: ['LEFT', 'CENTER', 'RIGHT', 'JUSTIFIED'] - }, - align_vertical: { - type: 'string', - description: 'Vertical text alignment', - enum: ['TOP', 'CENTER', 'BOTTOM'] - }, - auto_resize: { - type: 'string', - description: 'Text auto-resize mode', - enum: ['NONE', 'WIDTH_AND_HEIGHT', 'HEIGHT', 'TRUNCATE'] - }, - text_decoration: { - type: 'string', - description: 'Text decoration', - enum: ['NONE', 'UNDERLINE', 'STRIKETHROUGH'] - } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - if (node.type !== 'TEXT') return { error: `Node "${args.id}" is not a TEXT node` } - const updated: string[] = [] - if (args.align_horizontal !== undefined) { - node.textAlignHorizontal = args.align_horizontal - updated.push('textAlignHorizontal') - } - if (args.align_vertical !== undefined) { - node.textAlignVertical = args.align_vertical - updated.push('textAlignVertical') - } - if (args.auto_resize !== undefined) { - node.textAutoResize = args.auto_resize - updated.push('textAutoResize') - } - if (args.text_decoration !== undefined) { - node.textDecoration = args.text_decoration - updated.push('textDecoration') - } - return { id: args.id, updated } - } -}) - -// ─── Layout child ───────────────────────────────────────────── - -export const setLayoutChild = defineTool({ - name: 'set_layout_child', - description: - 'Configure auto-layout child: sizing (FIXED/HUG/FILL), grow, alignment, absolute positioning.', - params: { - id: { type: 'string', description: 'Child node ID', required: true }, - sizing_horizontal: { - type: 'string', - description: 'Horizontal sizing mode', - enum: ['FIXED', 'HUG', 'FILL'] - }, - sizing_vertical: { - type: 'string', - description: 'Vertical sizing mode', - enum: ['FIXED', 'HUG', 'FILL'] - }, - grow: { type: 'number', description: 'Flex grow factor (0 = fixed, 1 = grow)', min: 0 }, - align_self: { - type: 'string', - description: 'Self alignment override', - enum: ['INHERIT', 'STRETCH'] - }, - positioning: { - type: 'string', - description: 'ABSOLUTE to take node out of auto-layout flow', - enum: ['AUTO', 'ABSOLUTE'] - } - }, - execute: (figma, args) => { - const node = figma.getNodeById(args.id) - if (!node) return { error: `Node "${args.id}" not found` } - const updated: string[] = [] - if (args.sizing_horizontal !== undefined) { - node.layoutSizingHorizontal = args.sizing_horizontal - updated.push('layoutSizingHorizontal') - } - if (args.sizing_vertical !== undefined) { - node.layoutSizingVertical = args.sizing_vertical - updated.push('layoutSizingVertical') - } - if (args.grow !== undefined) { - node.layoutGrow = args.grow - updated.push('layoutGrow') - } - if (args.align_self !== undefined) { - node.layoutAlign = args.align_self - updated.push('layoutAlign') - } - if (args.positioning !== undefined) { - node.layoutPositioning = args.positioning - updated.push('layoutPositioning') - } - return { id: args.id, updated } - } -}) - -// ─── Export tools ───────────────────────────────────────────── - -export const exportSvg = defineTool({ - name: 'export_svg', - description: 'Export nodes as SVG markup. Returns the SVG string.', - params: { - ids: { - type: 'string[]', - description: 'Node IDs to export. Omit to export all top-level nodes on the current page.' - } - }, - execute: async (figma, args) => { - const { renderNodesToSVG } = await import('../svg-export.js') - const pageId = figma.currentPageId - const ids = - args.ids && args.ids.length > 0 - ? args.ids - : figma.currentPage.children.map((n) => n.id) - const svg = renderNodesToSVG(figma.graph, pageId, ids) - if (!svg) return { error: 'No visible nodes to export' } - return { svg } - } -}) - -// ─── Registry ───────────────────────────────────────────────── - -export const ALL_TOOLS: ToolDef[] = [ - getSelection, - getPageTree, - getNode, - findNodes, - createShape, - render, - setFill, - setStroke, - setEffects, - updateNode, - setLayout, - setConstraints, - deleteNode, - cloneNode, - renameNode, - reparentNode, - selectNodes, - groupNodes, - ungroupNode, - createComponent, - createInstance, - listPages, - switchPage, - listVariables, - listCollections, - evalCode, - // Granular set tools - setRotation, - setOpacity, - setRadius, - setMinMax, - setText, - setFont, - setFontRange, - setTextResize, - setVisible, - setBlend, - setLocked, - setStrokeAlign, - // Node operations - nodeBounds, - nodeMove, - nodeResize, - nodeAncestors, - nodeChildren, - nodeTree, - nodeBindings, - nodeReplaceWith, - // Variable CRUD - getVariable, - findVariables, - createVariable, - setVariable, - deleteVariable, - bindVariable, - // Collection CRUD - getCollection, - createCollection, - deleteCollection, - // Boolean operations - booleanUnion, - booleanSubtract, - booleanIntersect, - booleanExclude, - // Vector path tools - pathGet, - pathSet, - pathScale, - pathFlip, - pathMove, - // Create tools - createPage, - createVector, - createSlice, - // Group tools - flattenNodes, - // Viewport tools - viewportGet, - viewportSet, - viewportZoomToFit, - pageBounds, - // Font tools - listFonts, - // Text properties - setTextProperties, - // Layout child - setLayoutChild, - // Export - exportSvg -] diff --git a/packages/core/src/tools/structure.ts b/packages/core/src/tools/structure.ts index 2aafeb2d9..bec64d586 100644 --- a/packages/core/src/tools/structure.ts +++ b/packages/core/src/tools/structure.ts @@ -267,8 +267,8 @@ export const nodeReplaceWith = defineTool({ const x = node.x const y = node.y node.remove() - const { renderJsx } = await import('../render/render-jsx.js') - const result = await renderJsx(figma.graph, args.jsx, { parentId, x, y }) + const { renderJSX } = await import('../render/render-jsx.js') + const result = await renderJSX(figma.graph, args.jsx, { parentId, x, y }) return { id: result.id, name: result.name, type: result.type } } }) diff --git a/src/components/properties/EffectsSection.vue b/src/components/properties/EffectsSection.vue index 9821f8f4c..07fe63f7e 100644 --- a/src/components/properties/EffectsSection.vue +++ b/src/components/properties/EffectsSection.vue @@ -11,14 +11,9 @@ import { colorToCSS } from '@open-pencil/core' import type { Color, Effect } from '@open-pencil/core' const { store } = useNodeProps() -const { node, nodes, isMulti, active } = useMultiProps() +const { node, nodes, isMulti, active, isArrayMixed } = useMultiProps() -const effectsAreMixed = computed(() => { - if (!isMulti.value) return false - const all = nodes.value - const first = JSON.stringify(all[0].effects) - return all.some((n) => JSON.stringify(n.effects) !== first) -}) +const effectsAreMixed = computed(() => isArrayMixed('effects')) const expandedIndex = ref(null) const effectsBeforeScrub = ref(null) diff --git a/src/components/properties/FillSection.vue b/src/components/properties/FillSection.vue index 6b49e997f..e15bd8456 100644 --- a/src/components/properties/FillSection.vue +++ b/src/components/properties/FillSection.vue @@ -22,14 +22,9 @@ import { colorToCSS, colorToHexRaw } from '@open-pencil/core' import type { Fill, Variable, Color } from '@open-pencil/core' const { store } = useNodeProps() -const { nodes, isMulti, active, activeNode } = useMultiProps() +const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps() -const fillsAreMixed = computed(() => { - if (!isMulti.value) return false - const all = nodes.value - const first = JSON.stringify(all[0].fills) - return all.some((n) => JSON.stringify(n.fills) !== first) -}) +const fillsAreMixed = computed(() => isArrayMixed('fills')) const colorVariables = computed(() => store.graph.getVariablesByType('COLOR')) diff --git a/src/components/properties/StrokeSection.vue b/src/components/properties/StrokeSection.vue index fa7ec1252..9b533bbf1 100644 --- a/src/components/properties/StrokeSection.vue +++ b/src/components/properties/StrokeSection.vue @@ -20,14 +20,9 @@ import type { SceneNode } from '@open-pencil/core' type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM' const { store } = useNodeProps() -const { nodes, isMulti, active, activeNode } = useMultiProps() +const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps() -const strokesAreMixed = computed(() => { - if (!isMulti.value) return false - const all = nodes.value - const first = JSON.stringify(all[0].strokes) - return all.some((n) => JSON.stringify(n.strokes) !== first) -}) +const strokesAreMixed = computed(() => isArrayMixed('strokes')) const ALIGN_OPTIONS: { value: Stroke['align']; label: string }[] = [ { value: 'INSIDE', label: 'Inside' }, diff --git a/src/composables/use-multi-props.ts b/src/composables/use-multi-props.ts index 83edc8ebf..eed05f6c8 100644 --- a/src/composables/use-multi-props.ts +++ b/src/composables/use-multi-props.ts @@ -36,5 +36,23 @@ export function useMultiProps() { store.requestRender() } - return { store, node, nodes, isMulti, active, activeNode, prop, merged, updateAllWithUndo } + function isArrayMixed(key: K): boolean { + const all = nodes.value + if (all.length <= 1) return false + const first = JSON.stringify(all[0][key]) + return all.some((n) => JSON.stringify(n[key]) !== first) + } + + return { + store, + node, + nodes, + isMulti, + active, + activeNode, + prop, + merged, + updateAllWithUndo, + isArrayMixed + } } diff --git a/tests/engine/abspos-cache.test.ts b/tests/engine/abspos-cache.test.ts index 9857c5608..440922a28 100644 --- a/tests/engine/abspos-cache.test.ts +++ b/tests/engine/abspos-cache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { SceneGraph } from '../../packages/core/src/scene-graph' +import { SceneGraph } from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id diff --git a/tests/engine/clipboard.test.ts b/tests/engine/clipboard.test.ts index adade4092..02ec561cc 100644 --- a/tests/engine/clipboard.test.ts +++ b/tests/engine/clipboard.test.ts @@ -5,9 +5,10 @@ import { importClipboardNodes, figmaNodesBounds, buildFigmaClipboardHTML, -} from '../../packages/core/src/clipboard' -import { initCodec } from '../../packages/core/src/kiwi/codec' -import { SceneGraph, type SceneNode } from '../../packages/core/src/scene-graph' + initCodec, + SceneGraph, + type SceneNode, +} from '@open-pencil/core' function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) { // Minimal fig-kiwi clipboard: just meta + empty figma buffer diff --git a/tests/engine/color.test.ts b/tests/engine/color.test.ts new file mode 100644 index 000000000..b4214f9fe --- /dev/null +++ b/tests/engine/color.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect } from 'bun:test' + +import { + parseColor, + colorToHex, + colorToHexRaw, + colorToRgba255, + colorToCSS +} from '@open-pencil/core' + +describe('parseColor', () => { + test('hex 6-digit', () => { + const c = parseColor('#FF0000') + expect(c.r).toBeCloseTo(1) + expect(c.g).toBeCloseTo(0) + expect(c.b).toBeCloseTo(0) + expect(c.a).toBe(1) + }) + + test('hex 3-digit', () => { + const c = parseColor('#f00') + expect(c.r).toBeCloseTo(1) + expect(c.g).toBeCloseTo(0) + expect(c.b).toBeCloseTo(0) + }) + + test('hex 8-digit with alpha', () => { + const c = parseColor('#FF000080') + expect(c.r).toBeCloseTo(1) + expect(c.a).toBeCloseTo(0.5, 1) + }) + + test('rgb()', () => { + const c = parseColor('rgb(255, 0, 0)') + expect(c.r).toBeCloseTo(1) + expect(c.g).toBeCloseTo(0) + expect(c.b).toBeCloseTo(0) + expect(c.a).toBe(1) + }) + + test('rgba()', () => { + const c = parseColor('rgba(255, 0, 0, 0.5)') + expect(c.r).toBeCloseTo(1) + expect(c.a).toBeCloseTo(0.5) + }) + + test('named color', () => { + const c = parseColor('blue') + expect(c.b).toBeCloseTo(1) + expect(c.r).toBeCloseTo(0) + expect(c.a).toBe(1) + }) + + test('white', () => { + const c = parseColor('#FFFFFF') + expect(c.r).toBeCloseTo(1) + expect(c.g).toBeCloseTo(1) + expect(c.b).toBeCloseTo(1) + }) + + test('invalid returns black fallback', () => { + const c = parseColor('invalid') + expect(c.r).toBe(0) + expect(c.g).toBe(0) + expect(c.b).toBe(0) + expect(c.a).toBe(1) + }) + + test('empty string returns black fallback', () => { + const c = parseColor('') + expect(c.r).toBe(0) + expect(c.g).toBe(0) + expect(c.b).toBe(0) + }) +}) + +describe('colorToHex', () => { + test('red', () => { + expect(colorToHex({ r: 1, g: 0, b: 0, a: 1 })).toBe('#FF0000') + }) + + test('black', () => { + expect(colorToHex({ r: 0, g: 0, b: 0, a: 1 })).toBe('#000000') + }) + + test('white', () => { + expect(colorToHex({ r: 1, g: 1, b: 1, a: 1 })).toBe('#FFFFFF') + }) +}) + +describe('colorToHexRaw', () => { + test('strips # prefix', () => { + expect(colorToHexRaw({ r: 1, g: 0, b: 0, a: 1 })).toBe('FF0000') + }) +}) + +describe('colorToRgba255', () => { + test('converts 0-1 to 0-255', () => { + const result = colorToRgba255({ r: 1, g: 0.5, b: 0, a: 0.8 }) + expect(result.r).toBe(255) + expect(result.g).toBe(128) + expect(result.b).toBe(0) + expect(result.a).toBe(0.8) + }) +}) + +describe('colorToCSS', () => { + test('opaque uses rgb()', () => { + const result = colorToCSS({ r: 1, g: 0, b: 0, a: 1 }) + expect(result).toBe('rgb(255, 0, 0)') + }) + + test('transparent uses rgba()', () => { + const result = colorToCSS({ r: 1, g: 0, b: 0, a: 0.5 }) + expect(result).toBe('rgba(255, 0, 0, 0.5)') + }) +}) diff --git a/tests/engine/fig-import.test.ts b/tests/engine/fig-import.test.ts index 8f8660fa1..736de4ad3 100644 --- a/tests/engine/fig-import.test.ts +++ b/tests/engine/fig-import.test.ts @@ -1,8 +1,6 @@ import { describe, test, expect } from 'bun:test' -import { importNodeChanges } from '../../src/kiwi/fig-import' - -import type { NodeChange } from '../../src/kiwi/codec' +import { importNodeChanges, type NodeChange } from '@open-pencil/core' function doc(): NodeChange { return { diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index f36a02d76..02a11eccf 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -2,13 +2,16 @@ import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test' import { readFileSync } from 'fs' import { resolve } from 'path' -import { parseFigFile } from '../../packages/core/src/kiwi/fig-file' -import { exportFigFile } from '../../packages/core/src/fig-export' -import { importNodeChanges } from '../../packages/core/src/kiwi/fig-import' -import { initCodec } from '../../packages/core/src/kiwi/codec' -import { SceneGraph } from '../../packages/core/src/scene-graph' - -import type { SceneNode, NodeType, Fill } from '../../packages/core/src/scene-graph' +import { + parseFigFile, + exportFigFile, + importNodeChanges, + initCodec, + SceneGraph, + type SceneNode, + type NodeType, + type Fill, +} from '@open-pencil/core' import { heavy } from '../helpers/test-utils' setDefaultTimeout(60_000) diff --git a/tests/engine/fonts.test.ts b/tests/engine/fonts.test.ts index 0f58a2363..9f5b92046 100644 --- a/tests/engine/fonts.test.ts +++ b/tests/engine/fonts.test.ts @@ -6,8 +6,8 @@ import { markFontLoaded, styleToWeight, weightToStyle, -} from '../../packages/core/src/fonts' -import { SceneGraph } from '../../packages/core/src/scene-graph' + SceneGraph, +} from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id diff --git a/tests/engine/individual-strokes.test.ts b/tests/engine/individual-strokes.test.ts index 32d668b0f..721cf5cc3 100644 --- a/tests/engine/individual-strokes.test.ts +++ b/tests/engine/individual-strokes.test.ts @@ -1,10 +1,6 @@ import { describe, test, expect } from 'bun:test' -import { SceneGraph } from '../../packages/core/src/scene-graph' -import { FigmaAPI } from '../../packages/core/src/figma-api' -import { sceneNodeToKiwi } from '../../packages/core/src/kiwi-serialize' - -import type { Stroke } from '../../packages/core/src/scene-graph' +import { SceneGraph, FigmaAPI, sceneNodeToKiwi, type Stroke } from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id diff --git a/tests/engine/layout.test.ts b/tests/engine/layout.test.ts index 30e929012..869e2fb20 100644 --- a/tests/engine/layout.test.ts +++ b/tests/engine/layout.test.ts @@ -1,7 +1,6 @@ import { describe, test, expect } from 'bun:test' -import { SceneGraph, type SceneNode } from '../../src/engine/scene-graph' -import { computeLayout, computeAllLayouts, setTextMeasurer } from '../../src/engine/layout' +import { SceneGraph, type SceneNode, computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id diff --git a/tests/engine/multi-selection.test.ts b/tests/engine/multi-selection.test.ts index e644c889f..1c633e749 100644 --- a/tests/engine/multi-selection.test.ts +++ b/tests/engine/multi-selection.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'bun:test' -import { SceneGraph } from '../../packages/core/src/scene-graph' +import { SceneGraph } from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id diff --git a/tests/engine/profiler.test.ts b/tests/engine/profiler.test.ts index 3c8e54696..78d2a6db3 100644 --- a/tests/engine/profiler.test.ts +++ b/tests/engine/profiler.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect } from 'bun:test' -import { FrameStats } from '../../packages/core/src/profiler/frame-stats' -import { DrawCallCounter } from '../../packages/core/src/profiler/draw-call-counter' -import { PhaseTimer } from '../../packages/core/src/profiler/phase-timer' -import { GPUTimer } from '../../packages/core/src/profiler/gpu-timer' -import { CaptureStack, toSpeedscopeJSON } from '../../packages/core/src/profiler/frame-capture' +import { FrameStats, DrawCallCounter, PhaseTimer, GPUTimer, CaptureStack, toSpeedscopeJSON } from '@open-pencil/core' describe('FrameStats', () => { it('records frames and computes rolling averages', () => { diff --git a/tests/engine/renderer-effects.test.ts b/tests/engine/renderer-effects.test.ts index daaed806e..fb10c65f6 100644 --- a/tests/engine/renderer-effects.test.ts +++ b/tests/engine/renderer-effects.test.ts @@ -1,21 +1,25 @@ import { describe, test, expect } from 'bun:test' +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' -const rendererSource = await Bun.file( - new URL('../../packages/core/src/renderer.ts', import.meta.url) -).text() +const rendererDir = new URL('../../packages/core/src/renderer/', import.meta.url).pathname +const files = await readdir(rendererDir) +const parts = await Promise.all( + files.filter((f) => f.endsWith('.ts')).map((f) => Bun.file(join(rendererDir, f)).text()) +) +const rendererSource = parts.join('\n') describe('Renderer effect ordering', () => { test('drop shadow renders before fills', () => { - // In renderShape, "Effects (behind: drop shadow)" must come before "Fills" const behindIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'behind')") - const fillsIdx = rendererSource.indexOf('this.drawNodeFill(canvas, node, rect, hasRadius)') + const fillsIdx = rendererSource.indexOf('drawNodeFill(canvas, node, rect, hasRadius)') expect(behindIdx).toBeGreaterThan(-1) expect(fillsIdx).toBeGreaterThan(-1) expect(behindIdx).toBeLessThan(fillsIdx) }) test('inner shadow and blur render after strokes', () => { - const strokeIdx = rendererSource.indexOf('this.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)') + const strokeIdx = rendererSource.indexOf('drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)') const frontIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'front')") expect(strokeIdx).toBeGreaterThan(-1) expect(frontIdx).toBeGreaterThan(-1) @@ -65,11 +69,11 @@ describe('Shadow spread support', () => { }) test('makeRRectWithSpread helper exists', () => { - expect(rendererSource).toContain('private makeRRectWithSpread') + expect(rendererSource).toContain('function makeRRectWithSpread') }) test('makeRRectWithOffset helper exists', () => { - expect(rendererSource).toContain('private makeRRectWithOffset') + expect(rendererSource).toContain('function makeRRectWithOffset') }) }) @@ -110,7 +114,7 @@ describe('Blur effects use saveLayer pattern', () => { }) test('applyClippedBlur clips to node shape and uses saveLayer', () => { - const idx = rendererSource.indexOf('private applyClippedBlur') + const idx = rendererSource.indexOf('function applyClippedBlur') const section = rendererSource.slice(idx, idx + 500) expect(section).toContain('clipNodeShape') expect(section).toContain('getCachedBlur') diff --git a/tests/engine/scene-graph.test.ts b/tests/engine/scene-graph.test.ts index 2caa3ec56..2e3d55d01 100644 --- a/tests/engine/scene-graph.test.ts +++ b/tests/engine/scene-graph.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'bun:test' -import { SceneGraph } from '../../src/engine/scene-graph' +import { SceneGraph } from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id diff --git a/tests/engine/snap.test.ts b/tests/engine/snap.test.ts new file mode 100644 index 000000000..5c767e188 --- /dev/null +++ b/tests/engine/snap.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'bun:test' + +import { computeSnap, computeSelectionBounds, type SceneNode } from '@open-pencil/core' + +function node(overrides: Partial & { id: string }): SceneNode { + return { x: 0, y: 0, width: 100, height: 100, rotation: 0, ...overrides } as SceneNode +} + +describe('computeSelectionBounds', () => { + test('empty array returns null', () => { + expect(computeSelectionBounds([])).toBeNull() + }) + + test('single node', () => { + const bounds = computeSelectionBounds([node({ id: 'a', x: 10, y: 20, width: 100, height: 50 })]) + expect(bounds).toEqual({ x: 10, y: 20, width: 100, height: 50 }) + }) + + test('two nodes gives union', () => { + const bounds = computeSelectionBounds([ + node({ id: 'a', x: 0, y: 0, width: 50, height: 50 }), + node({ id: 'b', x: 100, y: 100, width: 50, height: 50 }) + ]) + expect(bounds).toEqual({ x: 0, y: 0, width: 150, height: 150 }) + }) + + test('rotated node expands bbox', () => { + const bounds = computeSelectionBounds([ + node({ id: 'a', x: 0, y: 0, width: 100, height: 0, rotation: 45 }) + ])! + expect(bounds.width).toBeGreaterThan(0) + expect(bounds.height).toBeGreaterThan(0) + }) +}) + +describe('computeSnap', () => { + test('no targets returns zero', () => { + const result = computeSnap( + new Set(['moving']), + { x: 50, y: 50, width: 100, height: 100 }, + [node({ id: 'moving' })] + ) + expect(result.dx).toBe(0) + expect(result.dy).toBe(0) + expect(result.guides).toHaveLength(0) + }) + + test('snaps to aligned left edge', () => { + const target = node({ id: 'target', x: 100, y: 200, width: 50, height: 50 }) + const result = computeSnap( + new Set(['moving']), + { x: 102, y: 50, width: 80, height: 80 }, + [node({ id: 'moving' }), target] + ) + expect(result.dx).toBe(-2) + }) + + test('snaps to aligned top edge', () => { + const target = node({ id: 'target', x: 200, y: 100, width: 50, height: 50 }) + const result = computeSnap( + new Set(['moving']), + { x: 50, y: 103, width: 80, height: 80 }, + [node({ id: 'moving' }), target] + ) + expect(result.dy).toBe(-3) + }) + + test('no snap when far away', () => { + const target = node({ id: 'target', x: 500, y: 500, width: 50, height: 50 }) + const result = computeSnap( + new Set(['moving']), + { x: 0, y: 0, width: 50, height: 50 }, + [node({ id: 'moving' }), target] + ) + expect(result.dx).toBe(0) + expect(result.dy).toBe(0) + }) + + test('center-to-center snap', () => { + // target center at x=50, moving center at x=-2+50=48 → dx=+2 + const target = node({ id: 'target', x: 0, y: 0, width: 100, height: 100 }) + const result = computeSnap( + new Set(['moving']), + { x: -2, y: 300, width: 100, height: 100 }, + [node({ id: 'moving' }), target] + ) + expect(result.dx).toBe(2) + }) +}) diff --git a/tests/engine/style-runs.test.ts b/tests/engine/style-runs.test.ts new file mode 100644 index 000000000..4a02b8195 --- /dev/null +++ b/tests/engine/style-runs.test.ts @@ -0,0 +1,157 @@ +import { describe, test, expect } from 'bun:test' + +import { + getStyleAt, + applyStyleToRange, + removeStyleFromRange, + selectionHasStyle, + adjustRunsForInsert, + adjustRunsForDelete, + toggleBoldInRange, + toggleItalicInRange, + toggleDecorationInRange, + type StyleRun, +} from '@open-pencil/core' + +describe('getStyleAt', () => { + test('returns style within run', () => { + const runs: StyleRun[] = [{ start: 2, length: 3, style: { fontWeight: 700 } }] + expect(getStyleAt(runs, 3)).toEqual({ fontWeight: 700 }) + }) + + test('returns empty object outside runs', () => { + const runs: StyleRun[] = [{ start: 5, length: 2, style: { fontWeight: 700 } }] + expect(getStyleAt(runs, 0)).toEqual({}) + }) +}) + +describe('applyStyleToRange', () => { + test('applies bold to range', () => { + const runs = applyStyleToRange([], 2, 5, { fontWeight: 700 }, 10) + expect(runs).toHaveLength(1) + expect(runs[0].start).toBe(2) + expect(runs[0].length).toBe(3) + expect(runs[0].style.fontWeight).toBe(700) + }) + + test('merges with existing style', () => { + const existing: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }] + const runs = applyStyleToRange(existing, 2, 4, { italic: true }, 10) + const style = getStyleAt(runs, 3) + expect(style.fontWeight).toBe(700) + expect(style.italic).toBe(true) + }) +}) + +describe('removeStyleFromRange', () => { + test('removes keys from range', () => { + const existing: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700, italic: true } }] + const runs = removeStyleFromRange(existing, 0, 5, ['fontWeight'], 10) + const style = getStyleAt(runs, 2) + expect(style.fontWeight).toBeUndefined() + expect(style.italic).toBe(true) + }) +}) + +describe('selectionHasStyle', () => { + test('all chars have style', () => { + const runs: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }] + expect(selectionHasStyle(runs, 0, 5, 'fontWeight', 700)).toBe(true) + }) + + test('some chars missing', () => { + const runs: StyleRun[] = [{ start: 0, length: 3, style: { fontWeight: 700 } }] + expect(selectionHasStyle(runs, 0, 5, 'fontWeight', 700)).toBe(false) + }) +}) + +describe('adjustRunsForInsert', () => { + test('insert before run shifts start', () => { + const runs: StyleRun[] = [{ start: 5, length: 3, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForInsert(runs, 2, 4) + expect(adjusted[0].start).toBe(9) + expect(adjusted[0].length).toBe(3) + }) + + test('insert inside run extends length', () => { + const runs: StyleRun[] = [{ start: 2, length: 6, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForInsert(runs, 4, 3) + expect(adjusted[0].start).toBe(2) + expect(adjusted[0].length).toBe(9) + }) + + test('insert after run leaves it unchanged', () => { + const runs: StyleRun[] = [{ start: 0, length: 3, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForInsert(runs, 10, 5) + expect(adjusted[0]).toEqual({ start: 0, length: 3, style: { fontWeight: 700 } }) + }) +}) + +describe('adjustRunsForDelete', () => { + test('delete before run shifts start', () => { + const runs: StyleRun[] = [{ start: 10, length: 5, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForDelete(runs, 2, 3) + expect(adjusted[0].start).toBe(7) + expect(adjusted[0].length).toBe(5) + }) + + test('delete inside run shrinks length', () => { + const runs: StyleRun[] = [{ start: 0, length: 10, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForDelete(runs, 3, 4) + expect(adjusted[0].length).toBe(6) + }) + + test('delete entire run removes it', () => { + const runs: StyleRun[] = [{ start: 2, length: 3, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForDelete(runs, 2, 3) + expect(adjusted).toHaveLength(0) + }) + + test('delete after run leaves it unchanged', () => { + const runs: StyleRun[] = [{ start: 0, length: 3, style: { fontWeight: 700 } }] + const adjusted = adjustRunsForDelete(runs, 5, 2) + expect(adjusted[0]).toEqual({ start: 0, length: 3, style: { fontWeight: 700 } }) + }) +}) + +describe('toggleBoldInRange', () => { + test('non-bold to bold', () => { + const { runs, newWeight } = toggleBoldInRange([], 0, 5, 400, 10) + expect(newWeight).toBe(700) + expect(runs).toHaveLength(1) + expect(runs[0].style.fontWeight).toBe(700) + }) + + test('bold to non-bold', () => { + const existing: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }] + const { runs, newWeight } = toggleBoldInRange(existing, 0, 5, 400, 10) + expect(newWeight).toBe(400) + }) +}) + +describe('toggleItalicInRange', () => { + test('toggle on', () => { + const { runs, newItalic } = toggleItalicInRange([], 0, 5, false, 10) + expect(newItalic).toBe(true) + expect(runs[0].style.italic).toBe(true) + }) + + test('toggle off', () => { + const existing: StyleRun[] = [{ start: 0, length: 5, style: { italic: true } }] + const { newItalic } = toggleItalicInRange(existing, 0, 5, false, 10) + expect(newItalic).toBe(false) + }) +}) + +describe('toggleDecorationInRange', () => { + test('add underline', () => { + const { newDeco } = toggleDecorationInRange([], 0, 5, 'UNDERLINE', 'NONE', 10) + expect(newDeco).toBe('UNDERLINE') + }) + + test('remove underline', () => { + const existing: StyleRun[] = [{ start: 0, length: 5, style: { textDecoration: 'UNDERLINE' } }] + const { newDeco } = toggleDecorationInRange(existing, 0, 5, 'UNDERLINE', 'NONE', 10) + expect(newDeco).toBe('NONE') + }) +}) diff --git a/tests/engine/text-editor.test.ts b/tests/engine/text-editor.test.ts new file mode 100644 index 000000000..587df0d00 --- /dev/null +++ b/tests/engine/text-editor.test.ts @@ -0,0 +1,205 @@ +import { describe, test, expect } from 'bun:test' + +import { TextEditor, type SceneNode } from '@open-pencil/core' + +const mockCk = {} as any + +function createEditor(text = 'Hello World') { + const editor = new TextEditor(mockCk) + const node = { id: 'test-node', text } as SceneNode + editor.start(node) + return { editor, node } +} + +describe('TextEditor', () => { + test('start initializes state', () => { + const { editor } = createEditor() + expect(editor.isActive).toBe(true) + expect(editor.nodeId).toBe('test-node') + expect(editor.state?.text).toBe('Hello World') + expect(editor.state?.cursor).toBe(11) + }) + + test('stop returns result and clears state', () => { + const { editor } = createEditor() + const result = editor.stop() + expect(result?.nodeId).toBe('test-node') + expect(result?.text).toBe('Hello World') + expect(editor.isActive).toBe(false) + expect(editor.nodeId).toBeNull() + }) + + test('stop on inactive returns null', () => { + const editor = new TextEditor(mockCk) + expect(editor.stop()).toBeNull() + }) + + test('insert at end', () => { + const { editor, node } = createEditor() + editor.insert('!', node) + expect(editor.state?.text).toBe('Hello World!') + expect(editor.state?.cursor).toBe(12) + }) + + test('insert at cursor position', () => { + const { editor, node } = createEditor() + editor.state!.cursor = 5 + editor.insert(' Beautiful', node) + expect(editor.state?.text).toBe('Hello Beautiful World') + }) + + test('insert replaces selection', () => { + const { editor, node } = createEditor() + editor.state!.selectionAnchor = 0 + editor.state!.cursor = 5 + editor.insert('Goodbye', node) + expect(editor.state?.text).toBe('Goodbye World') + expect(editor.state?.cursor).toBe(7) + expect(editor.state?.selectionAnchor).toBeNull() + }) + + test('backspace deletes char before cursor', () => { + const { editor, node } = createEditor() + editor.backspace(node) + expect(editor.state?.text).toBe('Hello Worl') + expect(editor.state?.cursor).toBe(10) + }) + + test('backspace at start does nothing', () => { + const { editor, node } = createEditor() + editor.state!.cursor = 0 + editor.backspace(node) + expect(editor.state?.text).toBe('Hello World') + }) + + test('backspace deletes selection', () => { + const { editor, node } = createEditor() + editor.state!.selectionAnchor = 6 + editor.state!.cursor = 11 + editor.backspace(node) + expect(editor.state?.text).toBe('Hello ') + expect(editor.state?.cursor).toBe(6) + }) + + test('delete removes char after cursor', () => { + const { editor, node } = createEditor() + editor.state!.cursor = 0 + editor.delete(node) + expect(editor.state?.text).toBe('ello World') + expect(editor.state?.cursor).toBe(0) + }) + + test('delete at end does nothing', () => { + const { editor, node } = createEditor() + editor.delete(node) + expect(editor.state?.text).toBe('Hello World') + }) + + test('delete removes selection', () => { + const { editor, node } = createEditor() + editor.state!.selectionAnchor = 0 + editor.state!.cursor = 5 + editor.delete(node) + expect(editor.state?.text).toBe(' World') + }) + + test('moveLeft', () => { + const { editor } = createEditor() + editor.moveLeft() + expect(editor.state?.cursor).toBe(10) + }) + + test('moveRight at end stays', () => { + const { editor } = createEditor() + editor.moveRight() + expect(editor.state?.cursor).toBe(11) + }) + + test('moveLeft collapses selection to start', () => { + const { editor } = createEditor() + editor.state!.selectionAnchor = 3 + editor.state!.cursor = 8 + editor.moveLeft() + expect(editor.state?.cursor).toBe(3) + expect(editor.state?.selectionAnchor).toBeNull() + }) + + test('moveRight collapses selection to end', () => { + const { editor } = createEditor() + editor.state!.selectionAnchor = 3 + editor.state!.cursor = 8 + editor.moveRight() + expect(editor.state?.cursor).toBe(8) + expect(editor.state?.selectionAnchor).toBeNull() + }) + + test('selectAll', () => { + const { editor } = createEditor() + editor.selectAll() + expect(editor.state?.selectionAnchor).toBe(0) + expect(editor.state?.cursor).toBe(11) + }) + + test('hasSelection', () => { + const { editor } = createEditor() + expect(editor.hasSelection()).toBe(false) + editor.state!.selectionAnchor = 0 + expect(editor.hasSelection()).toBe(true) + }) + + test('hasSelection false when anchor equals cursor', () => { + const { editor } = createEditor() + editor.state!.selectionAnchor = editor.state!.cursor + expect(editor.hasSelection()).toBe(false) + }) + + test('getSelectionRange', () => { + const { editor } = createEditor() + expect(editor.getSelectionRange()).toBeNull() + editor.state!.selectionAnchor = 8 + editor.state!.cursor = 3 + expect(editor.getSelectionRange()).toEqual([3, 8]) + }) + + test('getSelectedText', () => { + const { editor } = createEditor() + editor.state!.selectionAnchor = 0 + editor.state!.cursor = 5 + expect(editor.getSelectedText()).toBe('Hello') + }) + + test('selectWord', () => { + const { editor } = createEditor() + editor.selectWord(2) + expect(editor.state?.selectionAnchor).toBe(0) + expect(editor.state?.cursor).toBe(5) + }) + + test('selectWord on second word', () => { + const { editor } = createEditor() + editor.selectWord(8) + expect(editor.state?.selectionAnchor).toBe(6) + expect(editor.state?.cursor).toBe(11) + }) + + test('moveWordLeft', () => { + const { editor } = createEditor() + editor.moveWordLeft() + expect(editor.state?.cursor).toBe(6) + }) + + test('moveWordRight from start', () => { + const { editor } = createEditor() + editor.state!.cursor = 0 + editor.moveWordRight() + expect(editor.state?.cursor).toBe(6) + }) + + test('moveLeft with extend creates selection', () => { + const { editor } = createEditor() + editor.state!.cursor = 5 + editor.moveLeft(true) + expect(editor.state?.selectionAnchor).toBe(5) + expect(editor.state?.cursor).toBe(4) + }) +}) diff --git a/tests/engine/undo.test.ts b/tests/engine/undo.test.ts new file mode 100644 index 000000000..c3c5e849e --- /dev/null +++ b/tests/engine/undo.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect } from 'bun:test' + +import { UndoManager } from '@open-pencil/core' + +describe('UndoManager', () => { + test('initial state', () => { + const undo = new UndoManager() + expect(undo.canUndo).toBe(false) + expect(undo.canRedo).toBe(false) + expect(undo.undoLabel).toBeNull() + expect(undo.redoLabel).toBeNull() + }) + + test('apply executes forward and enables undo', () => { + const undo = new UndoManager() + let value = 0 + undo.apply({ + label: 'increment', + forward: () => { value = 1 }, + inverse: () => { value = 0 } + }) + expect(value).toBe(1) + expect(undo.canUndo).toBe(true) + expect(undo.undoLabel).toBe('increment') + }) + + test('push does not execute forward', () => { + const undo = new UndoManager() + let value = 0 + undo.push({ + label: 'set', + forward: () => { value = 1 }, + inverse: () => { value = 0 } + }) + expect(value).toBe(0) + expect(undo.canUndo).toBe(true) + }) + + test('undo calls inverse and returns label', () => { + const undo = new UndoManager() + let value = 0 + undo.apply({ + label: 'set', + forward: () => { value = 1 }, + inverse: () => { value = 0 } + }) + const label = undo.undo() + expect(label).toBe('set') + expect(value).toBe(0) + expect(undo.canUndo).toBe(false) + expect(undo.canRedo).toBe(true) + }) + + test('redo calls forward and returns label', () => { + const undo = new UndoManager() + let value = 0 + undo.apply({ + label: 'set', + forward: () => { value = 1 }, + inverse: () => { value = 0 } + }) + undo.undo() + const label = undo.redo() + expect(label).toBe('set') + expect(value).toBe(1) + expect(undo.canRedo).toBe(false) + }) + + test('new action clears redo stack', () => { + const undo = new UndoManager() + undo.apply({ label: 'a', forward: () => {}, inverse: () => {} }) + undo.undo() + expect(undo.canRedo).toBe(true) + undo.apply({ label: 'b', forward: () => {}, inverse: () => {} }) + expect(undo.canRedo).toBe(false) + }) + + test('undo on empty returns null', () => { + const undo = new UndoManager() + expect(undo.undo()).toBeNull() + }) + + test('redo on empty returns null', () => { + const undo = new UndoManager() + expect(undo.redo()).toBeNull() + }) + + test('clear empties both stacks', () => { + const undo = new UndoManager() + undo.apply({ label: 'a', forward: () => {}, inverse: () => {} }) + undo.undo() + undo.clear() + expect(undo.canUndo).toBe(false) + expect(undo.canRedo).toBe(false) + }) + + test('batch combines into single entry', () => { + const undo = new UndoManager() + const log: number[] = [] + undo.beginBatch('batch') + undo.apply({ label: 'a', forward: () => log.push(1), inverse: () => log.push(-1) }) + undo.apply({ label: 'b', forward: () => log.push(2), inverse: () => log.push(-2) }) + undo.commitBatch() + expect(log).toEqual([1, 2]) + expect(undo.undoLabel).toBe('batch') + + undo.undo() + expect(log).toEqual([1, 2, -2, -1]) + }) + + test('empty batch commits nothing', () => { + const undo = new UndoManager() + undo.beginBatch('empty') + undo.commitBatch() + expect(undo.canUndo).toBe(false) + }) + + test('multiple undo/redo', () => { + const undo = new UndoManager() + let val = 0 + undo.apply({ label: 'to1', forward: () => { val = 1 }, inverse: () => { val = 0 } }) + undo.apply({ label: 'to2', forward: () => { val = 2 }, inverse: () => { val = 1 } }) + undo.apply({ label: 'to3', forward: () => { val = 3 }, inverse: () => { val = 2 } }) + + undo.undo() + expect(val).toBe(2) + undo.undo() + expect(val).toBe(1) + undo.redo() + expect(val).toBe(2) + undo.redo() + expect(val).toBe(3) + }) +}) diff --git a/tests/engine/vector.test.ts b/tests/engine/vector.test.ts new file mode 100644 index 000000000..feb4b9081 --- /dev/null +++ b/tests/engine/vector.test.ts @@ -0,0 +1,175 @@ +import { describe, test, expect } from 'bun:test' + +import { + encodeVectorNetworkBlob, + decodeVectorNetworkBlob, + computeVectorBounds, + type VectorNetwork, +} from '@open-pencil/core' + +describe('vectorNetworkBlob round-trip', () => { + test('empty network', () => { + const network: VectorNetwork = { vertices: [], segments: [], regions: [] } + const blob = encodeVectorNetworkBlob(network) + const decoded = decodeVectorNetworkBlob(blob) + expect(decoded.vertices).toHaveLength(0) + expect(decoded.segments).toHaveLength(0) + expect(decoded.regions).toHaveLength(0) + }) + + test('single line segment', () => { + const network: VectorNetwork = { + vertices: [ + { x: 0, y: 0, handleMirroring: 'NONE' }, + { x: 100, y: 50, handleMirroring: 'NONE' } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [] + } + const blob = encodeVectorNetworkBlob(network) + const decoded = decodeVectorNetworkBlob(blob) + expect(decoded.vertices).toHaveLength(2) + expect(decoded.vertices[0].x).toBeCloseTo(0) + expect(decoded.vertices[1].x).toBeCloseTo(100) + expect(decoded.vertices[1].y).toBeCloseTo(50) + expect(decoded.segments).toHaveLength(1) + expect(decoded.segments[0].start).toBe(0) + expect(decoded.segments[0].end).toBe(1) + }) + + test('cubic bezier segment', () => { + const network: VectorNetwork = { + vertices: [ + { x: 0, y: 0, handleMirroring: 'ANGLE' }, + { x: 100, y: 100, handleMirroring: 'ANGLE' } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 30, y: 0 }, tangentEnd: { x: -30, y: 0 } } + ], + regions: [] + } + const blob = encodeVectorNetworkBlob(network) + const decoded = decodeVectorNetworkBlob(blob) + expect(decoded.segments[0].tangentStart.x).toBeCloseTo(30) + expect(decoded.segments[0].tangentEnd.x).toBeCloseTo(-30) + }) + + test('network with region', () => { + const network: VectorNetwork = { + vertices: [ + { x: 0, y: 0, handleMirroring: 'NONE' }, + { x: 100, y: 0, handleMirroring: 'NONE' }, + { x: 50, y: 100, handleMirroring: 'NONE' } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [ + { windingRule: 'NONZERO', loops: [[0, 1, 2]] } + ] + } + const blob = encodeVectorNetworkBlob(network) + const decoded = decodeVectorNetworkBlob(blob) + expect(decoded.regions).toHaveLength(1) + expect(decoded.regions[0].windingRule).toBe('NONZERO') + expect(decoded.regions[0].loops).toEqual([[0, 1, 2]]) + }) + + test('region with EVENODD winding', () => { + const network: VectorNetwork = { + vertices: [ + { x: 0, y: 0, handleMirroring: 'NONE' }, + { x: 10, y: 0, handleMirroring: 'NONE' }, + { x: 10, y: 10, handleMirroring: 'NONE' }, + { x: 0, y: 10, handleMirroring: 'NONE' } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [ + { windingRule: 'EVENODD', loops: [[0, 1, 2, 3]] } + ] + } + const blob = encodeVectorNetworkBlob(network) + const decoded = decodeVectorNetworkBlob(blob) + expect(decoded.regions[0].windingRule).toBe('EVENODD') + }) + + test('multiple regions with multiple loops', () => { + const network: VectorNetwork = { + vertices: [ + { x: 0, y: 0, handleMirroring: 'NONE' }, + { x: 10, y: 0, handleMirroring: 'NONE' }, + { x: 10, y: 10, handleMirroring: 'NONE' } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [ + { windingRule: 'NONZERO', loops: [[0, 1], [2]] }, + { windingRule: 'EVENODD', loops: [[0, 2]] } + ] + } + const blob = encodeVectorNetworkBlob(network) + const decoded = decodeVectorNetworkBlob(blob) + expect(decoded.regions).toHaveLength(2) + expect(decoded.regions[0].loops).toEqual([[0, 1], [2]]) + expect(decoded.regions[1].loops).toEqual([[0, 2]]) + }) +}) + +describe('computeVectorBounds', () => { + test('empty network', () => { + expect(computeVectorBounds({ vertices: [], segments: [], regions: [] })).toEqual({ + x: 0, y: 0, width: 0, height: 0 + }) + }) + + test('single vertex', () => { + const bounds = computeVectorBounds({ + vertices: [{ x: 50, y: 30, handleMirroring: 'NONE' }], + segments: [], + regions: [] + }) + expect(bounds.x).toBe(50) + expect(bounds.y).toBe(30) + expect(bounds.width).toBe(0) + expect(bounds.height).toBe(0) + }) + + test('two vertices', () => { + const bounds = computeVectorBounds({ + vertices: [ + { x: 0, y: 0, handleMirroring: 'NONE' }, + { x: 100, y: 50, handleMirroring: 'NONE' } + ], + segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }], + regions: [] + }) + expect(bounds).toEqual({ x: 0, y: 0, width: 100, height: 50 }) + }) + + test('bezier control points extend bounds', () => { + const bounds = computeVectorBounds({ + vertices: [ + { x: 0, y: 0, handleMirroring: 'NONE' }, + { x: 100, y: 0, handleMirroring: 'NONE' } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: -50 }, tangentEnd: { x: 0, y: 50 } } + ], + regions: [] + }) + expect(bounds.y).toBe(-50) + expect(bounds.height).toBe(100) + }) +})