@zseven-w/pen-renderer 0.6.0 → 0.7.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.
@@ -1,15 +1,16 @@
1
- import RBush from 'rbush'
2
- import type { RenderNode } from './types.js'
1
+ import RBush from 'rbush';
2
+ import type { RenderNode } from './types.js';
3
+ import type { PenEffect, PenFill, PenNode, PenStroke } from '@zseven-w/pen-types';
3
4
 
4
5
  interface RTreeItem {
5
- minX: number
6
- minY: number
7
- maxX: number
8
- maxY: number
9
- nodeId: string
10
- renderNode: RenderNode
6
+ minX: number;
7
+ minY: number;
8
+ maxX: number;
9
+ maxY: number;
10
+ nodeId: string;
11
+ renderNode: RenderNode;
11
12
  /** Position in the render array — higher = rendered later = visually on top */
12
- zIndex: number
13
+ zIndex: number;
13
14
  }
14
15
 
15
16
  /**
@@ -18,21 +19,21 @@ interface RTreeItem {
18
19
  * are sorted topmost-first (children before parents).
19
20
  */
20
21
  export class SpatialIndex {
21
- private tree = new RBush<RTreeItem>()
22
- private items = new Map<string, RTreeItem>()
22
+ private tree = new RBush<RTreeItem>();
23
+ private items = new Map<string, RTreeItem>();
23
24
 
24
25
  /**
25
26
  * Rebuild the entire index from a list of render nodes.
26
27
  */
27
28
  rebuild(nodes: RenderNode[]) {
28
- this.tree.clear()
29
- this.items.clear()
29
+ this.tree.clear();
30
+ this.items.clear();
30
31
 
31
- const items: RTreeItem[] = []
32
+ const items: RTreeItem[] = [];
32
33
  for (let i = 0; i < nodes.length; i++) {
33
- const rn = nodes[i]
34
- if (('visible' in rn.node ? rn.node.visible : undefined) === false) continue
35
- if (('locked' in rn.node ? rn.node.locked : undefined) === true) continue
34
+ const rn = nodes[i];
35
+ if (('visible' in rn.node ? rn.node.visible : undefined) === false) continue;
36
+ if (('locked' in rn.node ? rn.node.locked : undefined) === true) continue;
36
37
 
37
38
  const item: RTreeItem = {
38
39
  minX: rn.absX,
@@ -42,12 +43,12 @@ export class SpatialIndex {
42
43
  nodeId: rn.node.id,
43
44
  renderNode: rn,
44
45
  zIndex: i,
45
- }
46
- items.push(item)
47
- this.items.set(rn.node.id, item)
46
+ };
47
+ items.push(item);
48
+ this.items.set(rn.node.id, item);
48
49
  }
49
50
 
50
- this.tree.load(items)
51
+ this.tree.load(items);
51
52
  }
52
53
 
53
54
  /**
@@ -60,11 +61,11 @@ export class SpatialIndex {
60
61
  minY: sceneY,
61
62
  maxX: sceneX,
62
63
  maxY: sceneY,
63
- })
64
+ });
64
65
 
65
66
  // Sort by zIndex descending — children (rendered later) come first
66
- candidates.sort((a, b) => b.zIndex - a.zIndex)
67
- return candidates.map((c) => c.renderNode)
67
+ candidates.sort((a, b) => b.zIndex - a.zIndex);
68
+ return candidates.map((c) => c.renderNode).filter((rn) => isPointHittableRenderNode(rn));
68
69
  }
69
70
 
70
71
  /**
@@ -76,14 +77,125 @@ export class SpatialIndex {
76
77
  minY: Math.min(top, bottom),
77
78
  maxX: Math.max(left, right),
78
79
  maxY: Math.max(top, bottom),
79
- })
80
- return candidates.map((c) => c.renderNode)
80
+ });
81
+ return candidates.map((c) => c.renderNode);
81
82
  }
82
83
 
83
84
  /**
84
85
  * Get the render node for a specific node ID.
85
86
  */
86
87
  get(nodeId: string): RenderNode | undefined {
87
- return this.items.get(nodeId)?.renderNode
88
+ return this.items.get(nodeId)?.renderNode;
89
+ }
90
+ }
91
+
92
+ function isPointHittableRenderNode(renderNode: RenderNode): boolean {
93
+ const node = renderNode.node;
94
+ if (resolveNodeOpacity(node.opacity) <= 0) return false;
95
+
96
+ if (node.type === 'frame' || node.type === 'group' || node.type === 'rectangle') {
97
+ const hasExplicitAppearance =
98
+ (Array.isArray(node.fill) && node.fill.length > 0) ||
99
+ !!node.stroke ||
100
+ (Array.isArray(node.effects) && node.effects.length > 0);
101
+ if (!hasExplicitAppearance) {
102
+ if (node.type === 'frame' || node.type === 'group') {
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+ return (
108
+ hasVisibleFill(node.fill) || hasVisibleStroke(node.stroke) || hasVisibleEffects(node.effects)
109
+ );
110
+ }
111
+
112
+ return true;
113
+ }
114
+
115
+ function hasVisibleFill(fill: PenFill[] | undefined): boolean {
116
+ if (!Array.isArray(fill) || fill.length === 0) return false;
117
+ return fill.some((entry) => {
118
+ const opacity = resolveNodeOpacity(entry.opacity);
119
+ if (opacity <= 0) return false;
120
+
121
+ switch (entry.type) {
122
+ case 'solid':
123
+ return hasVisibleColor(entry.color);
124
+ case 'linear_gradient':
125
+ case 'radial_gradient':
126
+ return entry.stops.some((stop) => hasVisibleColor(stop.color));
127
+ case 'image':
128
+ return !!entry.url;
129
+ default:
130
+ return false;
131
+ }
132
+ });
133
+ }
134
+
135
+ function hasVisibleStroke(stroke: PenStroke | undefined): boolean {
136
+ if (!stroke) return false;
137
+ const thickness = resolveStrokeThickness(stroke);
138
+ if (thickness <= 0) return false;
139
+ return hasVisibleFill(stroke.fill);
140
+ }
141
+
142
+ function hasVisibleEffects(effects: PenEffect[] | undefined): boolean {
143
+ if (!Array.isArray(effects) || effects.length === 0) return false;
144
+ return effects.some((effect) => {
145
+ if (effect.type === 'shadow') {
146
+ return (
147
+ hasVisibleColor(effect.color) &&
148
+ (effect.blur > 0 || effect.spread !== 0 || effect.offsetX !== 0 || effect.offsetY !== 0)
149
+ );
150
+ }
151
+
152
+ return effect.radius > 0;
153
+ });
154
+ }
155
+
156
+ function hasVisibleColor(color: string | undefined): boolean {
157
+ if (!color) return false;
158
+ return resolveColorAlpha(color) > 0;
159
+ }
160
+
161
+ function resolveColorAlpha(color: string): number {
162
+ const normalized = color.trim().toLowerCase();
163
+ if (!normalized) return 0;
164
+ if (normalized === 'transparent') return 0;
165
+
166
+ const hex = normalized.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i)?.[1];
167
+ if (hex) {
168
+ if (hex.length === 4) return parseInt(hex[3] + hex[3], 16) / 255;
169
+ if (hex.length === 8) return parseInt(hex.slice(6, 8), 16) / 255;
170
+ return 1;
171
+ }
172
+
173
+ const rgbaMatch = normalized.match(/^rgba?\(([^)]+)\)$/);
174
+ if (rgbaMatch) {
175
+ const parts = rgbaMatch[1].split(',').map((part) => part.trim());
176
+ if (parts.length >= 4) {
177
+ const alpha = Number.parseFloat(parts[3]);
178
+ return Number.isFinite(alpha) ? alpha : 1;
179
+ }
180
+ return 1;
181
+ }
182
+
183
+ return 1;
184
+ }
185
+
186
+ function resolveNodeOpacity(opacity: PenNode['opacity'] | PenFill['opacity']): number {
187
+ if (typeof opacity === 'number') return opacity;
188
+ if (typeof opacity === 'string') {
189
+ const parsed = Number.parseFloat(opacity);
190
+ if (Number.isFinite(parsed)) return parsed;
191
+ }
192
+ return 1;
193
+ }
194
+
195
+ function resolveStrokeThickness(stroke: PenStroke): number {
196
+ if (typeof stroke.thickness === 'number') return stroke.thickness;
197
+ if (Array.isArray(stroke.thickness)) {
198
+ return Math.max(...stroke.thickness);
88
199
  }
200
+ return 0;
89
201
  }