@zseven-w/pen-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { PenNode, PenDocument } from '@zseven-w/pen-types'
3
+ import {
4
+ createEmptyDocument,
5
+ findNodeInTree,
6
+ findParentInTree,
7
+ removeNodeFromTree,
8
+ updateNodeInTree,
9
+ flattenNodes,
10
+ insertNodeInTree,
11
+ isDescendantOf,
12
+ getActivePageChildren,
13
+ setActivePageChildren,
14
+ migrateToPages,
15
+ DEFAULT_FRAME_ID,
16
+ DEFAULT_PAGE_ID,
17
+ } from '../tree-utils'
18
+
19
+ const frame = (id: string, children: PenNode[] = []): PenNode => ({
20
+ id, type: 'frame', name: id, x: 0, y: 0, width: 100, height: 100,
21
+ fill: [{ type: 'solid', color: '#fff' }], children,
22
+ })
23
+
24
+ const rect = (id: string): PenNode => ({
25
+ id, type: 'rectangle', x: 0, y: 0, width: 50, height: 50,
26
+ })
27
+
28
+ describe('tree-utils', () => {
29
+ describe('createEmptyDocument', () => {
30
+ it('creates a document with a default page and root frame', () => {
31
+ const doc = createEmptyDocument()
32
+ expect(doc.version).toBe('1.0.0')
33
+ expect(doc.pages).toHaveLength(1)
34
+ expect(doc.pages![0].id).toBe(DEFAULT_PAGE_ID)
35
+ expect(doc.pages![0].children).toHaveLength(1)
36
+ expect(doc.pages![0].children[0].id).toBe(DEFAULT_FRAME_ID)
37
+ })
38
+ })
39
+
40
+ describe('findNodeInTree', () => {
41
+ it('finds a node by id at root level', () => {
42
+ const nodes = [rect('a'), rect('b')]
43
+ expect(findNodeInTree(nodes, 'b')?.id).toBe('b')
44
+ })
45
+
46
+ it('finds a nested node', () => {
47
+ const nodes = [frame('parent', [rect('child')])]
48
+ expect(findNodeInTree(nodes, 'child')?.id).toBe('child')
49
+ })
50
+
51
+ it('returns undefined for missing node', () => {
52
+ expect(findNodeInTree([rect('a')], 'missing')).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe('findParentInTree', () => {
57
+ it('finds the parent of a child node', () => {
58
+ const nodes = [frame('parent', [rect('child')])]
59
+ expect(findParentInTree(nodes, 'child')?.id).toBe('parent')
60
+ })
61
+
62
+ it('returns undefined for root nodes', () => {
63
+ expect(findParentInTree([rect('root')], 'root')).toBeUndefined()
64
+ })
65
+ })
66
+
67
+ describe('removeNodeFromTree', () => {
68
+ it('removes a root node', () => {
69
+ const result = removeNodeFromTree([rect('a'), rect('b')], 'a')
70
+ expect(result).toHaveLength(1)
71
+ expect(result[0].id).toBe('b')
72
+ })
73
+
74
+ it('removes a nested node', () => {
75
+ const nodes = [frame('parent', [rect('child1'), rect('child2')])]
76
+ const result = removeNodeFromTree(nodes, 'child1')
77
+ const parent = result[0] as PenNode & { children: PenNode[] }
78
+ expect(parent.children).toHaveLength(1)
79
+ expect(parent.children[0].id).toBe('child2')
80
+ })
81
+ })
82
+
83
+ describe('updateNodeInTree', () => {
84
+ it('updates a node by id', () => {
85
+ const nodes = [rect('a')]
86
+ const result = updateNodeInTree(nodes, 'a', { name: 'updated' })
87
+ expect(result[0].name).toBe('updated')
88
+ })
89
+
90
+ it('updates a nested node', () => {
91
+ const nodes = [frame('parent', [rect('child')])]
92
+ const result = updateNodeInTree(nodes, 'child', { name: 'updated' })
93
+ const parent = result[0] as PenNode & { children: PenNode[] }
94
+ expect(parent.children[0].name).toBe('updated')
95
+ })
96
+ })
97
+
98
+ describe('flattenNodes', () => {
99
+ it('flattens a nested tree', () => {
100
+ const nodes = [frame('a', [rect('b'), frame('c', [rect('d')])])]
101
+ const flat = flattenNodes(nodes)
102
+ expect(flat.map(n => n.id)).toEqual(['a', 'b', 'c', 'd'])
103
+ })
104
+ })
105
+
106
+ describe('insertNodeInTree', () => {
107
+ it('inserts at root level', () => {
108
+ const result = insertNodeInTree([rect('a')], null, rect('b'))
109
+ expect(result).toHaveLength(2)
110
+ expect(result[1].id).toBe('b')
111
+ })
112
+
113
+ it('inserts into a parent', () => {
114
+ const nodes = [frame('parent', [rect('existing')])]
115
+ const result = insertNodeInTree(nodes, 'parent', rect('new'))
116
+ const parent = result[0] as PenNode & { children: PenNode[] }
117
+ expect(parent.children).toHaveLength(2)
118
+ expect(parent.children[1].id).toBe('new')
119
+ })
120
+
121
+ it('inserts at a specific index', () => {
122
+ const nodes = [frame('parent', [rect('a'), rect('c')])]
123
+ const result = insertNodeInTree(nodes, 'parent', rect('b'), 1)
124
+ const parent = result[0] as PenNode & { children: PenNode[] }
125
+ expect(parent.children.map(n => n.id)).toEqual(['a', 'b', 'c'])
126
+ })
127
+ })
128
+
129
+ describe('isDescendantOf', () => {
130
+ it('returns true for a descendant', () => {
131
+ const nodes = [frame('a', [frame('b', [rect('c')])])]
132
+ expect(isDescendantOf(nodes, 'c', 'a')).toBe(true)
133
+ })
134
+
135
+ it('returns false for non-descendant', () => {
136
+ const nodes = [frame('a', [rect('b')]), rect('c')]
137
+ expect(isDescendantOf(nodes, 'c', 'a')).toBe(false)
138
+ })
139
+ })
140
+
141
+ describe('page helpers', () => {
142
+ it('getActivePageChildren returns page children', () => {
143
+ const doc = createEmptyDocument()
144
+ const children = getActivePageChildren(doc, DEFAULT_PAGE_ID)
145
+ expect(children).toHaveLength(1)
146
+ expect(children[0].id).toBe(DEFAULT_FRAME_ID)
147
+ })
148
+
149
+ it('setActivePageChildren replaces page children', () => {
150
+ const doc = createEmptyDocument()
151
+ const newChildren = [rect('new')]
152
+ const updated = setActivePageChildren(doc, DEFAULT_PAGE_ID, newChildren)
153
+ expect(updated.pages![0].children).toHaveLength(1)
154
+ expect(updated.pages![0].children[0].id).toBe('new')
155
+ })
156
+
157
+ it('migrateToPages wraps legacy doc', () => {
158
+ const legacy: PenDocument = { version: '1.0.0', children: [rect('a')] }
159
+ const migrated = migrateToPages(legacy)
160
+ expect(migrated.pages).toHaveLength(1)
161
+ expect(migrated.pages![0].children[0].id).toBe('a')
162
+ expect(migrated.children).toEqual([])
163
+ })
164
+
165
+ it('migrateToPages preserves existing pages', () => {
166
+ const doc = createEmptyDocument()
167
+ expect(migrateToPages(doc)).toBe(doc)
168
+ })
169
+ })
170
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { PenNode } from '@zseven-w/pen-types'
3
+ import type { VariableDefinition } from '@zseven-w/pen-types'
4
+ import {
5
+ isVariableRef,
6
+ getDefaultTheme,
7
+ resolveVariableRef,
8
+ resolveColorRef,
9
+ resolveNumericRef,
10
+ resolveNodeForCanvas,
11
+ } from '../variables/resolve'
12
+
13
+ const vars: Record<string, VariableDefinition> = {
14
+ 'primary': { type: 'color', value: '#3b82f6' },
15
+ 'spacing': { type: 'number', value: 16 },
16
+ 'themed-color': {
17
+ type: 'color',
18
+ value: [
19
+ { value: '#ffffff', theme: { 'Theme-1': 'Light' } },
20
+ { value: '#1a1a1a', theme: { 'Theme-1': 'Dark' } },
21
+ ],
22
+ },
23
+ }
24
+
25
+ describe('variables/resolve', () => {
26
+ describe('isVariableRef', () => {
27
+ it('returns true for $ prefixed strings', () => {
28
+ expect(isVariableRef('$primary')).toBe(true)
29
+ })
30
+
31
+ it('returns false for regular strings', () => {
32
+ expect(isVariableRef('#ff0000')).toBe(false)
33
+ })
34
+
35
+ it('returns false for non-strings', () => {
36
+ expect(isVariableRef(42)).toBe(false)
37
+ expect(isVariableRef(undefined)).toBe(false)
38
+ })
39
+ })
40
+
41
+ describe('getDefaultTheme', () => {
42
+ it('returns first value of each theme axis', () => {
43
+ const themes = { 'Theme-1': ['Light', 'Dark'], 'Size': ['Compact', 'Regular'] }
44
+ expect(getDefaultTheme(themes)).toEqual({
45
+ 'Theme-1': 'Light',
46
+ 'Size': 'Compact',
47
+ })
48
+ })
49
+
50
+ it('returns empty for undefined themes', () => {
51
+ expect(getDefaultTheme(undefined)).toEqual({})
52
+ })
53
+ })
54
+
55
+ describe('resolveVariableRef', () => {
56
+ it('resolves a simple color variable', () => {
57
+ expect(resolveVariableRef('$primary', vars)).toBe('#3b82f6')
58
+ })
59
+
60
+ it('resolves a number variable', () => {
61
+ expect(resolveVariableRef('$spacing', vars)).toBe(16)
62
+ })
63
+
64
+ it('returns undefined for missing variable', () => {
65
+ expect(resolveVariableRef('$missing', vars)).toBeUndefined()
66
+ })
67
+
68
+ it('resolves themed values with matching theme', () => {
69
+ expect(resolveVariableRef('$themed-color', vars, { 'Theme-1': 'Dark' })).toBe('#1a1a1a')
70
+ })
71
+
72
+ it('falls back to first value when no theme match', () => {
73
+ expect(resolveVariableRef('$themed-color', vars)).toBe('#ffffff')
74
+ })
75
+
76
+ it('returns undefined for non-ref strings', () => {
77
+ expect(resolveVariableRef('#ff0000', vars)).toBeUndefined()
78
+ })
79
+ })
80
+
81
+ describe('resolveColorRef', () => {
82
+ it('returns the color when not a ref', () => {
83
+ expect(resolveColorRef('#ff0000', vars)).toBe('#ff0000')
84
+ })
85
+
86
+ it('resolves a color ref', () => {
87
+ expect(resolveColorRef('$primary', vars)).toBe('#3b82f6')
88
+ })
89
+
90
+ it('returns undefined for undefined input', () => {
91
+ expect(resolveColorRef(undefined, vars)).toBeUndefined()
92
+ })
93
+ })
94
+
95
+ describe('resolveNumericRef', () => {
96
+ it('returns the number when not a ref', () => {
97
+ expect(resolveNumericRef(42, vars)).toBe(42)
98
+ })
99
+
100
+ it('resolves a numeric ref', () => {
101
+ expect(resolveNumericRef('$spacing', vars)).toBe(16)
102
+ })
103
+
104
+ it('returns undefined for non-numeric results', () => {
105
+ expect(resolveNumericRef('$primary', vars)).toBeUndefined()
106
+ })
107
+ })
108
+
109
+ describe('resolveNodeForCanvas', () => {
110
+ it('returns same node when no variables', () => {
111
+ const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0 }
112
+ expect(resolveNodeForCanvas(node, {})).toBe(node)
113
+ })
114
+
115
+ it('resolves $variable opacity', () => {
116
+ const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0, opacity: '$spacing' }
117
+ const resolved = resolveNodeForCanvas(node, vars)
118
+ expect(resolved.opacity).toBe(16)
119
+ expect(resolved).not.toBe(node)
120
+ })
121
+
122
+ it('resolves fill colors', () => {
123
+ const node: PenNode = {
124
+ id: '1', type: 'rectangle', x: 0, y: 0,
125
+ fill: [{ type: 'solid', color: '$primary' }],
126
+ }
127
+ const resolved = resolveNodeForCanvas(node, vars)
128
+ const fill = (resolved as { fill: Array<{ color: string }> }).fill
129
+ expect(fill[0].color).toBe('#3b82f6')
130
+ })
131
+ })
132
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Build an SVG path `d` string for an ellipse arc (pie slice, donut segment, or ring).
3
+ *
4
+ * @param w - Bounding box width
5
+ * @param h - Bounding box height
6
+ * @param startDeg - Start angle in degrees (0 = right / 3 o'clock, clockwise)
7
+ * @param sweepDeg - Sweep angle in degrees (extent of the arc)
8
+ * @param inner - Inner radius ratio 0..1 (0 = pie, >0 = donut)
9
+ */
10
+ export function buildEllipseArcPath(
11
+ w: number,
12
+ h: number,
13
+ startDeg: number,
14
+ sweepDeg: number,
15
+ inner: number,
16
+ ): string {
17
+ const startRad = (startDeg * Math.PI) / 180
18
+ const sweepRad = (sweepDeg * Math.PI) / 180
19
+ const endRad = startRad + sweepRad
20
+
21
+ const rx = w / 2
22
+ const ry = h / 2
23
+ const cx = rx
24
+ const cy = ry
25
+
26
+ // Outer arc endpoints
27
+ const ox1 = cx + rx * Math.cos(startRad)
28
+ const oy1 = cy + ry * Math.sin(startRad)
29
+ const ox2 = cx + rx * Math.cos(endRad)
30
+ const oy2 = cy + ry * Math.sin(endRad)
31
+
32
+ const large = sweepRad > Math.PI ? 1 : 0
33
+
34
+ // Near-full circle (>=~359.9°): split into two semicircular arcs
35
+ if (sweepRad > Math.PI * 2 - 0.02) {
36
+ const midRad = startRad + Math.PI
37
+ const omx = cx + rx * Math.cos(midRad)
38
+ const omy = cy + ry * Math.sin(midRad)
39
+
40
+ if (inner <= 0.001) {
41
+ return [
42
+ `M${f(ox1)} ${f(oy1)}`,
43
+ `A${f(rx)} ${f(ry)} 0 1 1 ${f(omx)} ${f(omy)}`,
44
+ `A${f(rx)} ${f(ry)} 0 1 1 ${f(ox1)} ${f(oy1)}`,
45
+ 'Z',
46
+ ].join(' ')
47
+ }
48
+
49
+ const irx = rx * inner
50
+ const iry = ry * inner
51
+ const ix1 = cx + irx * Math.cos(startRad)
52
+ const iy1 = cy + iry * Math.sin(startRad)
53
+ const imx = cx + irx * Math.cos(midRad)
54
+ const imy = cy + iry * Math.sin(midRad)
55
+ return [
56
+ `M${f(ox1)} ${f(oy1)}`,
57
+ `A${f(rx)} ${f(ry)} 0 1 1 ${f(omx)} ${f(omy)}`,
58
+ `A${f(rx)} ${f(ry)} 0 1 1 ${f(ox1)} ${f(oy1)}`,
59
+ `L${f(ix1)} ${f(iy1)}`,
60
+ `A${f(irx)} ${f(iry)} 0 1 0 ${f(imx)} ${f(imy)}`,
61
+ `A${f(irx)} ${f(iry)} 0 1 0 ${f(ix1)} ${f(iy1)}`,
62
+ 'Z',
63
+ ].join(' ')
64
+ }
65
+
66
+ if (inner <= 0.001) {
67
+ // Pie slice: center → outer start → arc → close
68
+ return `M${f(cx)} ${f(cy)} L${f(ox1)} ${f(oy1)} A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)} Z`
69
+ }
70
+
71
+ // Donut slice: outer arc → line to inner → inner arc (reversed) → close
72
+ const irx = rx * inner
73
+ const iry = ry * inner
74
+ const ix1 = cx + irx * Math.cos(startRad)
75
+ const iy1 = cy + iry * Math.sin(startRad)
76
+ const ix2 = cx + irx * Math.cos(endRad)
77
+ const iy2 = cy + iry * Math.sin(endRad)
78
+ return [
79
+ `M${f(ox1)} ${f(oy1)}`,
80
+ `A${f(rx)} ${f(ry)} 0 ${large} 1 ${f(ox2)} ${f(oy2)}`,
81
+ `L${f(ix2)} ${f(iy2)}`,
82
+ `A${f(irx)} ${f(iry)} 0 ${large} 0 ${f(ix1)} ${f(iy1)}`,
83
+ 'Z',
84
+ ].join(' ')
85
+ }
86
+
87
+ /** True when the arc parameters describe something other than a plain full ellipse. */
88
+ export function isArcEllipse(
89
+ _startAngle?: number,
90
+ sweepAngle?: number,
91
+ innerRadius?: number,
92
+ ): boolean {
93
+ const sweep = sweepAngle ?? 360
94
+ const inner = innerRadius ?? 0
95
+ return sweep < 359.9 || inner > 0.001
96
+ }
97
+
98
+ function f(n: number): string {
99
+ return Math.abs(n) < 0.005 ? '0' : parseFloat(n.toFixed(2)).toString()
100
+ }
@@ -0,0 +1,256 @@
1
+ import paper from 'paper'
2
+ import { nanoid } from 'nanoid'
3
+ import type { PenNode, PathNode } from '@zseven-w/pen-types'
4
+
5
+ export type BooleanOpType = 'union' | 'subtract' | 'intersect'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Paper.js scope — headless (no canvas needed)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ let scope: paper.PaperScope | null = null
12
+
13
+ function getScope(): paper.PaperScope {
14
+ if (!scope) {
15
+ scope = new paper.PaperScope()
16
+ scope.setup(new scope.Size(1, 1))
17
+ }
18
+ scope.activate()
19
+ return scope
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Shape → SVG path string conversion
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function sizeVal(v: number | string | undefined, fallback: number): number {
27
+ if (typeof v === 'number') return v
28
+ if (typeof v === 'string') {
29
+ const m = v.match(/\((\d+(?:\.\d+)?)\)/)
30
+ if (m) return parseFloat(m[1])
31
+ const n = parseFloat(v)
32
+ if (!isNaN(n)) return n
33
+ }
34
+ return fallback
35
+ }
36
+
37
+ function rectToPath(
38
+ w: number,
39
+ h: number,
40
+ cr?: number | [number, number, number, number],
41
+ ): string {
42
+ if (!cr || (typeof cr === 'number' && cr === 0)) {
43
+ return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`
44
+ }
45
+ let [tl, tr, br, bl] =
46
+ typeof cr === 'number' ? [cr, cr, cr, cr] : cr
47
+ const maxR = Math.min(w, h) / 2
48
+ tl = Math.min(tl, maxR)
49
+ tr = Math.min(tr, maxR)
50
+ br = Math.min(br, maxR)
51
+ bl = Math.min(bl, maxR)
52
+ return [
53
+ `M ${tl} 0`,
54
+ `L ${w - tr} 0`,
55
+ tr > 0 ? `A ${tr} ${tr} 0 0 1 ${w} ${tr}` : '',
56
+ `L ${w} ${h - br}`,
57
+ br > 0 ? `A ${br} ${br} 0 0 1 ${w - br} ${h}` : '',
58
+ `L ${bl} ${h}`,
59
+ bl > 0 ? `A ${bl} ${bl} 0 0 1 0 ${h - bl}` : '',
60
+ `L 0 ${tl}`,
61
+ tl > 0 ? `A ${tl} ${tl} 0 0 1 ${tl} 0` : '',
62
+ 'Z',
63
+ ]
64
+ .filter(Boolean)
65
+ .join(' ')
66
+ }
67
+
68
+ function ellipseToPath(rx: number, ry: number): string {
69
+ // 4-arc approximation of an ellipse centered at (rx, ry)
70
+ return [
71
+ `M ${rx * 2} ${ry}`,
72
+ `A ${rx} ${ry} 0 0 1 ${rx} ${ry * 2}`,
73
+ `A ${rx} ${ry} 0 0 1 0 ${ry}`,
74
+ `A ${rx} ${ry} 0 0 1 ${rx} 0`,
75
+ `A ${rx} ${ry} 0 0 1 ${rx * 2} ${ry}`,
76
+ 'Z',
77
+ ].join(' ')
78
+ }
79
+
80
+ function polygonToPath(count: number, w: number, h: number): string {
81
+ const raw: [number, number][] = []
82
+ for (let i = 0; i < count; i++) {
83
+ const angle = (i * 2 * Math.PI) / count - Math.PI / 2
84
+ raw.push([Math.cos(angle), Math.sin(angle)])
85
+ }
86
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
87
+ for (const [rx, ry] of raw) {
88
+ if (rx < minX) minX = rx
89
+ if (rx > maxX) maxX = rx
90
+ if (ry < minY) minY = ry
91
+ if (ry > maxY) maxY = ry
92
+ }
93
+ const rw = maxX - minX
94
+ const rh = maxY - minY
95
+ const parts: string[] = []
96
+ for (let i = 0; i < count; i++) {
97
+ const px = ((raw[i][0] - minX) / rw) * w
98
+ const py = ((raw[i][1] - minY) / rh) * h
99
+ parts.push(i === 0 ? `M ${px} ${py}` : `L ${px} ${py}`)
100
+ }
101
+ parts.push('Z')
102
+ return parts.join(' ')
103
+ }
104
+
105
+ /** Convert a shape node to an SVG path `d` string in local coordinates (origin at 0,0). */
106
+ function nodeToLocalPath(node: PenNode): string | null {
107
+ switch (node.type) {
108
+ case 'rectangle':
109
+ case 'frame': {
110
+ const w = sizeVal(node.width, 100)
111
+ const h = sizeVal(node.height, 100)
112
+ return rectToPath(w, h, node.cornerRadius)
113
+ }
114
+ case 'ellipse': {
115
+ const w = sizeVal(node.width, 100)
116
+ const h = sizeVal(node.height, 100)
117
+ return ellipseToPath(w / 2, h / 2)
118
+ }
119
+ case 'polygon': {
120
+ const w = sizeVal(node.width, 100)
121
+ const h = sizeVal(node.height, 100)
122
+ return polygonToPath(node.polygonCount || 6, w, h)
123
+ }
124
+ case 'path':
125
+ return node.d
126
+ case 'line':
127
+ return `M 0 0 L ${(node.x2 ?? (node.x ?? 0) + 100) - (node.x ?? 0)} ${(node.y2 ?? (node.y ?? 0)) - (node.y ?? 0)}`
128
+ default:
129
+ return null
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Boolean operation helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** Types that can participate in boolean operations. */
138
+ const BOOLEAN_TYPES = new Set([
139
+ 'rectangle',
140
+ 'ellipse',
141
+ 'polygon',
142
+ 'path',
143
+ 'line',
144
+ 'frame',
145
+ ])
146
+
147
+ export function canBooleanOp(nodes: PenNode[]): boolean {
148
+ if (nodes.length < 2) return false
149
+ return nodes.every((n) => BOOLEAN_TYPES.has(n.type))
150
+ }
151
+
152
+ /**
153
+ * Create a Paper.js PathItem from a PenNode, positioned in absolute scene
154
+ * coordinates (applying x, y, rotation).
155
+ */
156
+ function nodeToPaperPath(node: PenNode): paper.PathItem | null {
157
+ const d = nodeToLocalPath(node)
158
+ if (!d) return null
159
+
160
+ const s = getScope()
161
+ let item: paper.PathItem
162
+ try {
163
+ item = s.CompoundPath.create(d)
164
+ } catch {
165
+ return null
166
+ }
167
+
168
+ // Apply node transform: translate to (x, y), then rotate around center
169
+ const x = node.x ?? 0
170
+ const y = node.y ?? 0
171
+ item.translate(new s.Point(x, y))
172
+
173
+ const rotation = node.rotation ?? 0
174
+ if (rotation !== 0) {
175
+ // Rotate around the bounding-box center of the translated item
176
+ item.rotate(rotation, item.bounds.center)
177
+ }
178
+
179
+ return item
180
+ }
181
+
182
+ /**
183
+ * Execute a boolean operation on the given PenNodes.
184
+ * Returns a new PathNode with the result, or null on failure.
185
+ */
186
+ export function executeBooleanOp(
187
+ nodes: PenNode[],
188
+ operation: BooleanOpType,
189
+ ): PathNode | null {
190
+ if (nodes.length < 2) return null
191
+
192
+ const paperPaths = nodes.map(nodeToPaperPath)
193
+ if (paperPaths.some((p) => p === null)) return null
194
+
195
+ const paths = paperPaths as paper.PathItem[]
196
+
197
+ // Accumulate: fold left with the boolean operation
198
+ let result = paths[0]
199
+ for (let i = 1; i < paths.length; i++) {
200
+ switch (operation) {
201
+ case 'union':
202
+ result = result.unite(paths[i])
203
+ break
204
+ case 'subtract':
205
+ result = result.subtract(paths[i])
206
+ break
207
+ case 'intersect':
208
+ result = result.intersect(paths[i])
209
+ break
210
+ }
211
+ }
212
+
213
+ // Extract SVG path data
214
+ const pathData = result.pathData
215
+ if (!pathData || pathData.trim().length === 0) return null
216
+
217
+ // Get bounding box for positioning
218
+ const bounds = result.bounds
219
+
220
+ // Translate path so it starts at origin (0,0)
221
+ result.translate(new paper.Point(-bounds.x, -bounds.y))
222
+ const originPathData = result.pathData
223
+
224
+ // Clean up Paper.js items
225
+ for (const p of paths) p.remove()
226
+ result.remove()
227
+
228
+ // Build the label
229
+ const opLabels: Record<BooleanOpType, string> = {
230
+ union: 'Union',
231
+ subtract: 'Subtract',
232
+ intersect: 'Intersect',
233
+ }
234
+
235
+ // Inherit style from first operand
236
+ const first = nodes[0]
237
+ const fill = 'fill' in first ? first.fill : undefined
238
+ const stroke = 'stroke' in first ? first.stroke : undefined
239
+ const effects = 'effects' in first ? first.effects : undefined
240
+ const opacity = first.opacity
241
+
242
+ return {
243
+ id: nanoid(),
244
+ type: 'path',
245
+ name: opLabels[operation],
246
+ d: originPathData,
247
+ x: bounds.x,
248
+ y: bounds.y,
249
+ width: Math.round(bounds.width * 100) / 100,
250
+ height: Math.round(bounds.height * 100) / 100,
251
+ fill,
252
+ stroke,
253
+ effects,
254
+ opacity,
255
+ }
256
+ }
@@ -0,0 +1,49 @@
1
+ export const MIN_ZOOM = 0.02
2
+ export const MAX_ZOOM = 256
3
+ export const ZOOM_STEP = 0.1
4
+ export const SNAP_THRESHOLD = 5
5
+ export const DEFAULT_FILL = '#d1d5db'
6
+ export const DEFAULT_STROKE = '#374151'
7
+ export const DEFAULT_STROKE_WIDTH = 1
8
+ export const CANVAS_BACKGROUND_LIGHT = '#e5e5e5'
9
+ export const CANVAS_BACKGROUND_DARK = '#1a1a1a'
10
+
11
+ export const SELECTION_BLUE = '#0d99ff'
12
+ export const COMPONENT_COLOR = '#a855f7'
13
+ export const INSTANCE_COLOR = '#9281f7'
14
+
15
+ // Hover / overlay / indicator
16
+ export const HOVER_BLUE = '#3b82f6'
17
+ export const HOVER_LINE_WIDTH = 1.5
18
+ export const HOVER_DASH = [4, 4]
19
+ export const INDICATOR_BLUE = '#3B82F6'
20
+ export const INDICATOR_LINE_WIDTH = 2
21
+ export const INDICATOR_DASH = [6, 4]
22
+ export const INDICATOR_ENDPOINT_RADIUS = 3
23
+
24
+ // Frame labels
25
+ export const FRAME_LABEL_FONT_SIZE = 12
26
+ export const FRAME_LABEL_OFFSET_Y = 6
27
+ export const FRAME_LABEL_COLOR = '#999999'
28
+
29
+ // Pen tool
30
+ export const PEN_ANCHOR_FILL = '#ffffff'
31
+ export const PEN_ANCHOR_RADIUS = 4
32
+ export const PEN_ANCHOR_FIRST_RADIUS = 5
33
+ export const PEN_HANDLE_DOT_RADIUS = 3
34
+ export const PEN_HANDLE_LINE_STROKE = '#888888'
35
+ export const PEN_RUBBER_BAND_STROKE = 'rgba(13, 153, 255, 0.5)'
36
+ export const PEN_RUBBER_BAND_DASH = [4, 4]
37
+ export const PEN_CLOSE_HIT_THRESHOLD = 8
38
+
39
+ // Dimension label
40
+ export const DIMENSION_LABEL_OFFSET_Y = 8
41
+
42
+ // Default node colors
43
+ export const DEFAULT_FRAME_FILL = '#ffffff'
44
+ export const DEFAULT_TEXT_FILL = '#000000'
45
+
46
+ // Smart guides
47
+ export const GUIDE_COLOR = '#FF6B35'
48
+ export const GUIDE_LINE_WIDTH = 1
49
+ export const GUIDE_DASH = [3, 3]