@jamesyong42/infinite-canvas 1.2.0 → 1.3.0

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.
Files changed (56) hide show
  1. package/README.md +65 -0
  2. package/dist/advanced.cjs +61 -24
  3. package/dist/advanced.cjs.map +1 -1
  4. package/dist/advanced.d.cts +180 -64
  5. package/dist/advanced.d.cts.map +1 -1
  6. package/dist/advanced.d.mts +180 -64
  7. package/dist/advanced.d.mts.map +1 -1
  8. package/dist/advanced.mjs +29 -12
  9. package/dist/advanced.mjs.map +1 -1
  10. package/dist/devtools.cjs +22 -22
  11. package/dist/devtools.cjs.map +1 -1
  12. package/dist/devtools.d.cts +2 -2
  13. package/dist/devtools.d.cts.map +1 -1
  14. package/dist/devtools.d.mts +2 -2
  15. package/dist/devtools.d.mts.map +1 -1
  16. package/dist/devtools.mjs +2 -2
  17. package/dist/devtools.mjs.map +1 -1
  18. package/dist/{hooks-BwY7rRHg.mjs → ecs-3kimUV5Z.mjs} +238 -74
  19. package/dist/ecs-3kimUV5Z.mjs.map +1 -0
  20. package/dist/{hooks-DHShH86C.cjs → ecs-B4QrqfvQ.cjs} +320 -108
  21. package/dist/ecs-B4QrqfvQ.cjs.map +1 -0
  22. package/dist/hooks-CtP02JNt.cjs +3762 -0
  23. package/dist/hooks-CtP02JNt.cjs.map +1 -0
  24. package/dist/hooks-gsQDDE56.mjs +3494 -0
  25. package/dist/hooks-gsQDDE56.mjs.map +1 -0
  26. package/dist/index-3GY7T8JM.d.mts +480 -0
  27. package/dist/index-3GY7T8JM.d.mts.map +1 -0
  28. package/dist/index-B7B1tRPl.d.cts +480 -0
  29. package/dist/index-B7B1tRPl.d.cts.map +1 -0
  30. package/dist/index-DSdbSQ_t.d.cts +1451 -0
  31. package/dist/index-DSdbSQ_t.d.cts.map +1 -0
  32. package/dist/index-Dj9odADH.d.mts +1451 -0
  33. package/dist/index-Dj9odADH.d.mts.map +1 -0
  34. package/dist/index.cjs +3865 -643
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +315 -138
  37. package/dist/index.d.cts.map +1 -1
  38. package/dist/index.d.mts +315 -138
  39. package/dist/index.d.mts.map +1 -1
  40. package/dist/index.mjs +3767 -571
  41. package/dist/index.mjs.map +1 -1
  42. package/package.json +1 -1
  43. package/dist/SelectionRenderer-CR2PBQwx.d.cts +0 -105
  44. package/dist/SelectionRenderer-CR2PBQwx.d.cts.map +0 -1
  45. package/dist/SelectionRenderer-DlsBstAq.d.mts +0 -105
  46. package/dist/SelectionRenderer-DlsBstAq.d.mts.map +0 -1
  47. package/dist/WebGLWidgetLayer-BBMuwzHq.cjs +0 -3560
  48. package/dist/WebGLWidgetLayer-BBMuwzHq.cjs.map +0 -1
  49. package/dist/WebGLWidgetLayer-C3p1tnpm.mjs +0 -3375
  50. package/dist/WebGLWidgetLayer-C3p1tnpm.mjs.map +0 -1
  51. package/dist/engine-BfbvWXSk.d.mts +0 -982
  52. package/dist/engine-BfbvWXSk.d.mts.map +0 -1
  53. package/dist/engine-CCjuFMC-.d.cts +0 -982
  54. package/dist/engine-CCjuFMC-.d.cts.map +0 -1
  55. package/dist/hooks-BwY7rRHg.mjs.map +0 -1
  56. package/dist/hooks-DHShH86C.cjs.map +0 -1
@@ -0,0 +1,3494 @@
1
+ import { B as OverlapTarget, D as CardOverlapHotPoint, E as Card, F as Dragging, M as Culled, Q as Widget, T as Active, Y as Transform2D, Z as Visible, f as EngineProvider, p as useLayoutEngine, r as useComponent, u as useTag, z as OverlapCandidate } from "./ecs-3kimUV5Z.mjs";
2
+ import { defineComponent, defineResource, defineTag } from "@jamesyong42/reactive-ecs";
3
+ import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from "react";
4
+ import { Canvas, createPortal, events, useFrame, useThree } from "@react-three/fiber";
5
+ import * as THREE from "three";
6
+ import { Mesh, OrthographicCamera, PlaneGeometry, SRGBColorSpace, Scene, ShaderMaterial, Vector2, Vector3, Vector4, WebGLRenderTarget } from "three";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/profiler/Profiler.ts
9
+ const TICK_RING_SIZE = 300;
10
+ const R3F_RING_SIZE = 300;
11
+ var Profiler = class {
12
+ enabled = false;
13
+ tickRing = [];
14
+ tickWrite = 0;
15
+ tickFilled = false;
16
+ r3fRing = [];
17
+ r3fWrite = 0;
18
+ r3fFilled = false;
19
+ frameStart = 0;
20
+ currentSystems = {};
21
+ visibilityMs = 0;
22
+ currentTick = 0;
23
+ /** Enable/disable profiling. When disabled, all methods are no-ops. */
24
+ setEnabled(on) {
25
+ this.enabled = on;
26
+ if (!on) this.clear();
27
+ }
28
+ isEnabled() {
29
+ return this.enabled;
30
+ }
31
+ /** Call at the start of engine.tick(). */
32
+ beginFrame(tick) {
33
+ if (!this.enabled) return;
34
+ this.currentTick = tick;
35
+ this.currentSystems = {};
36
+ this.visibilityMs = 0;
37
+ this.frameStart = performance.now();
38
+ performance.mark("ic-frame-start");
39
+ }
40
+ /** Call around each ECS system execution. */
41
+ beginSystem(name) {
42
+ if (!this.enabled) return;
43
+ performance.mark(`ic-sys-${name}-start`);
44
+ }
45
+ endSystem(name) {
46
+ if (!this.enabled) return;
47
+ performance.mark(`ic-sys-${name}-end`);
48
+ try {
49
+ const measure = performance.measure(`ic:sys:${name}`, `ic-sys-${name}-start`, `ic-sys-${name}-end`);
50
+ this.currentSystems[name] = measure.duration;
51
+ } catch {}
52
+ performance.clearMarks(`ic-sys-${name}-start`);
53
+ performance.clearMarks(`ic-sys-${name}-end`);
54
+ }
55
+ beginVisibility() {
56
+ if (!this.enabled) return;
57
+ performance.mark("ic-vis-start");
58
+ }
59
+ endVisibility() {
60
+ if (!this.enabled) return;
61
+ performance.mark("ic-vis-end");
62
+ try {
63
+ const measure = performance.measure("ic:visibility", "ic-vis-start", "ic-vis-end");
64
+ this.visibilityMs = measure.duration;
65
+ } catch {}
66
+ performance.clearMarks("ic-vis-start");
67
+ performance.clearMarks("ic-vis-end");
68
+ }
69
+ /** Call right before the named engine WebGL pass renders. */
70
+ beginWebGL(pass) {
71
+ if (!this.enabled) return;
72
+ performance.mark(`ic-gl-${pass}-start`);
73
+ }
74
+ /**
75
+ * Call right after the named engine WebGL pass renders.
76
+ *
77
+ * The WebGL pass runs AFTER endFrame has flushed the tick sample to the
78
+ * ring, so we mutate the most-recent ring entry in place instead of
79
+ * writing to scratch state (which would be wiped by the next beginFrame
80
+ * before ever being read).
81
+ */
82
+ endWebGL(pass) {
83
+ if (!this.enabled) return;
84
+ performance.mark(`ic-gl-${pass}-end`);
85
+ try {
86
+ const measure = performance.measure(`ic:gl:${pass}`, `ic-gl-${pass}-start`, `ic-gl-${pass}-end`);
87
+ const sample = this.getMostRecentTickSample();
88
+ if (sample) if (pass === "grid") sample.webgl.gridMs = measure.duration;
89
+ else sample.webgl.selectionMs = measure.duration;
90
+ } catch {}
91
+ performance.clearMarks(`ic-gl-${pass}-start`);
92
+ performance.clearMarks(`ic-gl-${pass}-end`);
93
+ }
94
+ /**
95
+ * Record WebGL engine pass counters for the current tick. `drawCalls` and
96
+ * `triangles` should be the totals from `renderer.info.render` accumulated
97
+ * across all engine passes in this tick (grid + selection). Callers must
98
+ * reset `renderer.info` at the start of the tick (with `autoReset=false`)
99
+ * so these values cover both passes.
100
+ *
101
+ * Called from the rAF loop AFTER engine.tick() / endFrame, so it targets
102
+ * the most-recent ring sample directly (see {@link endWebGL}).
103
+ */
104
+ recordWebGLStats(stats) {
105
+ if (!this.enabled) return;
106
+ const sample = this.getMostRecentTickSample();
107
+ if (!sample) return;
108
+ sample.webgl.drawCalls = stats.drawCalls;
109
+ sample.webgl.triangles = stats.triangles;
110
+ sample.webgl.selectionFrames = stats.selectionFrames;
111
+ sample.webgl.snapGuides = stats.snapGuides;
112
+ sample.webgl.spacingIndicators = stats.spacingIndicators;
113
+ sample.webgl.domPositionsUpdated = stats.domPositionsUpdated;
114
+ }
115
+ /** Returns the most recently pushed tick sample, or null if the ring is empty. */
116
+ getMostRecentTickSample() {
117
+ const n = this.tickRing.length;
118
+ if (n === 0) return null;
119
+ const idx = (this.tickWrite - 1 + TICK_RING_SIZE) % TICK_RING_SIZE;
120
+ return this.tickRing[idx % n] ?? null;
121
+ }
122
+ /** Call at the end of engine.tick() — flushes a TickSample to the ring. */
123
+ endFrame(entityCount, visibleCount) {
124
+ if (!this.enabled) return;
125
+ performance.mark("ic-frame-end");
126
+ let totalMs;
127
+ try {
128
+ totalMs = performance.measure("ic:frame", "ic-frame-start", "ic-frame-end").duration;
129
+ } catch {
130
+ totalMs = performance.now() - this.frameStart;
131
+ }
132
+ performance.clearMarks("ic-frame-start");
133
+ performance.clearMarks("ic-frame-end");
134
+ const sample = {
135
+ tick: this.currentTick,
136
+ timestamp: performance.now(),
137
+ totalMs,
138
+ ecs: {
139
+ systems: { ...this.currentSystems },
140
+ visibilityMs: this.visibilityMs,
141
+ entityCount,
142
+ visibleCount
143
+ },
144
+ webgl: {
145
+ gridMs: 0,
146
+ selectionMs: 0,
147
+ drawCalls: 0,
148
+ triangles: 0,
149
+ selectionFrames: 0,
150
+ snapGuides: 0,
151
+ spacingIndicators: 0,
152
+ domPositionsUpdated: 0
153
+ }
154
+ };
155
+ if (this.tickRing.length < TICK_RING_SIZE) this.tickRing.push(sample);
156
+ else this.tickRing[this.tickWrite] = sample;
157
+ this.tickWrite = (this.tickWrite + 1) % TICK_RING_SIZE;
158
+ if (this.tickRing.length >= TICK_RING_SIZE) this.tickFilled = true;
159
+ }
160
+ /**
161
+ * Push one R3F frame sample. Called from the R3F canvas via a probe
162
+ * component that has access to `useThree`.
163
+ */
164
+ recordR3FFrame(sample) {
165
+ if (!this.enabled) return;
166
+ const full = {
167
+ ...sample,
168
+ timestamp: performance.now()
169
+ };
170
+ if (this.r3fRing.length < R3F_RING_SIZE) this.r3fRing.push(full);
171
+ else this.r3fRing[this.r3fWrite] = full;
172
+ this.r3fWrite = (this.r3fWrite + 1) % R3F_RING_SIZE;
173
+ if (this.r3fRing.length >= R3F_RING_SIZE) this.r3fFilled = true;
174
+ }
175
+ /** Get the last N tick samples (newest first). */
176
+ getSamples(count) {
177
+ return readRing(this.tickRing, this.tickWrite, this.tickFilled, count);
178
+ }
179
+ /** Get the last N R3F samples (newest first). */
180
+ getR3FSamples(count) {
181
+ return readRing(this.r3fRing, this.r3fWrite, this.r3fFilled, count);
182
+ }
183
+ /** Compute rolling statistics across all three layers. */
184
+ getStats() {
185
+ return {
186
+ ecs: this.getEcsStats(),
187
+ webgl: this.getWebGLStats(),
188
+ r3f: this.getR3FStats()
189
+ };
190
+ }
191
+ getEcsStats() {
192
+ const samples = this.tickRing;
193
+ const n = samples.length;
194
+ if (n === 0) return {
195
+ fps: 0,
196
+ frameTime: {
197
+ avg: 0,
198
+ p50: 0,
199
+ p95: 0,
200
+ p99: 0,
201
+ max: 0
202
+ },
203
+ systemAvg: {},
204
+ systemP95: {},
205
+ budgetUsed: 0,
206
+ sampleCount: 0
207
+ };
208
+ const frameTimes = samples.map((s) => s.totalMs).toSorted((a, b) => a - b);
209
+ const avg = mean(frameTimes);
210
+ const fps = ringFps(samples, this.tickWrite, this.tickFilled, TICK_RING_SIZE);
211
+ const systemNames = /* @__PURE__ */ new Set();
212
+ for (const s of samples) for (const k of Object.keys(s.ecs.systems)) systemNames.add(k);
213
+ const systemAvg = {};
214
+ const systemP95 = {};
215
+ for (const name of systemNames) {
216
+ const times = samples.map((s) => s.ecs.systems[name] ?? 0).toSorted((a, b) => a - b);
217
+ systemAvg[name] = mean(times);
218
+ systemP95[name] = percentile(times, 95);
219
+ }
220
+ return {
221
+ fps,
222
+ frameTime: {
223
+ avg,
224
+ p50: percentile(frameTimes, 50),
225
+ p95: percentile(frameTimes, 95),
226
+ p99: percentile(frameTimes, 99),
227
+ max: frameTimes[frameTimes.length - 1]
228
+ },
229
+ systemAvg,
230
+ systemP95,
231
+ budgetUsed: avg / 16.67 * 100,
232
+ sampleCount: n
233
+ };
234
+ }
235
+ getWebGLStats() {
236
+ const samples = this.tickRing;
237
+ const n = samples.length;
238
+ if (n === 0) return {
239
+ fps: 0,
240
+ frameTime: {
241
+ avg: 0,
242
+ p50: 0,
243
+ p95: 0,
244
+ p99: 0,
245
+ max: 0
246
+ },
247
+ budgetUsed: 0,
248
+ gridAvg: 0,
249
+ gridP95: 0,
250
+ selectionAvg: 0,
251
+ selectionP95: 0,
252
+ avgDrawCalls: 0,
253
+ avgTriangles: 0,
254
+ avgSelectionFrames: 0,
255
+ avgSnapGuides: 0,
256
+ avgDomUpdates: 0,
257
+ sampleCount: 0
258
+ };
259
+ const gridTimes = samples.map((s) => s.webgl.gridMs).toSorted((a, b) => a - b);
260
+ const selTimes = samples.map((s) => s.webgl.selectionMs).toSorted((a, b) => a - b);
261
+ const combinedTimes = samples.map((s) => s.webgl.gridMs + s.webgl.selectionMs).toSorted((a, b) => a - b);
262
+ const combinedAvg = mean(combinedTimes);
263
+ return {
264
+ fps: ringFps(samples, this.tickWrite, this.tickFilled, TICK_RING_SIZE),
265
+ frameTime: {
266
+ avg: combinedAvg,
267
+ p50: percentile(combinedTimes, 50),
268
+ p95: percentile(combinedTimes, 95),
269
+ p99: percentile(combinedTimes, 99),
270
+ max: combinedTimes[combinedTimes.length - 1]
271
+ },
272
+ budgetUsed: combinedAvg / 16.67 * 100,
273
+ gridAvg: mean(gridTimes),
274
+ gridP95: percentile(gridTimes, 95),
275
+ selectionAvg: mean(selTimes),
276
+ selectionP95: percentile(selTimes, 95),
277
+ avgDrawCalls: mean(samples.map((s) => s.webgl.drawCalls)),
278
+ avgTriangles: mean(samples.map((s) => s.webgl.triangles)),
279
+ avgSelectionFrames: mean(samples.map((s) => s.webgl.selectionFrames)),
280
+ avgSnapGuides: mean(samples.map((s) => s.webgl.snapGuides)),
281
+ avgDomUpdates: mean(samples.map((s) => s.webgl.domPositionsUpdated)),
282
+ sampleCount: n
283
+ };
284
+ }
285
+ getR3FStats() {
286
+ const samples = this.r3fRing;
287
+ const n = samples.length;
288
+ if (n === 0) return {
289
+ fps: 0,
290
+ frameTime: {
291
+ avg: 0,
292
+ p50: 0,
293
+ p95: 0,
294
+ p99: 0,
295
+ max: 0
296
+ },
297
+ avgDrawCalls: 0,
298
+ avgTriangles: 0,
299
+ programs: 0,
300
+ geometries: 0,
301
+ textures: 0,
302
+ activeWidgets: 0,
303
+ avgWidgetsRepainted: 0,
304
+ fboBytes: 0,
305
+ phases: {
306
+ hot: 0,
307
+ warm: 0,
308
+ cold: 0,
309
+ waking: 0,
310
+ dormant: 0
311
+ },
312
+ sampleCount: 0
313
+ };
314
+ const dts = samples.map((s) => s.dtMs).toSorted((a, b) => a - b);
315
+ const fps = ringFps(samples, this.r3fWrite, this.r3fFilled, R3F_RING_SIZE);
316
+ const latest = samples[this.r3fFilled ? (this.r3fWrite - 1 + R3F_RING_SIZE) % R3F_RING_SIZE : n - 1];
317
+ const gpuPaintSamples = samples.filter((s) => s.gpuPaintMs !== void 0);
318
+ const gpuCompositeSamples = samples.filter((s) => s.gpuCompositeMs !== void 0);
319
+ return {
320
+ fps,
321
+ frameTime: {
322
+ avg: mean(dts),
323
+ p50: percentile(dts, 50),
324
+ p95: percentile(dts, 95),
325
+ p99: percentile(dts, 99),
326
+ max: dts[dts.length - 1]
327
+ },
328
+ avgDrawCalls: mean(samples.map((s) => s.drawCalls)),
329
+ avgTriangles: mean(samples.map((s) => s.triangles)),
330
+ programs: latest.programs,
331
+ geometries: latest.geometries,
332
+ textures: latest.textures,
333
+ activeWidgets: latest.activeWidgets,
334
+ avgWidgetsRepainted: mean(samples.map((s) => s.widgetsRepainted)),
335
+ fboBytes: latest.fboBytes,
336
+ phases: latest.phases,
337
+ avgGpuPaintMs: gpuPaintSamples.length > 0 ? mean(gpuPaintSamples.map((s) => s.gpuPaintMs)) : void 0,
338
+ avgGpuCompositeMs: gpuCompositeSamples.length > 0 ? mean(gpuCompositeSamples.map((s) => s.gpuCompositeMs)) : void 0,
339
+ sampleCount: n
340
+ };
341
+ }
342
+ /** Clear all collected data. */
343
+ clear() {
344
+ this.tickRing = [];
345
+ this.tickWrite = 0;
346
+ this.tickFilled = false;
347
+ this.r3fRing = [];
348
+ this.r3fWrite = 0;
349
+ this.r3fFilled = false;
350
+ }
351
+ };
352
+ function mean(xs) {
353
+ if (xs.length === 0) return 0;
354
+ let sum = 0;
355
+ for (const x of xs) sum += x;
356
+ return sum / xs.length;
357
+ }
358
+ function percentile(sorted, p) {
359
+ if (sorted.length === 0) return 0;
360
+ return sorted[Math.floor(p / 100 * (sorted.length - 1))] ?? 0;
361
+ }
362
+ function readRing(ring, write, filled, count) {
363
+ const n = ring.length;
364
+ if (n === 0) return [];
365
+ const take = Math.min(count ?? n, n);
366
+ const out = [];
367
+ for (let i = 0; i < take; i++) {
368
+ const idx = (write - 1 - i + n) % n;
369
+ out.push(ring[idx]);
370
+ }
371
+ return out;
372
+ }
373
+ function ringFps(ring, write, filled, size) {
374
+ const n = ring.length;
375
+ if (n < 2) return 0;
376
+ const newest = ring[filled ? (write - 1 + size) % size : n - 1];
377
+ const oldest = ring[filled ? write : 0];
378
+ const spanMs = newest.timestamp - oldest.timestamp;
379
+ return spanMs > 0 ? Math.round((n - 1) / spanMs * 1e3) : 0;
380
+ }
381
+ //#endregion
382
+ //#region ../../node_modules/.pnpm/quickselect@3.0.0/node_modules/quickselect/index.js
383
+ /**
384
+ * Rearranges items so that all items in the [left, k] are the smallest.
385
+ * The k-th element will have the (k - left + 1)-th smallest value in [left, right].
386
+ *
387
+ * @template T
388
+ * @param {T[]} arr the array to partially sort (in place)
389
+ * @param {number} k middle index for partial sorting (as defined above)
390
+ * @param {number} [left=0] left index of the range to sort
391
+ * @param {number} [right=arr.length-1] right index
392
+ * @param {(a: T, b: T) => number} [compare = (a, b) => a - b] compare function
393
+ */
394
+ function quickselect(arr, k, left = 0, right = arr.length - 1, compare = defaultCompare) {
395
+ while (right > left) {
396
+ if (right - left > 600) {
397
+ const n = right - left + 1;
398
+ const m = k - left + 1;
399
+ const z = Math.log(n);
400
+ const s = .5 * Math.exp(2 * z / 3);
401
+ const sd = .5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1);
402
+ quickselect(arr, k, Math.max(left, Math.floor(k - m * s / n + sd)), Math.min(right, Math.floor(k + (n - m) * s / n + sd)), compare);
403
+ }
404
+ const t = arr[k];
405
+ let i = left;
406
+ /** @type {number} */
407
+ let j = right;
408
+ swap(arr, left, k);
409
+ if (compare(arr[right], t) > 0) swap(arr, left, right);
410
+ while (i < j) {
411
+ swap(arr, i, j);
412
+ i++;
413
+ j--;
414
+ while (compare(arr[i], t) < 0) i++;
415
+ while (compare(arr[j], t) > 0) j--;
416
+ }
417
+ if (compare(arr[left], t) === 0) swap(arr, left, j);
418
+ else {
419
+ j++;
420
+ swap(arr, j, right);
421
+ }
422
+ if (j <= k) left = j + 1;
423
+ if (k <= j) right = j - 1;
424
+ }
425
+ }
426
+ /**
427
+ * @template T
428
+ * @param {T[]} arr
429
+ * @param {number} i
430
+ * @param {number} j
431
+ */
432
+ function swap(arr, i, j) {
433
+ const tmp = arr[i];
434
+ arr[i] = arr[j];
435
+ arr[j] = tmp;
436
+ }
437
+ /**
438
+ * @template T
439
+ * @param {T} a
440
+ * @param {T} b
441
+ * @returns {number}
442
+ */
443
+ function defaultCompare(a, b) {
444
+ return a < b ? -1 : a > b ? 1 : 0;
445
+ }
446
+ //#endregion
447
+ //#region ../../node_modules/.pnpm/rbush@4.0.1/node_modules/rbush/index.js
448
+ var RBush$1 = class {
449
+ constructor(maxEntries = 9) {
450
+ this._maxEntries = Math.max(4, maxEntries);
451
+ this._minEntries = Math.max(2, Math.ceil(this._maxEntries * .4));
452
+ this.clear();
453
+ }
454
+ all() {
455
+ return this._all(this.data, []);
456
+ }
457
+ search(bbox) {
458
+ let node = this.data;
459
+ const result = [];
460
+ if (!intersects(bbox, node)) return result;
461
+ const toBBox = this.toBBox;
462
+ const nodesToSearch = [];
463
+ while (node) {
464
+ for (let i = 0; i < node.children.length; i++) {
465
+ const child = node.children[i];
466
+ const childBBox = node.leaf ? toBBox(child) : child;
467
+ if (intersects(bbox, childBBox)) if (node.leaf) result.push(child);
468
+ else if (contains(bbox, childBBox)) this._all(child, result);
469
+ else nodesToSearch.push(child);
470
+ }
471
+ node = nodesToSearch.pop();
472
+ }
473
+ return result;
474
+ }
475
+ collides(bbox) {
476
+ let node = this.data;
477
+ if (!intersects(bbox, node)) return false;
478
+ const nodesToSearch = [];
479
+ while (node) {
480
+ for (let i = 0; i < node.children.length; i++) {
481
+ const child = node.children[i];
482
+ const childBBox = node.leaf ? this.toBBox(child) : child;
483
+ if (intersects(bbox, childBBox)) {
484
+ if (node.leaf || contains(bbox, childBBox)) return true;
485
+ nodesToSearch.push(child);
486
+ }
487
+ }
488
+ node = nodesToSearch.pop();
489
+ }
490
+ return false;
491
+ }
492
+ load(data) {
493
+ if (!(data && data.length)) return this;
494
+ if (data.length < this._minEntries) {
495
+ for (let i = 0; i < data.length; i++) this.insert(data[i]);
496
+ return this;
497
+ }
498
+ let node = this._build(data.slice(), 0, data.length - 1, 0);
499
+ if (!this.data.children.length) this.data = node;
500
+ else if (this.data.height === node.height) this._splitRoot(this.data, node);
501
+ else {
502
+ if (this.data.height < node.height) {
503
+ const tmpNode = this.data;
504
+ this.data = node;
505
+ node = tmpNode;
506
+ }
507
+ this._insert(node, this.data.height - node.height - 1, true);
508
+ }
509
+ return this;
510
+ }
511
+ insert(item) {
512
+ if (item) this._insert(item, this.data.height - 1);
513
+ return this;
514
+ }
515
+ clear() {
516
+ this.data = createNode([]);
517
+ return this;
518
+ }
519
+ remove(item, equalsFn) {
520
+ if (!item) return this;
521
+ let node = this.data;
522
+ const bbox = this.toBBox(item);
523
+ const path = [];
524
+ const indexes = [];
525
+ let i, parent, goingUp;
526
+ while (node || path.length) {
527
+ if (!node) {
528
+ node = path.pop();
529
+ parent = path[path.length - 1];
530
+ i = indexes.pop();
531
+ goingUp = true;
532
+ }
533
+ if (node.leaf) {
534
+ const index = findItem(item, node.children, equalsFn);
535
+ if (index !== -1) {
536
+ node.children.splice(index, 1);
537
+ path.push(node);
538
+ this._condense(path);
539
+ return this;
540
+ }
541
+ }
542
+ if (!goingUp && !node.leaf && contains(node, bbox)) {
543
+ path.push(node);
544
+ indexes.push(i);
545
+ i = 0;
546
+ parent = node;
547
+ node = node.children[0];
548
+ } else if (parent) {
549
+ i++;
550
+ node = parent.children[i];
551
+ goingUp = false;
552
+ } else node = null;
553
+ }
554
+ return this;
555
+ }
556
+ toBBox(item) {
557
+ return item;
558
+ }
559
+ compareMinX(a, b) {
560
+ return a.minX - b.minX;
561
+ }
562
+ compareMinY(a, b) {
563
+ return a.minY - b.minY;
564
+ }
565
+ toJSON() {
566
+ return this.data;
567
+ }
568
+ fromJSON(data) {
569
+ this.data = data;
570
+ return this;
571
+ }
572
+ _all(node, result) {
573
+ const nodesToSearch = [];
574
+ while (node) {
575
+ if (node.leaf) result.push(...node.children);
576
+ else nodesToSearch.push(...node.children);
577
+ node = nodesToSearch.pop();
578
+ }
579
+ return result;
580
+ }
581
+ _build(items, left, right, height) {
582
+ const N = right - left + 1;
583
+ let M = this._maxEntries;
584
+ let node;
585
+ if (N <= M) {
586
+ node = createNode(items.slice(left, right + 1));
587
+ calcBBox(node, this.toBBox);
588
+ return node;
589
+ }
590
+ if (!height) {
591
+ height = Math.ceil(Math.log(N) / Math.log(M));
592
+ M = Math.ceil(N / Math.pow(M, height - 1));
593
+ }
594
+ node = createNode([]);
595
+ node.leaf = false;
596
+ node.height = height;
597
+ const N2 = Math.ceil(N / M);
598
+ const N1 = N2 * Math.ceil(Math.sqrt(M));
599
+ multiSelect(items, left, right, N1, this.compareMinX);
600
+ for (let i = left; i <= right; i += N1) {
601
+ const right2 = Math.min(i + N1 - 1, right);
602
+ multiSelect(items, i, right2, N2, this.compareMinY);
603
+ for (let j = i; j <= right2; j += N2) {
604
+ const right3 = Math.min(j + N2 - 1, right2);
605
+ node.children.push(this._build(items, j, right3, height - 1));
606
+ }
607
+ }
608
+ calcBBox(node, this.toBBox);
609
+ return node;
610
+ }
611
+ _chooseSubtree(bbox, node, level, path) {
612
+ while (true) {
613
+ path.push(node);
614
+ if (node.leaf || path.length - 1 === level) break;
615
+ let minArea = Infinity;
616
+ let minEnlargement = Infinity;
617
+ let targetNode;
618
+ for (let i = 0; i < node.children.length; i++) {
619
+ const child = node.children[i];
620
+ const area = bboxArea(child);
621
+ const enlargement = enlargedArea(bbox, child) - area;
622
+ if (enlargement < minEnlargement) {
623
+ minEnlargement = enlargement;
624
+ minArea = area < minArea ? area : minArea;
625
+ targetNode = child;
626
+ } else if (enlargement === minEnlargement) {
627
+ if (area < minArea) {
628
+ minArea = area;
629
+ targetNode = child;
630
+ }
631
+ }
632
+ }
633
+ node = targetNode || node.children[0];
634
+ }
635
+ return node;
636
+ }
637
+ _insert(item, level, isNode) {
638
+ const bbox = isNode ? item : this.toBBox(item);
639
+ const insertPath = [];
640
+ const node = this._chooseSubtree(bbox, this.data, level, insertPath);
641
+ node.children.push(item);
642
+ extend(node, bbox);
643
+ while (level >= 0) if (insertPath[level].children.length > this._maxEntries) {
644
+ this._split(insertPath, level);
645
+ level--;
646
+ } else break;
647
+ this._adjustParentBBoxes(bbox, insertPath, level);
648
+ }
649
+ _split(insertPath, level) {
650
+ const node = insertPath[level];
651
+ const M = node.children.length;
652
+ const m = this._minEntries;
653
+ this._chooseSplitAxis(node, m, M);
654
+ const splitIndex = this._chooseSplitIndex(node, m, M);
655
+ const newNode = createNode(node.children.splice(splitIndex, node.children.length - splitIndex));
656
+ newNode.height = node.height;
657
+ newNode.leaf = node.leaf;
658
+ calcBBox(node, this.toBBox);
659
+ calcBBox(newNode, this.toBBox);
660
+ if (level) insertPath[level - 1].children.push(newNode);
661
+ else this._splitRoot(node, newNode);
662
+ }
663
+ _splitRoot(node, newNode) {
664
+ this.data = createNode([node, newNode]);
665
+ this.data.height = node.height + 1;
666
+ this.data.leaf = false;
667
+ calcBBox(this.data, this.toBBox);
668
+ }
669
+ _chooseSplitIndex(node, m, M) {
670
+ let index;
671
+ let minOverlap = Infinity;
672
+ let minArea = Infinity;
673
+ for (let i = m; i <= M - m; i++) {
674
+ const bbox1 = distBBox(node, 0, i, this.toBBox);
675
+ const bbox2 = distBBox(node, i, M, this.toBBox);
676
+ const overlap = intersectionArea(bbox1, bbox2);
677
+ const area = bboxArea(bbox1) + bboxArea(bbox2);
678
+ if (overlap < minOverlap) {
679
+ minOverlap = overlap;
680
+ index = i;
681
+ minArea = area < minArea ? area : minArea;
682
+ } else if (overlap === minOverlap) {
683
+ if (area < minArea) {
684
+ minArea = area;
685
+ index = i;
686
+ }
687
+ }
688
+ }
689
+ return index || M - m;
690
+ }
691
+ _chooseSplitAxis(node, m, M) {
692
+ const compareMinX = node.leaf ? this.compareMinX : compareNodeMinX;
693
+ const compareMinY = node.leaf ? this.compareMinY : compareNodeMinY;
694
+ if (this._allDistMargin(node, m, M, compareMinX) < this._allDistMargin(node, m, M, compareMinY)) node.children.sort(compareMinX);
695
+ }
696
+ _allDistMargin(node, m, M, compare) {
697
+ node.children.sort(compare);
698
+ const toBBox = this.toBBox;
699
+ const leftBBox = distBBox(node, 0, m, toBBox);
700
+ const rightBBox = distBBox(node, M - m, M, toBBox);
701
+ let margin = bboxMargin(leftBBox) + bboxMargin(rightBBox);
702
+ for (let i = m; i < M - m; i++) {
703
+ const child = node.children[i];
704
+ extend(leftBBox, node.leaf ? toBBox(child) : child);
705
+ margin += bboxMargin(leftBBox);
706
+ }
707
+ for (let i = M - m - 1; i >= m; i--) {
708
+ const child = node.children[i];
709
+ extend(rightBBox, node.leaf ? toBBox(child) : child);
710
+ margin += bboxMargin(rightBBox);
711
+ }
712
+ return margin;
713
+ }
714
+ _adjustParentBBoxes(bbox, path, level) {
715
+ for (let i = level; i >= 0; i--) extend(path[i], bbox);
716
+ }
717
+ _condense(path) {
718
+ for (let i = path.length - 1, siblings; i >= 0; i--) if (path[i].children.length === 0) if (i > 0) {
719
+ siblings = path[i - 1].children;
720
+ siblings.splice(siblings.indexOf(path[i]), 1);
721
+ } else this.clear();
722
+ else calcBBox(path[i], this.toBBox);
723
+ }
724
+ };
725
+ function findItem(item, items, equalsFn) {
726
+ if (!equalsFn) return items.indexOf(item);
727
+ for (let i = 0; i < items.length; i++) if (equalsFn(item, items[i])) return i;
728
+ return -1;
729
+ }
730
+ function calcBBox(node, toBBox) {
731
+ distBBox(node, 0, node.children.length, toBBox, node);
732
+ }
733
+ function distBBox(node, k, p, toBBox, destNode) {
734
+ if (!destNode) destNode = createNode(null);
735
+ destNode.minX = Infinity;
736
+ destNode.minY = Infinity;
737
+ destNode.maxX = -Infinity;
738
+ destNode.maxY = -Infinity;
739
+ for (let i = k; i < p; i++) {
740
+ const child = node.children[i];
741
+ extend(destNode, node.leaf ? toBBox(child) : child);
742
+ }
743
+ return destNode;
744
+ }
745
+ function extend(a, b) {
746
+ a.minX = Math.min(a.minX, b.minX);
747
+ a.minY = Math.min(a.minY, b.minY);
748
+ a.maxX = Math.max(a.maxX, b.maxX);
749
+ a.maxY = Math.max(a.maxY, b.maxY);
750
+ return a;
751
+ }
752
+ function compareNodeMinX(a, b) {
753
+ return a.minX - b.minX;
754
+ }
755
+ function compareNodeMinY(a, b) {
756
+ return a.minY - b.minY;
757
+ }
758
+ function bboxArea(a) {
759
+ return (a.maxX - a.minX) * (a.maxY - a.minY);
760
+ }
761
+ function bboxMargin(a) {
762
+ return a.maxX - a.minX + (a.maxY - a.minY);
763
+ }
764
+ function enlargedArea(a, b) {
765
+ return (Math.max(b.maxX, a.maxX) - Math.min(b.minX, a.minX)) * (Math.max(b.maxY, a.maxY) - Math.min(b.minY, a.minY));
766
+ }
767
+ function intersectionArea(a, b) {
768
+ const minX = Math.max(a.minX, b.minX);
769
+ const minY = Math.max(a.minY, b.minY);
770
+ const maxX = Math.min(a.maxX, b.maxX);
771
+ const maxY = Math.min(a.maxY, b.maxY);
772
+ return Math.max(0, maxX - minX) * Math.max(0, maxY - minY);
773
+ }
774
+ function contains(a, b) {
775
+ return a.minX <= b.minX && a.minY <= b.minY && b.maxX <= a.maxX && b.maxY <= a.maxY;
776
+ }
777
+ function intersects(a, b) {
778
+ return b.minX <= a.maxX && b.minY <= a.maxY && b.maxX >= a.minX && b.maxY >= a.minY;
779
+ }
780
+ function createNode(children) {
781
+ return {
782
+ children,
783
+ height: 1,
784
+ leaf: true,
785
+ minX: Infinity,
786
+ minY: Infinity,
787
+ maxX: -Infinity,
788
+ maxY: -Infinity
789
+ };
790
+ }
791
+ function multiSelect(arr, left, right, n, compare) {
792
+ const stack = [left, right];
793
+ while (stack.length) {
794
+ right = stack.pop();
795
+ left = stack.pop();
796
+ if (right - left <= n) continue;
797
+ const mid = left + Math.ceil((right - left) / n / 2) * n;
798
+ quickselect(arr, mid, left, right, compare);
799
+ stack.push(left, mid, mid, right);
800
+ }
801
+ }
802
+ //#endregion
803
+ //#region src/ecs/spatial/SpatialIndex.ts
804
+ const rbushModule = RBush$1;
805
+ const RBush = typeof rbushModule.default === "function" ? rbushModule.default : RBush$1;
806
+ /**
807
+ * Spatial index backed by an R-tree (rbush).
808
+ * Stores world-space AABBs for fast viewport culling and hit testing.
809
+ */
810
+ var SpatialIndex = class {
811
+ tree = new RBush();
812
+ entries = /* @__PURE__ */ new Map();
813
+ upsert(entityId, bounds) {
814
+ const existing = this.entries.get(entityId);
815
+ if (existing) this.tree.remove(existing);
816
+ const entry = {
817
+ ...bounds,
818
+ entityId
819
+ };
820
+ this.entries.set(entityId, entry);
821
+ this.tree.insert(entry);
822
+ }
823
+ remove(entityId) {
824
+ const existing = this.entries.get(entityId);
825
+ if (existing) {
826
+ this.tree.remove(existing);
827
+ this.entries.delete(entityId);
828
+ }
829
+ }
830
+ /** Query all entries intersecting the given AABB */
831
+ search(bounds) {
832
+ return this.tree.search(bounds);
833
+ }
834
+ /** Find the topmost entity at a point (by z-order — caller sorts) */
835
+ searchPoint(x, y, tolerance = 0) {
836
+ return this.tree.search({
837
+ minX: x - tolerance,
838
+ minY: y - tolerance,
839
+ maxX: x + tolerance,
840
+ maxY: y + tolerance
841
+ });
842
+ }
843
+ clear() {
844
+ this.tree.clear();
845
+ this.entries.clear();
846
+ }
847
+ get size() {
848
+ return this.entries.size;
849
+ }
850
+ };
851
+ //#endregion
852
+ //#region src/ecs/spatial/snap.ts
853
+ /**
854
+ * Compute snap guides for a dragged entity against reference entities.
855
+ */
856
+ function computeSnapGuides(dragged, references, threshold) {
857
+ const guides = [];
858
+ const spacings = [];
859
+ let snapDx = 0;
860
+ let snapDy = 0;
861
+ const dLeft = dragged.x;
862
+ const dRight = dragged.x + dragged.width;
863
+ const dCenterX = dragged.x + dragged.width / 2;
864
+ const dTop = dragged.y;
865
+ const dBottom = dragged.y + dragged.height;
866
+ const dCenterY = dragged.y + dragged.height / 2;
867
+ let bestSnapX = Number.POSITIVE_INFINITY;
868
+ let bestSnapY = Number.POSITIVE_INFINITY;
869
+ let bestDx = 0;
870
+ let bestDy = 0;
871
+ const xGuides = [];
872
+ const yGuides = [];
873
+ for (const ref of references) {
874
+ const rLeft = ref.x;
875
+ const rRight = ref.x + ref.width;
876
+ const rCenterX = ref.x + ref.width / 2;
877
+ const rTop = ref.y;
878
+ const rBottom = ref.y + ref.height;
879
+ const rCenterY = ref.y + ref.height / 2;
880
+ const xPairs = [
881
+ [
882
+ dLeft,
883
+ rLeft,
884
+ "edge"
885
+ ],
886
+ [
887
+ dLeft,
888
+ rRight,
889
+ "edge"
890
+ ],
891
+ [
892
+ dRight,
893
+ rLeft,
894
+ "edge"
895
+ ],
896
+ [
897
+ dRight,
898
+ rRight,
899
+ "edge"
900
+ ],
901
+ [
902
+ dCenterX,
903
+ rCenterX,
904
+ "center"
905
+ ],
906
+ [
907
+ dLeft,
908
+ rCenterX,
909
+ "edge"
910
+ ],
911
+ [
912
+ dRight,
913
+ rCenterX,
914
+ "edge"
915
+ ]
916
+ ];
917
+ for (const [dVal, rVal, type] of xPairs) {
918
+ const dist = Math.abs(dVal - rVal);
919
+ if (dist <= threshold) {
920
+ const dx = rVal - dVal;
921
+ if (dist < bestSnapX) {
922
+ bestSnapX = dist;
923
+ bestDx = dx;
924
+ xGuides.length = 0;
925
+ }
926
+ if (dist <= bestSnapX + .01) xGuides.push({
927
+ axis: "x",
928
+ position: rVal,
929
+ type
930
+ });
931
+ }
932
+ }
933
+ const yPairs = [
934
+ [
935
+ dTop,
936
+ rTop,
937
+ "edge"
938
+ ],
939
+ [
940
+ dTop,
941
+ rBottom,
942
+ "edge"
943
+ ],
944
+ [
945
+ dBottom,
946
+ rTop,
947
+ "edge"
948
+ ],
949
+ [
950
+ dBottom,
951
+ rBottom,
952
+ "edge"
953
+ ],
954
+ [
955
+ dCenterY,
956
+ rCenterY,
957
+ "center"
958
+ ],
959
+ [
960
+ dTop,
961
+ rCenterY,
962
+ "edge"
963
+ ],
964
+ [
965
+ dBottom,
966
+ rCenterY,
967
+ "edge"
968
+ ]
969
+ ];
970
+ for (const [dVal, rVal, type] of yPairs) {
971
+ const dist = Math.abs(dVal - rVal);
972
+ if (dist <= threshold) {
973
+ const dy = rVal - dVal;
974
+ if (dist < bestSnapY) {
975
+ bestSnapY = dist;
976
+ bestDy = dy;
977
+ yGuides.length = 0;
978
+ }
979
+ if (dist <= bestSnapY + .01) yGuides.push({
980
+ axis: "y",
981
+ position: rVal,
982
+ type
983
+ });
984
+ }
985
+ }
986
+ }
987
+ const eqResult = computeEqualSpacing(dragged, references, threshold);
988
+ if (bestSnapX <= threshold) snapDx = bestDx;
989
+ else if (eqResult.snapDx !== void 0) snapDx = eqResult.snapDx;
990
+ if (bestSnapY <= threshold) snapDy = bestDy;
991
+ else if (eqResult.snapDy !== void 0) snapDy = eqResult.snapDy;
992
+ if (bestSnapX <= threshold) {
993
+ const seen = /* @__PURE__ */ new Set();
994
+ for (const g of xGuides) if (!seen.has(g.position)) {
995
+ seen.add(g.position);
996
+ guides.push(g);
997
+ }
998
+ }
999
+ if (bestSnapY <= threshold) {
1000
+ const seen = /* @__PURE__ */ new Set();
1001
+ for (const g of yGuides) if (!seen.has(g.position)) {
1002
+ seen.add(g.position);
1003
+ guides.push(g);
1004
+ }
1005
+ }
1006
+ const eqFinal = computeEqualSpacing({
1007
+ x: dragged.x + snapDx,
1008
+ y: dragged.y + snapDy,
1009
+ width: dragged.width,
1010
+ height: dragged.height
1011
+ }, references, threshold * .5);
1012
+ spacings.push(...eqFinal.indicators);
1013
+ return {
1014
+ snapDx,
1015
+ snapDy,
1016
+ guides,
1017
+ spacings
1018
+ };
1019
+ }
1020
+ function computeEqualSpacing(dragged, references, threshold) {
1021
+ const indicators = [];
1022
+ let snapDx;
1023
+ let snapDy;
1024
+ const xResult = checkAxisSpacing(dragged, references, threshold, "x");
1025
+ if (xResult) {
1026
+ snapDx = xResult.snap;
1027
+ indicators.push(...xResult.indicators);
1028
+ }
1029
+ const yResult = checkAxisSpacing(dragged, references, threshold, "y");
1030
+ if (yResult) {
1031
+ snapDy = yResult.snap;
1032
+ indicators.push(...yResult.indicators);
1033
+ }
1034
+ return {
1035
+ snapDx,
1036
+ snapDy,
1037
+ indicators
1038
+ };
1039
+ }
1040
+ function checkAxisSpacing(dragged, references, threshold, axis) {
1041
+ const isX = axis === "x";
1042
+ const pos = (b) => isX ? b.x : b.y;
1043
+ const size = (b) => isX ? b.width : b.height;
1044
+ const perpPos = (b) => isX ? b.y : b.x;
1045
+ const perpSize = (b) => isX ? b.height : b.width;
1046
+ const end = (b) => pos(b) + size(b);
1047
+ const neighbors = references.filter((ref) => perpPos(ref) < perpPos(dragged) + perpSize(dragged) && perpPos(ref) + perpSize(ref) > perpPos(dragged));
1048
+ if (neighbors.length < 1) return null;
1049
+ const sorted = [...neighbors].toSorted((a, b) => pos(a) - pos(b));
1050
+ const refGaps = [];
1051
+ for (let i = 0; i < sorted.length - 1; i++) {
1052
+ const gap = pos(sorted[i + 1]) - end(sorted[i]);
1053
+ if (gap > .1) refGaps.push({
1054
+ from: sorted[i],
1055
+ to: sorted[i + 1],
1056
+ gap
1057
+ });
1058
+ }
1059
+ let bestSnap = null;
1060
+ let bestIndicators = [];
1061
+ let bestDiff = Number.POSITIVE_INFINITY;
1062
+ let leftN = null;
1063
+ let rightN = null;
1064
+ for (const ref of sorted) {
1065
+ if (end(ref) <= pos(dragged) + threshold) {
1066
+ if (!leftN || end(ref) > end(leftN)) leftN = ref;
1067
+ }
1068
+ if (pos(ref) >= end(dragged) - threshold) {
1069
+ if (!rightN || pos(ref) < pos(rightN)) rightN = ref;
1070
+ }
1071
+ }
1072
+ if (leftN && rightN) {
1073
+ const lGap = pos(dragged) - end(leftN);
1074
+ const rGap = pos(rightN) - end(dragged);
1075
+ const diff = Math.abs(lGap - rGap);
1076
+ if (diff <= threshold && diff < bestDiff) {
1077
+ const idealPos = (end(leftN) + pos(rightN) - size(dragged)) / 2;
1078
+ const snap = idealPos - pos(dragged);
1079
+ const equalGap = (pos(rightN) - end(leftN) - size(dragged)) / 2;
1080
+ if (equalGap > .1) {
1081
+ const perpY = computePerpCenter(dragged, [leftN, rightN], isX);
1082
+ bestSnap = snap;
1083
+ bestDiff = diff;
1084
+ bestIndicators = [{
1085
+ axis,
1086
+ gap: equalGap,
1087
+ segments: [{
1088
+ from: end(leftN),
1089
+ to: idealPos
1090
+ }, {
1091
+ from: idealPos + size(dragged),
1092
+ to: pos(rightN)
1093
+ }],
1094
+ perpPosition: perpY
1095
+ }];
1096
+ }
1097
+ }
1098
+ }
1099
+ for (const refGap of refGaps) {
1100
+ const patternGap = refGap.gap;
1101
+ if (rightN === null || pos(refGap.to) >= end(dragged) - threshold * 2) {
1102
+ const chainEnd = refGap.to;
1103
+ const dragGap = pos(dragged) - end(chainEnd);
1104
+ const diff = Math.abs(dragGap - patternGap);
1105
+ if (diff <= threshold && diff < bestDiff) {
1106
+ const idealPos = end(chainEnd) + patternGap;
1107
+ const snap = idealPos - pos(dragged);
1108
+ const perpY = computePerpCenter(dragged, [refGap.from, refGap.to], isX);
1109
+ bestSnap = snap;
1110
+ bestDiff = diff;
1111
+ bestIndicators = [{
1112
+ axis,
1113
+ gap: patternGap,
1114
+ segments: [{
1115
+ from: end(refGap.from),
1116
+ to: pos(refGap.to)
1117
+ }, {
1118
+ from: end(chainEnd),
1119
+ to: idealPos
1120
+ }],
1121
+ perpPosition: perpY
1122
+ }];
1123
+ }
1124
+ }
1125
+ if (leftN === null || end(refGap.from) <= pos(dragged) + threshold * 2) {
1126
+ const chainStart = refGap.from;
1127
+ const dragGap = pos(chainStart) - end(dragged);
1128
+ const diff = Math.abs(dragGap - patternGap);
1129
+ if (diff <= threshold && diff < bestDiff) {
1130
+ const idealPos = pos(chainStart) - patternGap - size(dragged);
1131
+ const snap = idealPos - pos(dragged);
1132
+ const perpY = computePerpCenter(dragged, [refGap.from, refGap.to], isX);
1133
+ bestSnap = snap;
1134
+ bestDiff = diff;
1135
+ bestIndicators = [{
1136
+ axis,
1137
+ gap: patternGap,
1138
+ segments: [{
1139
+ from: idealPos + size(dragged),
1140
+ to: pos(chainStart)
1141
+ }, {
1142
+ from: end(refGap.from),
1143
+ to: pos(refGap.to)
1144
+ }],
1145
+ perpPosition: perpY
1146
+ }];
1147
+ }
1148
+ }
1149
+ }
1150
+ if (bestSnap !== null) return {
1151
+ snap: bestSnap,
1152
+ indicators: bestIndicators
1153
+ };
1154
+ return null;
1155
+ }
1156
+ function computePerpCenter(dragged, refs, isX) {
1157
+ const perpPos = (b) => isX ? b.y : b.x;
1158
+ const perpSize = (b) => isX ? b.height : b.width;
1159
+ const allBounds = [dragged, ...refs];
1160
+ const maxStart = Math.max(...allBounds.map(perpPos));
1161
+ const minEnd = Math.min(...allBounds.map((b) => perpPos(b) + perpSize(b)));
1162
+ if (minEnd < maxStart) return perpPos(allBounds[0]) + perpSize(allBounds[0]) / 2;
1163
+ return maxStart + (minEnd - maxStart) / 2;
1164
+ }
1165
+ //#endregion
1166
+ //#region src/react/context/container-ref-context.ts
1167
+ const ContainerRefContext = createContext(null);
1168
+ const ContainerRefProvider = ContainerRefContext.Provider;
1169
+ function useContainerRef() {
1170
+ return useContext(ContainerRefContext);
1171
+ }
1172
+ //#endregion
1173
+ //#region src/react/context/widget-resolver-context.ts
1174
+ const WidgetResolverContext = createContext(null);
1175
+ const WidgetResolverProvider = WidgetResolverContext.Provider;
1176
+ function useWidgetResolver() {
1177
+ return useContext(WidgetResolverContext);
1178
+ }
1179
+ //#endregion
1180
+ //#region src/react/input/debug.ts
1181
+ /**
1182
+ * RFC-008 input pipeline — diagnostic logger.
1183
+ *
1184
+ * Single toggle (`INPUT_DEBUG`) wires every layer's "what just happened"
1185
+ * decision into the browser console with colour-coded tags so the dispatch
1186
+ * flow is legible in real time:
1187
+ *
1188
+ * [Adapter] [InputManager] [Router] [R3F] [Recognizer] [Engine]
1189
+ *
1190
+ * `INPUT_DEBUG_VERBOSE` extends this to per-frame `move` events, which
1191
+ * fire at 60+ Hz and quickly drown the console — leave it `false` unless
1192
+ * you're chasing a hover or pan-update issue.
1193
+ *
1194
+ * Toggle either by editing the constants below or, more conveniently, by
1195
+ * setting `window.__INPUT_DEBUG__` / `window.__INPUT_DEBUG_VERBOSE__` from
1196
+ * the devtools console at runtime.
1197
+ */
1198
+ const DEFAULT_DEBUG = true;
1199
+ const DEFAULT_VERBOSE = false;
1200
+ function isDebug() {
1201
+ if (typeof window === "undefined") return DEFAULT_DEBUG;
1202
+ return window.__INPUT_DEBUG__ ?? DEFAULT_DEBUG;
1203
+ }
1204
+ function isVerbose() {
1205
+ if (typeof window === "undefined") return DEFAULT_VERBOSE;
1206
+ return window.__INPUT_DEBUG_VERBOSE__ ?? DEFAULT_VERBOSE;
1207
+ }
1208
+ const STYLES = {
1209
+ Adapter: "background:#0288d1; color:#fff; padding:1px 4px; border-radius:2px; font-weight:bold",
1210
+ InputManager: "background:#f9a825; color:#222; padding:1px 4px; border-radius:2px; font-weight:bold",
1211
+ Router: "background:#7b1fa2; color:#fff; padding:1px 4px; border-radius:2px; font-weight:bold",
1212
+ R3F: "background:#00838f; color:#fff; padding:1px 4px; border-radius:2px; font-weight:bold",
1213
+ Recognizer: "background:#558b2f; color:#fff; padding:1px 4px; border-radius:2px",
1214
+ Engine: "background:#d84315; color:#fff; padding:1px 4px; border-radius:2px; font-weight:bold"
1215
+ };
1216
+ /**
1217
+ * Log a single line tagged with a layer. `data` is appended as a single
1218
+ * object so devtools can expand it inline.
1219
+ *
1220
+ * `move`-typed events are gated on `INPUT_DEBUG_VERBOSE` so the default
1221
+ * trace stays readable.
1222
+ */
1223
+ function inputLog(layer, message, data) {
1224
+ if (!isDebug()) return;
1225
+ if (typeof data?.type === "string" && (data.type === "move" || data.type.endsWith("-update")) && !isVerbose()) return;
1226
+ if (data !== void 0) console.log(`%c${layer}%c ${message}`, STYLES[layer], "color:inherit", data);
1227
+ else console.log(`%c${layer}%c ${message}`, STYLES[layer], "color:inherit");
1228
+ }
1229
+ /**
1230
+ * Group all logs for a single InputManager.dispatch under one collapsible
1231
+ * heading. Pass the returned closer to console.groupEnd at the end of
1232
+ * dispatch. No-op when debug is off.
1233
+ */
1234
+ function inputGroupStart(label) {
1235
+ if (!isDebug()) return () => {};
1236
+ console.groupCollapsed(`%cInput · ${label}`, "color:#555; font-weight:bold");
1237
+ return () => console.groupEnd();
1238
+ }
1239
+ //#endregion
1240
+ //#region src/react/input/r3f/createR3FEventManager.ts
1241
+ function createR3FEventManager(engine, registry, onCreate) {
1242
+ let activeScene = null;
1243
+ function skipEvent(state) {
1244
+ activeScene = null;
1245
+ state.raycaster.camera = null;
1246
+ state.pointer.set(0, 0);
1247
+ }
1248
+ return (store) => {
1249
+ const base = events(store);
1250
+ const compute = (event, state) => {
1251
+ const rect = state.gl.domElement.getBoundingClientRect();
1252
+ const screenX = event.clientX - rect.left;
1253
+ const screenY = event.clientY - rect.top;
1254
+ const entityId = engine.pickAt(screenX, screenY);
1255
+ if (entityId === null) {
1256
+ skipEvent(state);
1257
+ return;
1258
+ }
1259
+ if (engine.get(entityId, Widget)?.surface !== "webgl") {
1260
+ skipEvent(state);
1261
+ return;
1262
+ }
1263
+ const widget = registry.get(entityId);
1264
+ const t = engine.get(entityId, Transform2D);
1265
+ if (!widget || !t) {
1266
+ skipEvent(state);
1267
+ return;
1268
+ }
1269
+ const cam = engine.getCamera();
1270
+ const worldX = screenX / cam.zoom + cam.x;
1271
+ const worldY = screenY / cam.zoom + cam.y;
1272
+ const widgetCenterX = t.x + t.width / 2;
1273
+ const widgetCenterY = t.y + t.height / 2;
1274
+ const localX = worldX - widgetCenterX;
1275
+ const localY = -(worldY - widgetCenterY);
1276
+ const ndcX = 2 * localX / t.width;
1277
+ const ndcY = 2 * localY / t.height;
1278
+ activeScene = widget.scene;
1279
+ state.pointer.set(ndcX, ndcY);
1280
+ state.raycaster.setFromCamera(state.pointer, widget.camera);
1281
+ state.raycaster.camera = widget.camera;
1282
+ inputLog("R3F", `compute: ready raycaster for entity ${entityId}`, {
1283
+ type: event.type,
1284
+ entityId,
1285
+ ndc: {
1286
+ x: ndcX,
1287
+ y: ndcY
1288
+ },
1289
+ sceneChildren: widget.scene.children.length
1290
+ });
1291
+ };
1292
+ const filter = (items) => {
1293
+ const scene = activeScene;
1294
+ if (!scene) return [];
1295
+ return items.filter((hit) => isDescendantOf(hit.object, scene));
1296
+ };
1297
+ const isPointerCaptured = (pointerId) => {
1298
+ return store.getState().internal?.capturedMap?.has?.(pointerId) ?? false;
1299
+ };
1300
+ const manager = {
1301
+ ...base,
1302
+ compute,
1303
+ filter,
1304
+ isPointerCaptured,
1305
+ connect: (_target) => {
1306
+ inputLog("R3F", "createR3FEventManager.connect: no-op (driven by InputManager)");
1307
+ },
1308
+ disconnect: () => {
1309
+ inputLog("R3F", "createR3FEventManager.disconnect: no-op");
1310
+ }
1311
+ };
1312
+ onCreate?.(manager);
1313
+ return manager;
1314
+ };
1315
+ }
1316
+ function isDescendantOf(obj, ancestor) {
1317
+ let n = obj;
1318
+ while (n) {
1319
+ if (n === ancestor) return true;
1320
+ n = n.parent;
1321
+ }
1322
+ return false;
1323
+ }
1324
+ //#endregion
1325
+ //#region src/r3f/compositor/CompositionMaterial.ts
1326
+ /**
1327
+ * Module-level shared uniforms for the overlap glow.
1328
+ *
1329
+ * Every {@link CompositionMaterial} instance references THESE EXACT
1330
+ * Vector2 / Vector3 objects. Mutating any `.value` here propagates to
1331
+ * every R3F card simultaneously (Three.js sees them as the same uniform
1332
+ * binding). One call to `applyOverlapGlowShaderUniforms(config)` retunes
1333
+ * every card without iterating materials.
1334
+ */
1335
+ const sharedGlowUniforms = {
1336
+ uGlowColor: { value: new Vector3(.5, .5, .5) },
1337
+ uGlowAlpha: { value: new Vector2(.25, .45) },
1338
+ uGlowFalloff: { value: new Vector2(.3, .4) },
1339
+ uRimColor: { value: new Vector3(.5, .5, .5) },
1340
+ uRimAlpha: { value: new Vector2(.55, .85) },
1341
+ uRimRadius: { value: 3 }
1342
+ };
1343
+ /**
1344
+ * Shader pair for the composition pass — sample an sRGB-encoded widget
1345
+ * FBO and write it to the sRGB backbuffer unchanged. No tone mapping,
1346
+ * no output encoding (the FBO already holds display-ready values, see
1347
+ * RFC-002 § sRGB FBO fix).
1348
+ *
1349
+ * `uDraggedRect` + `uIsDragged` implement the RFC-003 drag-promote
1350
+ * clip: when an R3F widget is being dragged, every other widget's
1351
+ * fragments inside that widget's screen rect are discarded so the
1352
+ * promoted DOM CardChrome (now above the R3F canvas) shows through.
1353
+ *
1354
+ * `uHotPoint` + `uHotStrength` + `uIsOverlapTarget` drive the
1355
+ * single-layer overlap glow — a soft radial gradient at the
1356
+ * intersection centroid that fades out via `uGlowFalloff`. Color and
1357
+ * alpha are tunable via {@link sharedGlowUniforms}.
1358
+ */
1359
+ const VERTEX_SHADER = `
1360
+ varying vec2 vUv;
1361
+ void main() {
1362
+ vUv = uv;
1363
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1364
+ }
1365
+ `;
1366
+ const FRAGMENT_SHADER = `
1367
+ uniform sampler2D map;
1368
+ uniform vec4 uDraggedRect;
1369
+ uniform float uIsDragged;
1370
+ uniform vec2 uHotPoint;
1371
+ uniform float uHotStrength;
1372
+ uniform float uIsOverlapTarget;
1373
+
1374
+ uniform vec3 uGlowColor;
1375
+ uniform vec2 uGlowAlpha;
1376
+ uniform vec2 uGlowFalloff;
1377
+ uniform vec3 uRimColor;
1378
+ uniform vec2 uRimAlpha;
1379
+ uniform float uRimRadius;
1380
+
1381
+ varying vec2 vUv;
1382
+
1383
+ void main() {
1384
+ if (uIsDragged < 0.5) {
1385
+ vec2 sp = gl_FragCoord.xy;
1386
+ if (sp.x >= uDraggedRect.x && sp.x <= uDraggedRect.z &&
1387
+ sp.y >= uDraggedRect.y && sp.y <= uDraggedRect.w) {
1388
+ discard;
1389
+ }
1390
+ }
1391
+ vec4 c = texture2D(map, vUv);
1392
+ if (c.a < 0.001) discard;
1393
+
1394
+ // Two-part overlap highlight, both tinted with uGlowColor:
1395
+ // 1. Inner glow — soft radial at the hot point, falling off to
1396
+ // transparent at uGlowFalloff.
1397
+ // 2. Rim — a thin band along the card edge that pools toward the
1398
+ // hot point (rimBand AND rimFocus must both be high).
1399
+ if (uHotStrength > 0.001) {
1400
+ float d = length(vUv - uHotPoint);
1401
+
1402
+ // Inner glow.
1403
+ float falloff = mix(uGlowFalloff.x, uGlowFalloff.y, uIsOverlapTarget);
1404
+ float alpha = mix(uGlowAlpha.x, uGlowAlpha.y, uIsOverlapTarget);
1405
+ float glow = smoothstep(falloff, 0.0, d) * uHotStrength * alpha;
1406
+ c.rgb = mix(c.rgb, uGlowColor, glow);
1407
+
1408
+ // Rim — band thickness fixed at 0.028 in uv (~3% of card on each
1409
+ // axis); brightness pools toward the hot point via rimFocus, with
1410
+ // falloff distance derived from uRimRadius (matches the DOM
1411
+ // radial-gradient(uRimRadius circle ..., transparent 40%)).
1412
+ float edge = min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y));
1413
+ float rimBand = smoothstep(0.028, 0.0, edge);
1414
+ float rimFocus = smoothstep(uRimRadius * 0.4, 0.0, d);
1415
+ float rimA = mix(uRimAlpha.x, uRimAlpha.y, uIsOverlapTarget);
1416
+ float rim = rimBand * rimFocus * uHotStrength * rimA;
1417
+ c.rgb = mix(c.rgb, uRimColor, rim);
1418
+ }
1419
+
1420
+ gl_FragColor = c;
1421
+ #include <colorspace_fragment>
1422
+ }
1423
+ `;
1424
+ /**
1425
+ * Per-instance composition material. Each widget's quad gets its own
1426
+ * instance so the per-quad uniforms (`map`, `uIsDragged`,
1427
+ * `uDraggedRect`) are independent. Three.js compiles the shader once
1428
+ * and reuses the program across instances since they share
1429
+ * vertex/fragment source — verify in dev via
1430
+ * `renderer.info.programs.length === 1` for the composition shader.
1431
+ */
1432
+ var CompositionMaterial = class extends ShaderMaterial {
1433
+ constructor() {
1434
+ super({
1435
+ vertexShader: VERTEX_SHADER,
1436
+ fragmentShader: FRAGMENT_SHADER,
1437
+ uniforms: {
1438
+ map: { value: null },
1439
+ uDraggedRect: { value: new Vector4(0, 0, 0, 0) },
1440
+ uIsDragged: { value: 0 },
1441
+ uHotPoint: { value: new Vector2(.5, .5) },
1442
+ uHotStrength: { value: 0 },
1443
+ uIsOverlapTarget: { value: 0 },
1444
+ uGlowColor: sharedGlowUniforms.uGlowColor,
1445
+ uGlowAlpha: sharedGlowUniforms.uGlowAlpha,
1446
+ uGlowFalloff: sharedGlowUniforms.uGlowFalloff,
1447
+ uRimColor: sharedGlowUniforms.uRimColor,
1448
+ uRimAlpha: sharedGlowUniforms.uRimAlpha,
1449
+ uRimRadius: sharedGlowUniforms.uRimRadius
1450
+ },
1451
+ transparent: true,
1452
+ depthWrite: false
1453
+ });
1454
+ }
1455
+ setMap(map) {
1456
+ this.uniforms.map.value = map;
1457
+ }
1458
+ setDraggedRect(minX, minY, maxX, maxY) {
1459
+ this.uniforms.uDraggedRect.value.set(minX, minY, maxX, maxY);
1460
+ }
1461
+ setIsDragged(isDragged) {
1462
+ this.uniforms.uIsDragged.value = isDragged ? 1 : 0;
1463
+ }
1464
+ /** Set the radial-glow centre in uv space (0..1). */
1465
+ setHotPoint(x, y) {
1466
+ this.uniforms.uHotPoint.value.set(x, y);
1467
+ }
1468
+ /** 0..1 — glow opacity; set to 0 to disable. */
1469
+ setHotStrength(strength) {
1470
+ this.uniforms.uHotStrength.value = strength;
1471
+ }
1472
+ /** Toggle target state (mixes toward the target alpha / falloff). */
1473
+ setIsOverlapTarget(on) {
1474
+ this.uniforms.uIsOverlapTarget.value = on ? 1 : 0;
1475
+ }
1476
+ };
1477
+ //#endregion
1478
+ //#region src/r3f/compositor/CompositorContext.tsx
1479
+ const CompositorContext = createContext(null);
1480
+ function useCompositor() {
1481
+ const ctx = useContext(CompositorContext);
1482
+ if (!ctx) throw new Error("useCompositor must be used inside <Compositor>");
1483
+ return ctx;
1484
+ }
1485
+ //#endregion
1486
+ //#region src/r3f/compositor/eviction.ts
1487
+ /**
1488
+ * Eviction priority — lower numbers evict first. Hot and Waking are never
1489
+ * evicted because they are about to be (or are actively) painted.
1490
+ *
1491
+ * Cold 0 off-screen, eviction-eligible immediately
1492
+ * Warm 1 visible + idle, retained while budget allows
1493
+ * Dormant 2 inactive but eviction-protected — only released as a
1494
+ * last resort because losing the FBO costs the
1495
+ * "instant re-activation" guarantee
1496
+ * Waking ∞ scheduled to repaint imminently — never evict
1497
+ * Hot ∞ actively painted every frame — never evict
1498
+ */
1499
+ const PHASE_PRIORITY = {
1500
+ Cold: 0,
1501
+ Warm: 1,
1502
+ Dormant: 2,
1503
+ Waking: Number.POSITIVE_INFINITY,
1504
+ Hot: Number.POSITIVE_INFINITY
1505
+ };
1506
+ /**
1507
+ * Returns the ordered list of entity ids to release so total bytes ≤ budget.
1508
+ * Sort key: `(phasePriority, lastUsedMs)` — within a phase, oldest goes
1509
+ * first (LRU). Hot / Waking widgets are never returned even if the budget
1510
+ * is impossible to satisfy without them.
1511
+ *
1512
+ * Pure function — Compositor calls this in its useFrame and forwards each
1513
+ * id to `pool.release()`.
1514
+ */
1515
+ function selectEvictions(candidates, totalBytes, maxBytes) {
1516
+ if (totalBytes <= maxBytes) return [];
1517
+ const eligible = candidates.filter((c) => Number.isFinite(PHASE_PRIORITY[c.phase]));
1518
+ eligible.sort((a, b) => {
1519
+ const p = PHASE_PRIORITY[a.phase] - PHASE_PRIORITY[b.phase];
1520
+ if (p !== 0) return p;
1521
+ return a.lastUsedMs - b.lastUsedMs;
1522
+ });
1523
+ const toEvict = [];
1524
+ let remaining = totalBytes;
1525
+ for (const c of eligible) {
1526
+ if (remaining <= maxBytes) break;
1527
+ toEvict.push(c.entityId);
1528
+ remaining -= c.bytes;
1529
+ }
1530
+ return toEvict;
1531
+ }
1532
+ //#endregion
1533
+ //#region src/r3f/compositor/ResourceRegistry.ts
1534
+ /**
1535
+ * Archetype-keyed cache of GPU resources shared across per-widget scenes
1536
+ * (RFC-002 § Three.js resource sharing).
1537
+ *
1538
+ * Geometries, materials, and textures returned by `acquire*` are reference
1539
+ * counted. The registry disposes the underlying resource only after every
1540
+ * holder has called the matching `release*` — so 100 widgets of the same
1541
+ * card archetype share one geometry instance and one set of material
1542
+ * uniforms, rather than allocating 100 copies.
1543
+ */
1544
+ var ResourceRegistry = class {
1545
+ geometries = /* @__PURE__ */ new Map();
1546
+ materials = /* @__PURE__ */ new Map();
1547
+ textures = /* @__PURE__ */ new Map();
1548
+ disposed = false;
1549
+ acquireGeometry(key, factory) {
1550
+ const existing = this.geometries.get(key);
1551
+ if (existing) {
1552
+ existing.refCount++;
1553
+ return existing.resource;
1554
+ }
1555
+ const resource = factory();
1556
+ this.geometries.set(key, {
1557
+ resource,
1558
+ refCount: 1
1559
+ });
1560
+ return resource;
1561
+ }
1562
+ releaseGeometry(key) {
1563
+ this.release(this.geometries, key);
1564
+ }
1565
+ acquireMaterial(key, factory) {
1566
+ const existing = this.materials.get(key);
1567
+ if (existing) {
1568
+ existing.refCount++;
1569
+ return existing.resource;
1570
+ }
1571
+ const resource = factory();
1572
+ this.materials.set(key, {
1573
+ resource,
1574
+ refCount: 1
1575
+ });
1576
+ return resource;
1577
+ }
1578
+ releaseMaterial(key) {
1579
+ this.release(this.materials, key);
1580
+ }
1581
+ acquireTexture(key, factory) {
1582
+ const existing = this.textures.get(key);
1583
+ if (existing) {
1584
+ existing.refCount++;
1585
+ return existing.resource;
1586
+ }
1587
+ const resource = factory();
1588
+ this.textures.set(key, {
1589
+ resource,
1590
+ refCount: 1
1591
+ });
1592
+ return resource;
1593
+ }
1594
+ releaseTexture(key) {
1595
+ this.release(this.textures, key);
1596
+ }
1597
+ /** Number of distinct shared geometries currently held. */
1598
+ geometryCount() {
1599
+ return this.geometries.size;
1600
+ }
1601
+ /** Number of distinct shared materials currently held. */
1602
+ materialCount() {
1603
+ return this.materials.size;
1604
+ }
1605
+ /** Number of distinct shared textures currently held. */
1606
+ textureCount() {
1607
+ return this.textures.size;
1608
+ }
1609
+ /**
1610
+ * Estimated GPU bytes for shared geometry attribute buffers. Best-effort —
1611
+ * actual GPU footprint depends on driver alignment, but this is a useful
1612
+ * relative metric for the profiler.
1613
+ */
1614
+ geometryBytes() {
1615
+ let total = 0;
1616
+ for (const { resource } of this.geometries.values()) {
1617
+ for (const attr of Object.values(resource.attributes)) if ("array" in attr && attr.array.byteLength) total += attr.array.byteLength;
1618
+ if (resource.index) total += resource.index.array.byteLength;
1619
+ }
1620
+ return total;
1621
+ }
1622
+ /** Dispose every resource and clear the registry. */
1623
+ dispose() {
1624
+ if (this.disposed) return;
1625
+ for (const { resource } of this.geometries.values()) resource.dispose();
1626
+ for (const { resource } of this.materials.values()) resource.dispose();
1627
+ for (const { resource } of this.textures.values()) resource.dispose();
1628
+ this.geometries.clear();
1629
+ this.materials.clear();
1630
+ this.textures.clear();
1631
+ this.disposed = true;
1632
+ }
1633
+ /** True after `dispose()` — callers should re-create the registry instead of using it. */
1634
+ isDisposed() {
1635
+ return this.disposed;
1636
+ }
1637
+ release(map, key) {
1638
+ const entry = map.get(key);
1639
+ if (!entry) return;
1640
+ entry.refCount--;
1641
+ if (entry.refCount <= 0) queueMicrotask(() => {
1642
+ const current = map.get(key);
1643
+ if (current && current.refCount <= 0) {
1644
+ current.resource.dispose();
1645
+ map.delete(key);
1646
+ }
1647
+ });
1648
+ }
1649
+ };
1650
+ const R3FRenderState = defineComponent("R3FRenderState", {
1651
+ phase: "Cold",
1652
+ paintedAt: {
1653
+ width: 0,
1654
+ height: 0,
1655
+ dpr: 1,
1656
+ zoom: 1
1657
+ },
1658
+ animating: false,
1659
+ paintGeneration: 0,
1660
+ fboGeneration: -1
1661
+ });
1662
+ /**
1663
+ * Opt-in tag — a widget that wants per-frame ticking sets this. The state
1664
+ * machine treats `Visible + R3FAnimationSignal` as the trigger for `Hot`.
1665
+ * Removing the tag transitions back to `Warm` when the widget settles.
1666
+ */
1667
+ const R3FAnimationSignal = defineTag("R3FAnimationSignal");
1668
+ const R3FRenderBudget = defineResource("R3FRenderBudget", {
1669
+ maxBytes: 256 * 1024 * 1024,
1670
+ currentBytes: 0,
1671
+ maxRepaintsPerFrame: 4
1672
+ });
1673
+ //#endregion
1674
+ //#region src/r3f/compositor/WidgetRenderTargetPool.ts
1675
+ /**
1676
+ * Bytes per pixel for the default render target format (RGBA8 colour +
1677
+ * 24-bit depth + 8-bit stencil packed into 32 bits = 8 bytes/pixel).
1678
+ */
1679
+ const BYTES_PER_PIXEL = 8;
1680
+ /**
1681
+ * Allocates one persistent `WebGLRenderTarget` per R3F widget entity. Used
1682
+ * by the compositor (RFC-002 Phase 4) so each widget paints into its own
1683
+ * texture instead of into the main canvas backbuffer.
1684
+ *
1685
+ * Acquire returns an existing FBO if its pixel resolution matches the
1686
+ * request; otherwise the old FBO is disposed and a new one created (sized
1687
+ * to the new resolution). Eviction under memory pressure is Phase 6 — this
1688
+ * pool grows monotonically until widgets are released.
1689
+ */
1690
+ var WidgetRenderTargetPool = class {
1691
+ entries = /* @__PURE__ */ new Map();
1692
+ totalBytes = 0;
1693
+ disposed = false;
1694
+ /**
1695
+ * Get or create an FBO for `entityId` at the requested logical size and
1696
+ * device pixel ratio. If an FBO already exists at the same pixel
1697
+ * dimensions, returns it unchanged.
1698
+ */
1699
+ acquire(entityId, width, height, dpr) {
1700
+ if (this.disposed) throw new Error("WidgetRenderTargetPool: cannot acquire after dispose");
1701
+ const pixelWidth = Math.max(1, Math.round(width * dpr));
1702
+ const pixelHeight = Math.max(1, Math.round(height * dpr));
1703
+ const now = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : 0;
1704
+ const existing = this.entries.get(entityId);
1705
+ if (existing && existing.pixelWidth === pixelWidth && existing.pixelHeight === pixelHeight) {
1706
+ existing.lastUsedMs = now;
1707
+ return existing.rt;
1708
+ }
1709
+ if (existing) {
1710
+ existing.rt.dispose();
1711
+ this.totalBytes -= existing.bytes;
1712
+ }
1713
+ const rt = new WebGLRenderTarget(pixelWidth, pixelHeight, { samples: 4 });
1714
+ rt.texture.colorSpace = SRGBColorSpace;
1715
+ const bytes = pixelWidth * pixelHeight * BYTES_PER_PIXEL;
1716
+ this.entries.set(entityId, {
1717
+ rt,
1718
+ pixelWidth,
1719
+ pixelHeight,
1720
+ dpr,
1721
+ bytes,
1722
+ lastUsedMs: now
1723
+ });
1724
+ this.totalBytes += bytes;
1725
+ return rt;
1726
+ }
1727
+ /** Look up an FBO without creating one. */
1728
+ get(entityId) {
1729
+ return this.entries.get(entityId)?.rt ?? null;
1730
+ }
1731
+ /**
1732
+ * Refresh `lastUsedMs` without re-acquiring. The Compositor calls this
1733
+ * for every widget it samples in the composition pass — without it,
1734
+ * Warm widgets that never repaint would freeze their `lastUsedMs` at
1735
+ * the time of their last paint, and eviction LRU would treat
1736
+ * still-visible widgets as stale.
1737
+ */
1738
+ touch(entityId) {
1739
+ const entry = this.entries.get(entityId);
1740
+ if (!entry) return;
1741
+ entry.lastUsedMs = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : 0;
1742
+ }
1743
+ /**
1744
+ * Release `entityId`'s FBO. Returns true if something was released.
1745
+ * Safe to call after dispose — returns false rather than corrupting the
1746
+ * byte counter or double-disposing the target.
1747
+ */
1748
+ release(entityId) {
1749
+ if (this.disposed) return false;
1750
+ const entry = this.entries.get(entityId);
1751
+ if (!entry) return false;
1752
+ entry.rt.dispose();
1753
+ this.totalBytes = Math.max(0, this.totalBytes - entry.bytes);
1754
+ this.entries.delete(entityId);
1755
+ return true;
1756
+ }
1757
+ /** Total GPU bytes consumed by the pool. */
1758
+ bytesUsed() {
1759
+ return this.totalBytes;
1760
+ }
1761
+ /** Number of FBOs currently held. */
1762
+ size() {
1763
+ return this.entries.size;
1764
+ }
1765
+ /** True after `dispose()` — callers should re-create the pool instead of using it. */
1766
+ isDisposed() {
1767
+ return this.disposed;
1768
+ }
1769
+ /** Iterate live entries. */
1770
+ forEach(cb) {
1771
+ for (const [id, entry] of this.entries) cb(id, entry.rt);
1772
+ }
1773
+ /**
1774
+ * Snapshot of every live entry's `bytes` + `lastUsedMs` — input for the
1775
+ * eviction algorithm in {@link selectEvictions}.
1776
+ */
1777
+ entryInfos() {
1778
+ const out = [];
1779
+ for (const [id, entry] of this.entries) out.push({
1780
+ entityId: id,
1781
+ bytes: entry.bytes,
1782
+ lastUsedMs: entry.lastUsedMs
1783
+ });
1784
+ return out;
1785
+ }
1786
+ /** Dispose every FBO. After this, acquire throws and release returns false. */
1787
+ dispose() {
1788
+ if (this.disposed) return;
1789
+ for (const entry of this.entries.values()) entry.rt.dispose();
1790
+ this.entries.clear();
1791
+ this.totalBytes = 0;
1792
+ this.disposed = true;
1793
+ }
1794
+ };
1795
+ //#endregion
1796
+ //#region src/r3f/compositor/ZoomBands.ts
1797
+ /**
1798
+ * Hysteresis-banded zoom-resolution policy (RFC-002 § Zoom handling).
1799
+ *
1800
+ * A widget's FBO is allocated at `widget bounds × dpr × band(zoom)` pixels.
1801
+ * As long as the camera zoom stays within `[band × 0.5, band × 2]` of the
1802
+ * band the widget was painted at, no repaint is needed — the composition
1803
+ * shader does a small up/down sample. Crossing that gap triggers a repaint
1804
+ * at the new band, snapping back to a 1:1 (or near-1:1) sample ratio.
1805
+ *
1806
+ * Bands are powers of 2 so each band covers a 4× display range. With the
1807
+ * default ladder (0.0625 ↔ 16) we span camera zooms 0.03125 ↔ 32 — covering
1808
+ * essentially every realistic infinite-canvas zoom level.
1809
+ */
1810
+ const ZOOM_BANDS = [
1811
+ .0625,
1812
+ .125,
1813
+ .25,
1814
+ .5,
1815
+ 1,
1816
+ 2,
1817
+ 4,
1818
+ 8,
1819
+ 16
1820
+ ];
1821
+ /**
1822
+ * Pick the band whose `[band × 0.5, band × 2]` range contains the current
1823
+ * zoom. The smallest band ≥ zoom (after rounding to a band edge) is the
1824
+ * canonical choice — keeps the texture resolution at or above what the
1825
+ * display needs.
1826
+ */
1827
+ function selectBand(zoom) {
1828
+ if (zoom <= ZOOM_BANDS[0]) return ZOOM_BANDS[0];
1829
+ if (zoom >= ZOOM_BANDS[ZOOM_BANDS.length - 1]) return ZOOM_BANDS[ZOOM_BANDS.length - 1];
1830
+ for (const b of ZOOM_BANDS) if (zoom <= b) return b;
1831
+ return ZOOM_BANDS[ZOOM_BANDS.length - 1];
1832
+ }
1833
+ /**
1834
+ * Returns true if the widget needs to be repainted because the camera
1835
+ * zoom has wandered outside the tolerance window of the band it was
1836
+ * painted at.
1837
+ *
1838
+ * `paintedBand` of 0 (or negative) means the widget has never been
1839
+ * painted yet — caller should treat that as "needs paint" through other
1840
+ * channels (Waking phase / paintGeneration > fboGeneration).
1841
+ */
1842
+ function isOutOfBand(currentZoom, paintedBand) {
1843
+ if (paintedBand <= 0) return false;
1844
+ const ratio = currentZoom / paintedBand;
1845
+ return ratio > 2 || ratio < .5;
1846
+ }
1847
+ //#endregion
1848
+ //#region src/r3f/compositor/Compositor.tsx
1849
+ /**
1850
+ * Drives the per-widget paint + composition render loop (RFC-002 Phase 4).
1851
+ *
1852
+ * Lifecycle each invalidation:
1853
+ * 1. For every registered widget whose phase is Hot or Waking (or whose
1854
+ * paintGeneration > fboGeneration), bind its FBO and render its scene
1855
+ * with its widget-local camera.
1856
+ * 2. Update each widget's composition quad (position, scale, texture).
1857
+ * 3. Render the composition scene to the canvas backbuffer with a
1858
+ * world-space orthographic camera matching the engine camera.
1859
+ *
1860
+ * Owns the `WidgetRenderTargetPool`. Children mount `<VirtualWidget />`
1861
+ * instances which register their scene+camera via context.
1862
+ *
1863
+ * Replaces the old `R3FWidgetSlot` + `CameraSync` pair.
1864
+ */
1865
+ function Compositor({ engine, widgetRegistry, children }) {
1866
+ const { gl, size, scene: defaultScene, set } = useThree();
1867
+ const invalidate = useThree((s) => s.invalidate);
1868
+ const poolRef = useRef(null);
1869
+ if (!poolRef.current || poolRef.current.isDisposed()) poolRef.current = new WidgetRenderTargetPool();
1870
+ const pool = poolRef.current;
1871
+ const registryRef = useRef(null);
1872
+ if (!registryRef.current || registryRef.current.isDisposed()) registryRef.current = new ResourceRegistry();
1873
+ const registry = registryRef.current;
1874
+ const quadGeometry = useMemo(() => new PlaneGeometry(1, 1), []);
1875
+ const quadsRef = useRef(/* @__PURE__ */ new Map());
1876
+ const liftScaleRef = useRef(/* @__PURE__ */ new Map());
1877
+ const lastDprRef = useRef(-1);
1878
+ const idleDpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1879
+ const gestureDpr = Math.min(idleDpr, 1);
1880
+ const compCamera = useMemo(() => new OrthographicCamera(0, 1, 0, -1, .1, 1e4), []);
1881
+ useEffect(() => {
1882
+ set({ camera: compCamera });
1883
+ }, [set, compCamera]);
1884
+ const register = useCallback((entityId, entry) => {
1885
+ const unregisterFromRegistry = widgetRegistry.register(entityId, entry);
1886
+ const mesh = new Mesh(quadGeometry, new CompositionMaterial());
1887
+ mesh.frustumCulled = false;
1888
+ mesh.visible = false;
1889
+ defaultScene.add(mesh);
1890
+ quadsRef.current.set(entityId, mesh);
1891
+ entry.requestRepaint();
1892
+ return () => {
1893
+ unregisterFromRegistry();
1894
+ const m = quadsRef.current.get(entityId);
1895
+ if (m) {
1896
+ defaultScene.remove(m);
1897
+ m.material.dispose();
1898
+ quadsRef.current.delete(entityId);
1899
+ }
1900
+ liftScaleRef.current.delete(entityId);
1901
+ pool.release(entityId);
1902
+ };
1903
+ }, [
1904
+ defaultScene,
1905
+ pool,
1906
+ quadGeometry,
1907
+ widgetRegistry
1908
+ ]);
1909
+ const ctxValue = useMemo(() => ({
1910
+ pool,
1911
+ registry,
1912
+ register
1913
+ }), [
1914
+ pool,
1915
+ registry,
1916
+ register
1917
+ ]);
1918
+ useEffect(() => {
1919
+ return () => {
1920
+ pool.dispose();
1921
+ registry.dispose();
1922
+ };
1923
+ }, [pool, registry]);
1924
+ useFrame(() => {
1925
+ const cam = engine.getCamera();
1926
+ compCamera.left = 0;
1927
+ compCamera.right = size.width / cam.zoom;
1928
+ compCamera.top = 0;
1929
+ compCamera.bottom = -(size.height / cam.zoom);
1930
+ compCamera.position.set(cam.x, -cam.y, 1e3);
1931
+ compCamera.updateProjectionMatrix();
1932
+ const targetDpr = cam.gesturing ? gestureDpr : idleDpr;
1933
+ if (lastDprRef.current !== targetDpr) {
1934
+ gl.setPixelRatio(targetDpr);
1935
+ lastDprRef.current = targetDpr;
1936
+ }
1937
+ const dpr = gl.getPixelRatio();
1938
+ const world = engine.world;
1939
+ let sharedEnv = null;
1940
+ if (defaultScene.environment) sharedEnv = defaultScene.environment;
1941
+ else for (const [, entry] of widgetRegistry.all()) if (entry.scene.environment) {
1942
+ sharedEnv = entry.scene.environment;
1943
+ break;
1944
+ }
1945
+ if (sharedEnv) for (const [eid, entry] of widgetRegistry.all()) {
1946
+ if (entry.scene.environment === sharedEnv) continue;
1947
+ entry.scene.environment = sharedEnv;
1948
+ const s = world.getComponent(eid, R3FRenderState);
1949
+ if (s) world.setComponent(eid, R3FRenderState, {
1950
+ ...s,
1951
+ paintGeneration: s.paintGeneration + 1
1952
+ });
1953
+ }
1954
+ const band = selectBand(cam.zoom);
1955
+ const effectiveDpr = dpr * band;
1956
+ let widgetsRepainted = 0;
1957
+ for (const [entityId, entry] of widgetRegistry.all()) {
1958
+ const wt = world.getComponent(entityId, Transform2D);
1959
+ if (!wt) continue;
1960
+ const state = world.getComponent(entityId, R3FRenderState);
1961
+ if (!state) continue;
1962
+ const phaseWantsPaint = state.phase === "Hot" || state.phase === "Waking";
1963
+ const generationDirty = state.paintGeneration > state.fboGeneration;
1964
+ const bandChanged = !cam.gesturing && isOutOfBand(cam.zoom, state.paintedAt.zoom);
1965
+ if (!phaseWantsPaint && !generationDirty && !bandChanged && pool.get(entityId) !== null) continue;
1966
+ const fbo = pool.acquire(entityId, wt.width, wt.height, effectiveDpr);
1967
+ gl.setRenderTarget(fbo);
1968
+ try {
1969
+ gl.setClearColor(0, 0);
1970
+ gl.clear(true, true, false);
1971
+ gl.render(entry.scene, entry.camera);
1972
+ } finally {
1973
+ gl.setRenderTarget(null);
1974
+ }
1975
+ world.setComponent(entityId, R3FRenderState, {
1976
+ ...state,
1977
+ fboGeneration: state.paintGeneration,
1978
+ paintedAt: {
1979
+ width: wt.width,
1980
+ height: wt.height,
1981
+ dpr: effectiveDpr,
1982
+ zoom: band
1983
+ }
1984
+ });
1985
+ widgetsRepainted++;
1986
+ }
1987
+ const budget = world.getResource(R3FRenderBudget);
1988
+ if (pool.bytesUsed() > budget.maxBytes) {
1989
+ const candidates = [];
1990
+ for (const info of pool.entryInfos()) {
1991
+ const s = world.getComponent(info.entityId, R3FRenderState);
1992
+ if (!s) continue;
1993
+ candidates.push({
1994
+ entityId: info.entityId,
1995
+ phase: s.phase,
1996
+ bytes: info.bytes,
1997
+ lastUsedMs: info.lastUsedMs
1998
+ });
1999
+ }
2000
+ const toEvict = selectEvictions(candidates, pool.bytesUsed(), budget.maxBytes);
2001
+ for (const eid of toEvict) {
2002
+ const s = world.getComponent(eid, R3FRenderState);
2003
+ if (s?.phase === "Dormant") console.debug("[r3f-compositor] evicting Dormant widget", eid, "— consider raising R3FRenderBudget.maxBytes");
2004
+ pool.release(eid);
2005
+ if (s) world.setComponent(eid, R3FRenderState, {
2006
+ ...s,
2007
+ fboGeneration: -1
2008
+ });
2009
+ }
2010
+ }
2011
+ let draggedEntityId = null;
2012
+ let draggedRectMinX = 0;
2013
+ let draggedRectMinY = 0;
2014
+ let draggedRectMaxX = 0;
2015
+ let draggedRectMaxY = 0;
2016
+ const canvasHeightPx = size.height * dpr;
2017
+ for (const entityId of widgetRegistry.keys()) {
2018
+ if (!world.hasTag(entityId, Dragging)) continue;
2019
+ if (world.getComponent(entityId, Widget)?.surface !== "webgl") continue;
2020
+ draggedEntityId = entityId;
2021
+ if (!world.hasComponent(entityId, Card)) break;
2022
+ const dt = world.getComponent(entityId, Transform2D);
2023
+ if (!dt) break;
2024
+ const lift = liftScaleRef.current.get(entityId) ?? 1;
2025
+ const cx = dt.x + dt.width / 2;
2026
+ const cy = dt.y + dt.height / 2;
2027
+ const halfW = dt.width * lift / 2;
2028
+ const halfH = dt.height * lift / 2;
2029
+ const minWx = cx - halfW;
2030
+ const maxWx = cx + halfW;
2031
+ const minWy = cy - halfH;
2032
+ const maxWy = cy + halfH;
2033
+ const sxMin = (minWx - cam.x) * cam.zoom * dpr;
2034
+ const sxMax = (maxWx - cam.x) * cam.zoom * dpr;
2035
+ const syTop = (minWy - cam.y) * cam.zoom * dpr;
2036
+ const syBot = (maxWy - cam.y) * cam.zoom * dpr;
2037
+ draggedRectMinX = sxMin;
2038
+ draggedRectMinY = canvasHeightPx - syBot;
2039
+ draggedRectMaxX = sxMax;
2040
+ draggedRectMaxY = canvasHeightPx - syTop;
2041
+ break;
2042
+ }
2043
+ let liftSettling = false;
2044
+ for (const [entityId, mesh] of quadsRef.current) {
2045
+ const qt = world.getComponent(entityId, Transform2D);
2046
+ const state = world.getComponent(entityId, R3FRenderState);
2047
+ const fbo = pool.get(entityId);
2048
+ if (!qt || !fbo || !state || state.fboGeneration < 0) {
2049
+ mesh.visible = false;
2050
+ continue;
2051
+ }
2052
+ pool.touch(entityId);
2053
+ const targetScale = world.hasTag(entityId, Dragging) ? 1.05 : 1;
2054
+ let scale = liftScaleRef.current.get(entityId) ?? 1;
2055
+ scale += (targetScale - scale) * .2;
2056
+ if (Math.abs(targetScale - scale) > .001) liftSettling = true;
2057
+ else scale = targetScale;
2058
+ liftScaleRef.current.set(entityId, scale);
2059
+ mesh.visible = true;
2060
+ mesh.position.set(qt.x + qt.width / 2, -(qt.y + qt.height / 2), 0);
2061
+ mesh.scale.set(qt.width * scale, qt.height * scale, 1);
2062
+ mesh.renderOrder = entityId === draggedEntityId ? 99 : 1;
2063
+ const material = mesh.material;
2064
+ material.setMap(fbo.texture);
2065
+ material.setIsDragged(entityId === draggedEntityId);
2066
+ material.setDraggedRect(draggedRectMinX, draggedRectMinY, draggedRectMaxX, draggedRectMaxY);
2067
+ const hot = world.getComponent(entityId, CardOverlapHotPoint);
2068
+ if (hot && world.hasTag(entityId, OverlapCandidate)) {
2069
+ material.setHotPoint(hot.x, 1 - hot.y);
2070
+ material.setHotStrength(hot.strength);
2071
+ } else material.setHotStrength(0);
2072
+ material.setIsOverlapTarget(world.hasTag(entityId, OverlapTarget));
2073
+ }
2074
+ gl.setRenderTarget(null);
2075
+ gl.setClearColor(0, 0);
2076
+ gl.clear(true, true, false);
2077
+ gl.render(defaultScene, compCamera);
2078
+ COMPOSITOR_TELEMETRY.widgetsRepainted = widgetsRepainted;
2079
+ COMPOSITOR_TELEMETRY.fboBytes = pool.bytesUsed();
2080
+ let anyHot = false;
2081
+ for (const eid of widgetRegistry.keys()) if (world.getComponent(eid, R3FRenderState)?.phase === "Hot") {
2082
+ anyHot = true;
2083
+ break;
2084
+ }
2085
+ if (anyHot || liftSettling) invalidate();
2086
+ }, 1);
2087
+ return /* @__PURE__ */ jsx(CompositorContext.Provider, {
2088
+ value: ctxValue,
2089
+ children
2090
+ });
2091
+ }
2092
+ /**
2093
+ * Shared between Compositor and ProfilerProbe so the probe can record FBO
2094
+ * bytes and per-frame repaint counts without an extra subscription path.
2095
+ * Module-scoped because both components live in the same canvas.
2096
+ */
2097
+ const COMPOSITOR_TELEMETRY = {
2098
+ widgetsRepainted: 0,
2099
+ fboBytes: 0
2100
+ };
2101
+ //#endregion
2102
+ //#region src/r3f/compositor/VirtualWidget.tsx
2103
+ /**
2104
+ * Mounts one R3F widget into its own Three.js scene + ortho camera so it
2105
+ * can be painted into a private `WebGLRenderTarget` instead of the main
2106
+ * canvas backbuffer.
2107
+ *
2108
+ * The user component is rendered in widget-local space — origin at centre,
2109
+ * X right, Y up, dimensions = (Transform2D.width, Transform2D.height) in
2110
+ * frame-local world units.
2111
+ * That matches the contract the previous `R3FWidgetSlot` exposed, so user
2112
+ * widget code (e.g. `geometry-card`) needs no changes.
2113
+ *
2114
+ * VirtualWidget itself does not paint or composite — that's the
2115
+ * Compositor's job. We just create the scene/camera and register them so
2116
+ * the Compositor can iterate widgets in its render loop.
2117
+ */
2118
+ function VirtualWidget({ entityId, component: Component }) {
2119
+ const { register } = useCompositor();
2120
+ const invalidate = useThree((s) => s.invalidate);
2121
+ const engine = useLayoutEngine();
2122
+ const scene = useMemo(() => new Scene(), []);
2123
+ const camera = useMemo(() => new OrthographicCamera(-1, 1, 1, -1, .1, 1e3), []);
2124
+ useEffect(() => {
2125
+ camera.position.set(0, 0, 100);
2126
+ camera.lookAt(0, 0, 0);
2127
+ }, [camera]);
2128
+ const t = useComponent(entityId, Transform2D);
2129
+ const w = t?.width ?? 0;
2130
+ const h = t?.height ?? 0;
2131
+ useLayoutEffect(() => {
2132
+ if (!w || !h) return;
2133
+ camera.left = -w / 2;
2134
+ camera.right = w / 2;
2135
+ camera.top = h / 2;
2136
+ camera.bottom = -h / 2;
2137
+ camera.updateProjectionMatrix();
2138
+ const current = engine.world.getComponent(entityId, R3FRenderState);
2139
+ if (current) engine.world.setComponent(entityId, R3FRenderState, {
2140
+ ...current,
2141
+ paintGeneration: current.paintGeneration + 1
2142
+ });
2143
+ invalidate();
2144
+ }, [
2145
+ camera,
2146
+ w,
2147
+ h,
2148
+ engine,
2149
+ entityId,
2150
+ invalidate
2151
+ ]);
2152
+ useEffect(() => {
2153
+ return register(entityId, {
2154
+ scene,
2155
+ camera,
2156
+ requestRepaint: invalidate
2157
+ });
2158
+ }, [
2159
+ entityId,
2160
+ register,
2161
+ scene,
2162
+ camera,
2163
+ invalidate
2164
+ ]);
2165
+ if (!t) return null;
2166
+ return createPortal(/* @__PURE__ */ jsx(Component, {
2167
+ entityId,
2168
+ width: t.width,
2169
+ height: t.height
2170
+ }), scene);
2171
+ }
2172
+ //#endregion
2173
+ //#region src/r3f/compositor/WidgetRegistry.ts
2174
+ /**
2175
+ * Stable per-canvas registry of R3F widget scenes + cameras (RFC-006).
2176
+ *
2177
+ * Created in `R3FManager` so it's reachable both by the `Compositor`
2178
+ * (which adds/removes widgets as `VirtualWidget` mounts) and by the
2179
+ * R3F event factory (which resolves the active widget by entityId
2180
+ * returned from `engine.pickAt`, then looks up its scene + camera here).
2181
+ *
2182
+ * Plain Map under the hood — the wrapping class exists to give the
2183
+ * registry a stable identity across React renders and to keep the read
2184
+ * surface (`get`, `keys`, `all`) discoverable from both consumers.
2185
+ */
2186
+ var WidgetRegistry = class {
2187
+ entries = /* @__PURE__ */ new Map();
2188
+ register(entityId, entry) {
2189
+ this.entries.set(entityId, entry);
2190
+ return () => this.entries.delete(entityId);
2191
+ }
2192
+ get(entityId) {
2193
+ return this.entries.get(entityId);
2194
+ }
2195
+ all() {
2196
+ return this.entries.entries();
2197
+ }
2198
+ keys() {
2199
+ return this.entries.keys();
2200
+ }
2201
+ values() {
2202
+ return this.entries.values();
2203
+ }
2204
+ clear() {
2205
+ this.entries.clear();
2206
+ }
2207
+ };
2208
+ //#endregion
2209
+ //#region src/r3f/compositor/WidgetStateMachine.tsx
2210
+ /**
2211
+ * Updates `R3FRenderState` phases for every R3F (`surface === 'webgl'`)
2212
+ * widget after each engine tick.
2213
+ *
2214
+ * At this phase the state machine is bookkeeping only — it does not
2215
+ * allocate or paint render targets (that arrives in Phase 4). Consumers
2216
+ * such as widget `useFrame` callbacks read the phase to decide whether
2217
+ * to do work this frame.
2218
+ *
2219
+ * Transition rules match RFC-002 § State machine:
2220
+ *
2221
+ * Active + Visible + R3FAnimationSignal → Hot
2222
+ * Active + Visible (idle) → Warm (or Waking if texture invalid — irrelevant pre-Phase-4)
2223
+ * Active + Culled → Cold
2224
+ * !Active → Dormant
2225
+ */
2226
+ function WidgetStateMachine({ engine }) {
2227
+ useEffect(() => {
2228
+ const world = engine.world;
2229
+ function updatePhases() {
2230
+ for (const entity of world.query(Widget)) {
2231
+ const widget = world.getComponent(entity, Widget);
2232
+ if (!widget || widget.surface !== "webgl") continue;
2233
+ const current = world.getComponent(entity, R3FRenderState);
2234
+ const animating = world.hasTag(entity, R3FAnimationSignal);
2235
+ const hasFbo = (current?.fboGeneration ?? -1) >= 0;
2236
+ const nextPhase = computePhase(world.hasTag(entity, Active), world.hasTag(entity, Visible), world.hasTag(entity, Culled), animating, hasFbo);
2237
+ if (!current) world.addComponent(entity, R3FRenderState, {
2238
+ phase: nextPhase,
2239
+ paintedAt: {
2240
+ width: 0,
2241
+ height: 0,
2242
+ dpr: 1,
2243
+ zoom: 1
2244
+ },
2245
+ animating,
2246
+ paintGeneration: 0,
2247
+ fboGeneration: -1
2248
+ });
2249
+ else if (current.phase !== nextPhase || current.animating !== animating) world.setComponent(entity, R3FRenderState, {
2250
+ ...current,
2251
+ phase: nextPhase,
2252
+ animating
2253
+ });
2254
+ }
2255
+ }
2256
+ updatePhases();
2257
+ return engine.onFrame(updatePhases);
2258
+ }, [engine]);
2259
+ return null;
2260
+ }
2261
+ /**
2262
+ * Pure function version of the state-machine transition rule. Exported so
2263
+ * tests can pin the truth table without mounting React.
2264
+ *
2265
+ * `hasFbo` distinguishes Warm (texture present, can be sampled) from Waking
2266
+ * (Visible but no valid texture yet — the compositor must paint before the
2267
+ * widget is composited). After eviction (Phase 6) a Cold widget can lose
2268
+ * its texture and re-enter as Waking.
2269
+ */
2270
+ function computePhase(active, visible, culled, animationSignal, hasFbo) {
2271
+ if (!active) return "Dormant";
2272
+ if (visible) {
2273
+ if (animationSignal) return "Hot";
2274
+ return hasFbo ? "Warm" : "Waking";
2275
+ }
2276
+ if (culled) return "Cold";
2277
+ return "Cold";
2278
+ }
2279
+ //#endregion
2280
+ //#region src/r3f/EngineInvalidator.tsx
2281
+ /**
2282
+ * Subscribes to engine frame events and invalidates the R3F canvas only when
2283
+ * something has changed that affects R3F output. With `frameloop="demand"` on
2284
+ * the parent `<Canvas>`, the canvas only renders in response to these
2285
+ * invalidations — idle scenes produce zero R3F frames.
2286
+ *
2287
+ * Triggers invalidation on: camera change, position change (drag/resize),
2288
+ * widgets entering or exiting the visible set.
2289
+ */
2290
+ function EngineInvalidator({ engine }) {
2291
+ const invalidate = useThree((s) => s.invalidate);
2292
+ useEffect(() => {
2293
+ return engine.onFrame(() => {
2294
+ const c = engine.getFrameChanges();
2295
+ if (c.cameraChanged || c.positionsChanged.length > 0 || c.entered.length > 0 || c.exited.length > 0) invalidate();
2296
+ });
2297
+ }, [engine, invalidate]);
2298
+ return null;
2299
+ }
2300
+ //#endregion
2301
+ //#region src/r3f/ProfilerProbe.tsx
2302
+ /**
2303
+ * Reports one R3F frame sample per animation frame to the engine profiler.
2304
+ * Reads `renderer.info` from three.js — draw calls / triangles / memory /
2305
+ * programs — which is maintained by R3F's default render loop regardless
2306
+ * of whether we opt in. Only samples when the profiler is enabled.
2307
+ */
2308
+ function ProfilerProbe({ engine, widgetCount }) {
2309
+ const { gl } = useThree();
2310
+ const prevTimeRef = useRef(null);
2311
+ const prevCallsRef = useRef(0);
2312
+ const prevTrianglesRef = useRef(0);
2313
+ const prevPointsRef = useRef(0);
2314
+ const prevLinesRef = useRef(0);
2315
+ useFrame(() => {
2316
+ const profiler = engine.profiler;
2317
+ if (!profiler.isEnabled()) {
2318
+ prevTimeRef.current = null;
2319
+ return;
2320
+ }
2321
+ const now = performance.now();
2322
+ const dtMs = prevTimeRef.current === null ? 0 : now - prevTimeRef.current;
2323
+ prevTimeRef.current = now;
2324
+ const info = gl.info;
2325
+ const calls = info.render.calls;
2326
+ const triangles = info.render.triangles;
2327
+ const points = info.render.points;
2328
+ const lines = info.render.lines;
2329
+ const frameCalls = info.autoReset ? calls : Math.max(0, calls - prevCallsRef.current);
2330
+ const frameTris = info.autoReset ? triangles : Math.max(0, triangles - prevTrianglesRef.current);
2331
+ const framePoints = info.autoReset ? points : Math.max(0, points - prevPointsRef.current);
2332
+ const frameLines = info.autoReset ? lines : Math.max(0, lines - prevLinesRef.current);
2333
+ prevCallsRef.current = calls;
2334
+ prevTrianglesRef.current = triangles;
2335
+ prevPointsRef.current = points;
2336
+ prevLinesRef.current = lines;
2337
+ const phases = {
2338
+ hot: 0,
2339
+ warm: 0,
2340
+ cold: 0,
2341
+ waking: 0,
2342
+ dormant: 0
2343
+ };
2344
+ const world = engine.world;
2345
+ for (const entity of world.query(Widget, R3FRenderState)) {
2346
+ const widget = world.getComponent(entity, Widget);
2347
+ if (!widget || widget.surface !== "webgl") continue;
2348
+ const state = world.getComponent(entity, R3FRenderState);
2349
+ if (!state) continue;
2350
+ switch (state.phase) {
2351
+ case "Hot":
2352
+ phases.hot++;
2353
+ break;
2354
+ case "Warm":
2355
+ phases.warm++;
2356
+ break;
2357
+ case "Cold":
2358
+ phases.cold++;
2359
+ break;
2360
+ case "Waking":
2361
+ phases.waking++;
2362
+ break;
2363
+ case "Dormant":
2364
+ phases.dormant++;
2365
+ break;
2366
+ }
2367
+ }
2368
+ profiler.recordR3FFrame({
2369
+ dtMs,
2370
+ drawCalls: frameCalls,
2371
+ triangles: frameTris,
2372
+ points: framePoints,
2373
+ lines: frameLines,
2374
+ programs: info.programs?.length ?? 0,
2375
+ geometries: info.memory.geometries,
2376
+ textures: info.memory.textures,
2377
+ activeWidgets: widgetCount,
2378
+ widgetsRepainted: COMPOSITOR_TELEMETRY.widgetsRepainted,
2379
+ fboBytes: COMPOSITOR_TELEMETRY.fboBytes,
2380
+ phases
2381
+ });
2382
+ }, 2);
2383
+ return null;
2384
+ }
2385
+ //#endregion
2386
+ //#region src/r3f/R3FManager.tsx
2387
+ /**
2388
+ * Top-level coordinator for the R3F (React Three Fiber) rendering layer.
2389
+ *
2390
+ * Mounts a single `<Canvas>` and lets the {@link Compositor} drive the
2391
+ * render loop — each R3F widget paints into its own `WebGLRenderTarget`
2392
+ * via {@link VirtualWidget} and a final composition pass samples those
2393
+ * textures into the visible canvas (RFC-002 Phase 4).
2394
+ */
2395
+ function R3FManager({ engine, entities, resolve, r3fRoot, eventManagerRef }) {
2396
+ const canvasRef = useRef(null);
2397
+ const containerRef = useContainerRef();
2398
+ const initialCamera = useMemo(() => {
2399
+ const cam = new THREE.OrthographicCamera(0, 1, 0, -1, .1, 1e4);
2400
+ cam.position.set(0, 0, 1e3);
2401
+ return cam;
2402
+ }, []);
2403
+ const widgetRegistry = useMemo(() => new WidgetRegistry(), []);
2404
+ const eventManager = useMemo(() => createR3FEventManager(engine, widgetRegistry, (manager) => {
2405
+ if (eventManagerRef) eventManagerRef.current = manager;
2406
+ }), [
2407
+ engine,
2408
+ widgetRegistry,
2409
+ eventManagerRef
2410
+ ]);
2411
+ const widgetEntries = useMemo(() => {
2412
+ const result = [];
2413
+ for (const id of entities) {
2414
+ const resolved = resolve(id);
2415
+ if (resolved && resolved.surface === "webgl") result.push({
2416
+ entityId: id,
2417
+ component: resolved.component
2418
+ });
2419
+ }
2420
+ return result;
2421
+ }, [entities, resolve]);
2422
+ return /* @__PURE__ */ jsx(Canvas, {
2423
+ ref: canvasRef,
2424
+ camera: initialCamera,
2425
+ frameloop: "demand",
2426
+ events: eventManager,
2427
+ eventSource: containerRef ?? void 0,
2428
+ gl: {
2429
+ alpha: true,
2430
+ antialias: true
2431
+ },
2432
+ style: {
2433
+ position: "absolute",
2434
+ inset: 0,
2435
+ pointerEvents: "none",
2436
+ zIndex: 1,
2437
+ display: widgetEntries.length === 0 ? "none" : "block"
2438
+ },
2439
+ children: /* @__PURE__ */ jsxs(EngineProvider, {
2440
+ value: engine,
2441
+ children: [
2442
+ /* @__PURE__ */ jsx(EngineInvalidator, { engine }),
2443
+ /* @__PURE__ */ jsx(WidgetStateMachine, { engine }),
2444
+ /* @__PURE__ */ jsx(ProfilerProbe, {
2445
+ engine,
2446
+ widgetCount: widgetEntries.length
2447
+ }),
2448
+ r3fRoot,
2449
+ /* @__PURE__ */ jsx(Compositor, {
2450
+ engine,
2451
+ widgetRegistry,
2452
+ children: widgetEntries.map(({ entityId, component }) => /* @__PURE__ */ jsx(VirtualWidget, {
2453
+ entityId,
2454
+ component
2455
+ }, entityId))
2456
+ })
2457
+ ]
2458
+ })
2459
+ });
2460
+ }
2461
+ //#endregion
2462
+ //#region src/webgl/renderers/GridRenderer.ts
2463
+ const DEFAULT_GRID_CONFIG = {
2464
+ spacings: [
2465
+ 20,
2466
+ 100,
2467
+ 500
2468
+ ],
2469
+ dotColor: [
2470
+ .75,
2471
+ .77,
2472
+ .8
2473
+ ],
2474
+ dotAlpha: 1,
2475
+ fadeIn: [8, 16],
2476
+ fadeOut: [120, 200],
2477
+ dotRadius: [.75, .75],
2478
+ levelWeight: [1, 0]
2479
+ };
2480
+ const vertexShader$2 = `
2481
+ void main() {
2482
+ gl_Position = vec4(position.xy, 0.0, 1.0);
2483
+ }
2484
+ `;
2485
+ const fragmentShader$2 = `
2486
+ precision highp float;
2487
+
2488
+ uniform vec2 u_resolution; // device pixels
2489
+ uniform vec2 u_camera; // world-space top-left
2490
+ uniform float u_zoom; // CSS zoom
2491
+ uniform float u_dpr; // device pixel ratio
2492
+ uniform vec3 u_spacings; // world-unit grid spacings
2493
+ uniform vec3 u_dotColor; // dot RGB
2494
+ uniform float u_dotAlpha; // dot base alpha
2495
+ uniform vec2 u_fadeIn; // CSS-px [start, end]
2496
+ uniform vec2 u_fadeOut; // CSS-px [start, end]
2497
+ uniform vec2 u_dotRadius; // CSS-px [min, max]
2498
+ uniform vec2 u_levelWeight; // [base, step]
2499
+
2500
+ void main() {
2501
+ vec2 devicePos = gl_FragCoord.xy;
2502
+ devicePos.y = u_resolution.y - devicePos.y;
2503
+
2504
+ float effectiveZoom = u_zoom * u_dpr;
2505
+ vec2 worldPos = devicePos / effectiveZoom + u_camera;
2506
+
2507
+ float totalAlpha = 0.0;
2508
+
2509
+ for (int i = 0; i < 3; i++) {
2510
+ float spacing;
2511
+ if (i == 0) spacing = u_spacings.x;
2512
+ else if (i == 1) spacing = u_spacings.y;
2513
+ else spacing = u_spacings.z;
2514
+
2515
+ // Screen spacing in CSS pixels (DPR-independent for consistent fading)
2516
+ float cssSpacing = spacing * u_zoom;
2517
+
2518
+ // Fade curve
2519
+ float opacity = 0.0;
2520
+ if (cssSpacing >= u_fadeIn.x && cssSpacing < u_fadeIn.y) {
2521
+ opacity = (cssSpacing - u_fadeIn.x) / (u_fadeIn.y - u_fadeIn.x);
2522
+ } else if (cssSpacing >= u_fadeIn.y && cssSpacing < u_fadeOut.x) {
2523
+ opacity = 1.0;
2524
+ } else if (cssSpacing >= u_fadeOut.x && cssSpacing < u_fadeOut.y) {
2525
+ opacity = 1.0 - (cssSpacing - u_fadeOut.x) / (u_fadeOut.y - u_fadeOut.x);
2526
+ }
2527
+ if (opacity <= 0.001) continue;
2528
+
2529
+ // Distance to nearest grid intersection in device pixels
2530
+ vec2 f = fract(worldPos / spacing + 0.5) - 0.5;
2531
+ float dist = length(f) * spacing * effectiveZoom;
2532
+
2533
+ // Dot radius in device pixels — optionally grows for sparser levels
2534
+ // (set u_dotRadius.x == u_dotRadius.y for Freeform/FigJam-style
2535
+ // constant-size dots)
2536
+ float t = clamp((cssSpacing - u_fadeIn.x) / 40.0, 0.0, 1.0);
2537
+ float radius = mix(u_dotRadius.x, u_dotRadius.y, t) * u_dpr;
2538
+
2539
+ // Anti-aliased dot (0.5 device pixel smoothstep)
2540
+ float dot = 1.0 - smoothstep(radius - 0.5, radius + 0.5, dist);
2541
+
2542
+ // Per-level weight: base + i * step. Step=0 keeps all levels at equal
2543
+ // intensity; positive step emphasizes coarser levels (CAD feel).
2544
+ float weight = u_levelWeight.x + float(i) * u_levelWeight.y;
2545
+
2546
+ // Composite with max, not sum. Additive compositing causes anti-
2547
+ // aliased dot rims to stack at joint intersections (every N-th dot
2548
+ // visibly fatter — a CAD tell). max() guarantees a joint intersection
2549
+ // looks identical to a single-level dot, matching Freeform / FigJam.
2550
+ totalAlpha = max(totalAlpha, dot * opacity * weight);
2551
+ }
2552
+
2553
+ gl_FragColor = vec4(u_dotColor, clamp(totalAlpha * u_dotAlpha, 0.0, 1.0));
2554
+ }
2555
+ `;
2556
+ /**
2557
+ * Draws the infinite dot-grid background into a THREE.WebGLRenderer.
2558
+ * The renderer is owned by the parent (see {@link WebGLManager}) — this class
2559
+ * only contributes a scene, camera, and shader material.
2560
+ */
2561
+ var GridRenderer = class {
2562
+ scene;
2563
+ camera;
2564
+ material;
2565
+ mesh;
2566
+ constructor() {
2567
+ this.scene = new THREE.Scene();
2568
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
2569
+ this.material = new THREE.ShaderMaterial({
2570
+ vertexShader: vertexShader$2,
2571
+ fragmentShader: fragmentShader$2,
2572
+ uniforms: {
2573
+ u_resolution: { value: new THREE.Vector2(1, 1) },
2574
+ u_camera: { value: new THREE.Vector2(0, 0) },
2575
+ u_zoom: { value: 1 },
2576
+ u_dpr: { value: 1 },
2577
+ u_spacings: { value: new THREE.Vector3(...DEFAULT_GRID_CONFIG.spacings) },
2578
+ u_dotColor: { value: new THREE.Vector3(...DEFAULT_GRID_CONFIG.dotColor) },
2579
+ u_dotAlpha: { value: DEFAULT_GRID_CONFIG.dotAlpha },
2580
+ u_fadeIn: { value: new THREE.Vector2(...DEFAULT_GRID_CONFIG.fadeIn) },
2581
+ u_fadeOut: { value: new THREE.Vector2(...DEFAULT_GRID_CONFIG.fadeOut) },
2582
+ u_dotRadius: { value: new THREE.Vector2(...DEFAULT_GRID_CONFIG.dotRadius) },
2583
+ u_levelWeight: { value: new THREE.Vector2(...DEFAULT_GRID_CONFIG.levelWeight) }
2584
+ },
2585
+ transparent: true,
2586
+ depthTest: false,
2587
+ depthWrite: false
2588
+ });
2589
+ const geometry = new THREE.BufferGeometry();
2590
+ const vertices = new Float32Array([
2591
+ -1,
2592
+ -1,
2593
+ 0,
2594
+ 3,
2595
+ -1,
2596
+ 0,
2597
+ -1,
2598
+ 3,
2599
+ 0
2600
+ ]);
2601
+ geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
2602
+ this.mesh = new THREE.Mesh(geometry, this.material);
2603
+ this.scene.add(this.mesh);
2604
+ }
2605
+ /** Apply a (partial) grid config. Only provided fields are updated. */
2606
+ setConfig(config) {
2607
+ const u = this.material.uniforms;
2608
+ if (config.spacings) u.u_spacings.value.set(...config.spacings);
2609
+ if (config.dotColor) u.u_dotColor.value.set(...config.dotColor);
2610
+ if (config.dotAlpha !== void 0) u.u_dotAlpha.value = config.dotAlpha;
2611
+ if (config.fadeIn) u.u_fadeIn.value.set(...config.fadeIn);
2612
+ if (config.fadeOut) u.u_fadeOut.value.set(...config.fadeOut);
2613
+ if (config.dotRadius) u.u_dotRadius.value.set(...config.dotRadius);
2614
+ if (config.levelWeight) u.u_levelWeight.value.set(...config.levelWeight);
2615
+ }
2616
+ setSize(width, height, dpr = 1) {
2617
+ const u = this.material.uniforms;
2618
+ u.u_resolution.value.set(width * dpr, height * dpr);
2619
+ u.u_dpr.value = dpr;
2620
+ }
2621
+ render(renderer, cameraX, cameraY, zoom) {
2622
+ const u = this.material.uniforms;
2623
+ u.u_camera.value.set(cameraX, cameraY);
2624
+ u.u_zoom.value = zoom;
2625
+ renderer.render(this.scene, this.camera);
2626
+ }
2627
+ dispose() {
2628
+ this.mesh.geometry.dispose();
2629
+ this.material.dispose();
2630
+ }
2631
+ };
2632
+ //#endregion
2633
+ //#region src/webgl/renderers/SelectionRenderer.ts
2634
+ const DEFAULT_SELECTION_CONFIG = {
2635
+ outlineColor: [
2636
+ .051,
2637
+ .6,
2638
+ 1
2639
+ ],
2640
+ outlineWidth: 1.5,
2641
+ hoverColor: [
2642
+ .051,
2643
+ .6,
2644
+ 1
2645
+ ],
2646
+ hoverWidth: 1,
2647
+ handleSize: 8,
2648
+ handleFill: [
2649
+ 1,
2650
+ 1,
2651
+ 1
2652
+ ],
2653
+ handleBorder: [
2654
+ .051,
2655
+ .6,
2656
+ 1
2657
+ ],
2658
+ handleBorderWidth: 1.5,
2659
+ groupDash: 4
2660
+ };
2661
+ const MAX_ENTITIES = 32;
2662
+ const vertexShader$1 = `
2663
+ void main() {
2664
+ gl_Position = vec4(position.xy, 0.0, 1.0);
2665
+ }
2666
+ `;
2667
+ const fragmentShader$1 = `
2668
+ precision highp float;
2669
+
2670
+ uniform vec2 u_resolution;
2671
+ uniform vec2 u_camera;
2672
+ uniform float u_zoom;
2673
+ uniform float u_dpr;
2674
+
2675
+ // Selection data
2676
+ uniform int u_count;
2677
+ uniform vec4 u_bounds[${MAX_ENTITIES}]; // (x, y, width, height) — frame-local Transform2D
2678
+ uniform int u_hoverIdx; // -1 = none
2679
+ uniform vec4 u_groupBounds; // group bbox (0 if count <= 1)
2680
+ uniform int u_hasGroup;
2681
+
2682
+ // Style
2683
+ uniform vec3 u_outlineColor;
2684
+ uniform float u_outlineWidth;
2685
+ uniform vec3 u_hoverColor;
2686
+ uniform float u_hoverWidth;
2687
+ uniform float u_handleSize;
2688
+ uniform vec3 u_handleFill;
2689
+ uniform vec3 u_handleBorder;
2690
+ uniform float u_handleBorderWidth;
2691
+ uniform float u_groupDash;
2692
+
2693
+ // SDF for axis-aligned rectangle outline (returns distance to edge)
2694
+ float sdRectOutline(vec2 p, vec2 center, vec2 halfSize) {
2695
+ vec2 d = abs(p - center) - halfSize;
2696
+ float outside = length(max(d, 0.0));
2697
+ float inside = min(max(d.x, d.y), 0.0);
2698
+ return abs(outside + inside);
2699
+ }
2700
+
2701
+ // SDF for filled square
2702
+ float sdSquare(vec2 p, vec2 center, float halfSize) {
2703
+ vec2 d = abs(p - center) - vec2(halfSize);
2704
+ return max(d.x, d.y);
2705
+ }
2706
+
2707
+ void main() {
2708
+ if (u_count == 0 && u_hoverIdx < 0) discard;
2709
+
2710
+ vec2 devicePos = gl_FragCoord.xy;
2711
+ devicePos.y = u_resolution.y - devicePos.y;
2712
+
2713
+ float effectiveZoom = u_zoom * u_dpr;
2714
+ vec2 worldPos = devicePos / effectiveZoom + u_camera;
2715
+
2716
+ // Screen-space conversion factor
2717
+ float pxToWorld = 1.0 / effectiveZoom;
2718
+
2719
+ vec4 color = vec4(0.0);
2720
+
2721
+ // --- Hover outline ---
2722
+ if (u_hoverIdx >= 0 && u_hoverIdx < ${MAX_ENTITIES}) {
2723
+ vec4 b = u_bounds[u_hoverIdx];
2724
+ vec2 center = vec2(b.x + b.z * 0.5, b.y + b.w * 0.5);
2725
+ vec2 halfSize = vec2(b.z, b.w) * 0.5;
2726
+ float dist = sdRectOutline(worldPos, center, halfSize);
2727
+ float width = u_hoverWidth * pxToWorld;
2728
+ float alpha = 1.0 - smoothstep(width - pxToWorld * 0.5, width + pxToWorld * 0.5, dist);
2729
+ color = max(color, vec4(u_hoverColor, alpha * 0.6));
2730
+ }
2731
+
2732
+ // --- Selection outlines ---
2733
+ for (int i = 0; i < ${MAX_ENTITIES}; i++) {
2734
+ if (i >= u_count) break;
2735
+ vec4 b = u_bounds[i];
2736
+ vec2 center = vec2(b.x + b.z * 0.5, b.y + b.w * 0.5);
2737
+ vec2 halfSize = vec2(b.z, b.w) * 0.5;
2738
+
2739
+ // Outline
2740
+ float dist = sdRectOutline(worldPos, center, halfSize);
2741
+ float width = u_outlineWidth * pxToWorld;
2742
+ float outlineAlpha = 1.0 - smoothstep(width - pxToWorld * 0.5, width + pxToWorld * 0.5, dist);
2743
+ color = max(color, vec4(u_outlineColor, outlineAlpha));
2744
+
2745
+ // 8 resize handles
2746
+ float hs = u_handleSize * 0.5 * pxToWorld;
2747
+ float bw = u_handleBorderWidth * pxToWorld;
2748
+ vec2 corners[8];
2749
+ corners[0] = vec2(b.x, b.y); // nw
2750
+ corners[1] = vec2(b.x + b.z * 0.5, b.y); // n
2751
+ corners[2] = vec2(b.x + b.z, b.y); // ne
2752
+ corners[3] = vec2(b.x + b.z, b.y + b.w * 0.5); // e
2753
+ corners[4] = vec2(b.x + b.z, b.y + b.w); // se
2754
+ corners[5] = vec2(b.x + b.z * 0.5, b.y + b.w); // s
2755
+ corners[6] = vec2(b.x, b.y + b.w); // sw
2756
+ corners[7] = vec2(b.x, b.y + b.w * 0.5); // w
2757
+
2758
+ for (int h = 0; h < 8; h++) {
2759
+ float d = sdSquare(worldPos, corners[h], hs);
2760
+ // Fill (white)
2761
+ float fillAlpha = 1.0 - smoothstep(-pxToWorld * 0.5, pxToWorld * 0.5, d);
2762
+ // Border
2763
+ float borderDist = abs(d + bw * 0.5) - bw * 0.5;
2764
+ float borderAlpha = 1.0 - smoothstep(-pxToWorld * 0.5, pxToWorld * 0.5, borderDist);
2765
+
2766
+ if (fillAlpha > 0.01) {
2767
+ // Composite: border color on top of fill
2768
+ vec3 handleColor = mix(u_handleFill, u_handleBorder, borderAlpha);
2769
+ color = vec4(handleColor, max(fillAlpha, color.a));
2770
+ }
2771
+ }
2772
+ }
2773
+
2774
+ // --- Group bounding box (dashed) ---
2775
+ if (u_hasGroup == 1 && u_count > 1) {
2776
+ vec4 gb = u_groupBounds;
2777
+ vec2 center = vec2(gb.x + gb.z * 0.5, gb.y + gb.w * 0.5);
2778
+ vec2 halfSize = vec2(gb.z, gb.w) * 0.5;
2779
+ float dist = sdRectOutline(worldPos, center, halfSize);
2780
+ float width = u_outlineWidth * 0.75 * pxToWorld;
2781
+ float lineAlpha = 1.0 - smoothstep(width - pxToWorld * 0.5, width + pxToWorld * 0.5, dist);
2782
+
2783
+ // Dash pattern along the rectangle perimeter
2784
+ if (u_groupDash > 0.0 && lineAlpha > 0.01) {
2785
+ // Proper perimeter arc-length from top-left corner going clockwise
2786
+ float perim;
2787
+ vec2 rel = worldPos - vec2(gb.x, gb.y);
2788
+ float w = gb.z;
2789
+ float h = gb.w;
2790
+
2791
+ // Determine which edge is nearest and compute cumulative arc length
2792
+ float dTop = abs(rel.y);
2793
+ float dRight = abs(rel.x - w);
2794
+ float dBottom = abs(rel.y - h);
2795
+ float dLeft = abs(rel.x);
2796
+
2797
+ if (dTop <= dBottom && dTop <= dLeft && dTop <= dRight) {
2798
+ perim = rel.x; // top edge: 0 to w
2799
+ } else if (dRight <= dLeft) {
2800
+ perim = w + rel.y; // right edge: w to w+h
2801
+ } else if (dBottom <= dTop) {
2802
+ perim = w + h + (w - rel.x); // bottom edge: w+h to 2w+h
2803
+ } else {
2804
+ perim = 2.0 * w + h + (h - rel.y); // left edge: 2w+h to 2w+2h
2805
+ }
2806
+
2807
+ float dashWorld = u_groupDash * pxToWorld;
2808
+ float dashPattern = step(0.5, fract(perim / (dashWorld * 2.0)));
2809
+ lineAlpha *= dashPattern;
2810
+ }
2811
+
2812
+ color = max(color, vec4(u_outlineColor, lineAlpha * 0.5));
2813
+ }
2814
+
2815
+ if (color.a < 0.01) discard;
2816
+ gl_FragColor = color;
2817
+ }
2818
+ `;
2819
+ /**
2820
+ * Draws selection outlines, 8 resize handles, group-bbox, and the hover
2821
+ * outline in a single SDF-based shader pass. Snap guides and equal-
2822
+ * spacing indicators are a separate concern — see {@link SnapGuideRenderer}.
2823
+ *
2824
+ * The `THREE.WebGLRenderer` is owned by the parent ({@link WebGLManager})
2825
+ * and passed to each render call so grid + selection can share a single
2826
+ * GL context and accumulate `renderer.info` counters for the same tick.
2827
+ */
2828
+ var SelectionRenderer = class {
2829
+ material;
2830
+ mesh;
2831
+ scene;
2832
+ camera;
2833
+ constructor() {
2834
+ this.scene = new THREE.Scene();
2835
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
2836
+ const boundsDefault = [];
2837
+ for (let i = 0; i < MAX_ENTITIES; i++) boundsDefault.push(new THREE.Vector4(0, 0, 0, 0));
2838
+ this.material = new THREE.ShaderMaterial({
2839
+ vertexShader: vertexShader$1,
2840
+ fragmentShader: fragmentShader$1,
2841
+ uniforms: {
2842
+ u_resolution: { value: new THREE.Vector2(1, 1) },
2843
+ u_camera: { value: new THREE.Vector2(0, 0) },
2844
+ u_zoom: { value: 1 },
2845
+ u_dpr: { value: 1 },
2846
+ u_count: { value: 0 },
2847
+ u_bounds: { value: boundsDefault },
2848
+ u_hoverIdx: { value: -1 },
2849
+ u_groupBounds: { value: new THREE.Vector4(0, 0, 0, 0) },
2850
+ u_hasGroup: { value: 0 },
2851
+ u_outlineColor: { value: new THREE.Vector3(...DEFAULT_SELECTION_CONFIG.outlineColor) },
2852
+ u_outlineWidth: { value: DEFAULT_SELECTION_CONFIG.outlineWidth },
2853
+ u_hoverColor: { value: new THREE.Vector3(...DEFAULT_SELECTION_CONFIG.hoverColor) },
2854
+ u_hoverWidth: { value: DEFAULT_SELECTION_CONFIG.hoverWidth },
2855
+ u_handleSize: { value: DEFAULT_SELECTION_CONFIG.handleSize },
2856
+ u_handleFill: { value: new THREE.Vector3(...DEFAULT_SELECTION_CONFIG.handleFill) },
2857
+ u_handleBorder: { value: new THREE.Vector3(...DEFAULT_SELECTION_CONFIG.handleBorder) },
2858
+ u_handleBorderWidth: { value: DEFAULT_SELECTION_CONFIG.handleBorderWidth },
2859
+ u_groupDash: { value: DEFAULT_SELECTION_CONFIG.groupDash }
2860
+ },
2861
+ transparent: true,
2862
+ depthTest: false,
2863
+ depthWrite: false
2864
+ });
2865
+ const geometry = new THREE.BufferGeometry();
2866
+ const vertices = new Float32Array([
2867
+ -1,
2868
+ -1,
2869
+ 0,
2870
+ 3,
2871
+ -1,
2872
+ 0,
2873
+ -1,
2874
+ 3,
2875
+ 0
2876
+ ]);
2877
+ geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
2878
+ this.mesh = new THREE.Mesh(geometry, this.material);
2879
+ this.scene.add(this.mesh);
2880
+ }
2881
+ setConfig(config) {
2882
+ const u = this.material.uniforms;
2883
+ if (config.outlineColor) u.u_outlineColor.value.set(...config.outlineColor);
2884
+ if (config.outlineWidth !== void 0) u.u_outlineWidth.value = config.outlineWidth;
2885
+ if (config.hoverColor) u.u_hoverColor.value.set(...config.hoverColor);
2886
+ if (config.hoverWidth !== void 0) u.u_hoverWidth.value = config.hoverWidth;
2887
+ if (config.handleSize !== void 0) u.u_handleSize.value = config.handleSize;
2888
+ if (config.handleFill) u.u_handleFill.value.set(...config.handleFill);
2889
+ if (config.handleBorder) u.u_handleBorder.value.set(...config.handleBorder);
2890
+ if (config.handleBorderWidth !== void 0) u.u_handleBorderWidth.value = config.handleBorderWidth;
2891
+ if (config.groupDash !== void 0) u.u_groupDash.value = config.groupDash;
2892
+ }
2893
+ setSize(resolution, dpr) {
2894
+ this.material.uniforms.u_resolution.value.copy(resolution);
2895
+ this.material.uniforms.u_dpr.value = dpr;
2896
+ }
2897
+ render(renderer, cameraX, cameraY, zoom, selected, hovered) {
2898
+ const u = this.material.uniforms;
2899
+ u.u_camera.value.set(cameraX, cameraY);
2900
+ u.u_zoom.value = zoom;
2901
+ const count = Math.min(selected.length, MAX_ENTITIES);
2902
+ u.u_count.value = count;
2903
+ for (let i = 0; i < count; i++) {
2904
+ const b = selected[i];
2905
+ u.u_bounds.value[i].set(b.x, b.y, b.width, b.height);
2906
+ }
2907
+ if (hovered && count < MAX_ENTITIES) {
2908
+ let hoverIdx = -1;
2909
+ for (let i = 0; i < count; i++) {
2910
+ const b = selected[i];
2911
+ if (b.x === hovered.x && b.y === hovered.y) {
2912
+ hoverIdx = i;
2913
+ break;
2914
+ }
2915
+ }
2916
+ if (hoverIdx < 0) {
2917
+ u.u_bounds.value[count].set(hovered.x, hovered.y, hovered.width, hovered.height);
2918
+ u.u_hoverIdx.value = count;
2919
+ } else u.u_hoverIdx.value = -1;
2920
+ } else u.u_hoverIdx.value = -1;
2921
+ if (count > 1) {
2922
+ let minX = Number.POSITIVE_INFINITY;
2923
+ let minY = Number.POSITIVE_INFINITY;
2924
+ let maxX = Number.NEGATIVE_INFINITY;
2925
+ let maxY = Number.NEGATIVE_INFINITY;
2926
+ for (let i = 0; i < count; i++) {
2927
+ const b = selected[i];
2928
+ minX = Math.min(minX, b.x);
2929
+ minY = Math.min(minY, b.y);
2930
+ maxX = Math.max(maxX, b.x + b.width);
2931
+ maxY = Math.max(maxY, b.y + b.height);
2932
+ }
2933
+ u.u_groupBounds.value.set(minX, minY, maxX - minX, maxY - minY);
2934
+ u.u_hasGroup.value = 1;
2935
+ } else u.u_hasGroup.value = 0;
2936
+ const prevAutoClear = renderer.autoClear;
2937
+ renderer.autoClear = false;
2938
+ renderer.render(this.scene, this.camera);
2939
+ renderer.autoClear = prevAutoClear;
2940
+ }
2941
+ dispose() {
2942
+ this.mesh.geometry.dispose();
2943
+ this.material.dispose();
2944
+ }
2945
+ };
2946
+ //#endregion
2947
+ //#region src/webgl/renderers/SnapGuideRenderer.ts
2948
+ const DEFAULT_SNAP_GUIDE_CONFIG = {
2949
+ color: [
2950
+ 1,
2951
+ 0,
2952
+ .55
2953
+ ],
2954
+ lineWidth: .5,
2955
+ guideAlpha: .8,
2956
+ spacingAlpha: .7
2957
+ };
2958
+ const MAX_GUIDES = 16;
2959
+ const MAX_SPACINGS = 8;
2960
+ const vertexShader = `
2961
+ void main() {
2962
+ gl_Position = vec4(position.xy, 0.0, 1.0);
2963
+ }
2964
+ `;
2965
+ const fragmentShader = `
2966
+ precision highp float;
2967
+
2968
+ uniform vec2 u_resolution;
2969
+ uniform vec2 u_camera;
2970
+ uniform float u_zoom;
2971
+ uniform float u_dpr;
2972
+
2973
+ uniform int u_guideCount;
2974
+ uniform vec4 u_guides[${MAX_GUIDES}]; // (axis: 0=x/1=y, position, 0, 0)
2975
+ uniform int u_spacingCount;
2976
+ uniform vec4 u_spacings[${MAX_SPACINGS}]; // (axis, from, to, perpPos)
2977
+
2978
+ uniform vec3 u_color;
2979
+ uniform float u_lineWidth;
2980
+ uniform float u_guideAlpha;
2981
+ uniform float u_spacingAlpha;
2982
+
2983
+ void main() {
2984
+ if (u_guideCount == 0 && u_spacingCount == 0) discard;
2985
+
2986
+ vec2 devicePos = gl_FragCoord.xy;
2987
+ devicePos.y = u_resolution.y - devicePos.y;
2988
+
2989
+ float effectiveZoom = u_zoom * u_dpr;
2990
+ vec2 worldPos = devicePos / effectiveZoom + u_camera;
2991
+ float pxToWorld = 1.0 / effectiveZoom;
2992
+ float lineHalf = u_lineWidth * pxToWorld;
2993
+
2994
+ vec4 color = vec4(0.0);
2995
+
2996
+ // --- Snap guide lines (full-canvas axis alignment) ---
2997
+ for (int i = 0; i < ${MAX_GUIDES}; i++) {
2998
+ if (i >= u_guideCount) break;
2999
+ vec4 g = u_guides[i];
3000
+ float dist = g.x < 0.5 ? abs(worldPos.x - g.y) : abs(worldPos.y - g.y);
3001
+ float a = 1.0 - smoothstep(lineHalf - pxToWorld * 0.3, lineHalf + pxToWorld * 0.3, dist);
3002
+ color = max(color, vec4(u_color, a * u_guideAlpha));
3003
+ }
3004
+
3005
+ // --- Equal spacing indicators (segment + end-bars) ---
3006
+ for (int i = 0; i < ${MAX_SPACINGS}; i++) {
3007
+ if (i >= u_spacingCount) break;
3008
+ vec4 s = u_spacings[i];
3009
+ float segAlpha = 0.0;
3010
+ if (s.x < 0.5) {
3011
+ // Horizontal gap segment
3012
+ float yDist = abs(worldPos.y - s.w);
3013
+ float xInRange = step(s.y, worldPos.x) * step(worldPos.x, s.z);
3014
+ segAlpha = (1.0 - smoothstep(lineHalf, lineHalf + pxToWorld, yDist)) * xInRange;
3015
+ float barHeight = 4.0 * pxToWorld;
3016
+ float barFrom = (1.0 - smoothstep(lineHalf, lineHalf + pxToWorld, abs(worldPos.x - s.y)))
3017
+ * (1.0 - smoothstep(barHeight, barHeight + pxToWorld, abs(worldPos.y - s.w)));
3018
+ float barTo = (1.0 - smoothstep(lineHalf, lineHalf + pxToWorld, abs(worldPos.x - s.z)))
3019
+ * (1.0 - smoothstep(barHeight, barHeight + pxToWorld, abs(worldPos.y - s.w)));
3020
+ segAlpha = max(segAlpha, max(barFrom, barTo));
3021
+ } else {
3022
+ // Vertical gap segment
3023
+ float xDist = abs(worldPos.x - s.w);
3024
+ float yInRange = step(s.y, worldPos.y) * step(worldPos.y, s.z);
3025
+ segAlpha = (1.0 - smoothstep(lineHalf, lineHalf + pxToWorld, xDist)) * yInRange;
3026
+ float barWidth = 4.0 * pxToWorld;
3027
+ float barFrom = (1.0 - smoothstep(lineHalf, lineHalf + pxToWorld, abs(worldPos.y - s.y)))
3028
+ * (1.0 - smoothstep(barWidth, barWidth + pxToWorld, abs(worldPos.x - s.w)));
3029
+ float barTo = (1.0 - smoothstep(lineHalf, lineHalf + pxToWorld, abs(worldPos.y - s.z)))
3030
+ * (1.0 - smoothstep(barWidth, barWidth + pxToWorld, abs(worldPos.x - s.w)));
3031
+ segAlpha = max(segAlpha, max(barFrom, barTo));
3032
+ }
3033
+ color = max(color, vec4(u_color, segAlpha * u_spacingAlpha));
3034
+ }
3035
+
3036
+ if (color.a < 0.01) discard;
3037
+ gl_FragColor = color;
3038
+ }
3039
+ `;
3040
+ /**
3041
+ * Renders alignment guide lines and equal-spacing indicators in a single
3042
+ * SDF pass. Independent of {@link SelectionRenderer} — guides participate
3043
+ * regardless of whether anything is selected, and the dragged entity may
3044
+ * have its own chrome that opts out of the engine-drawn selection frame.
3045
+ */
3046
+ var SnapGuideRenderer = class {
3047
+ material;
3048
+ mesh;
3049
+ scene;
3050
+ camera;
3051
+ constructor() {
3052
+ this.scene = new THREE.Scene();
3053
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
3054
+ this.material = new THREE.ShaderMaterial({
3055
+ vertexShader,
3056
+ fragmentShader,
3057
+ uniforms: {
3058
+ u_resolution: { value: new THREE.Vector2(1, 1) },
3059
+ u_camera: { value: new THREE.Vector2(0, 0) },
3060
+ u_zoom: { value: 1 },
3061
+ u_dpr: { value: 1 },
3062
+ u_guideCount: { value: 0 },
3063
+ u_guides: { value: Array.from({ length: MAX_GUIDES }, () => new THREE.Vector4(0, 0, 0, 0)) },
3064
+ u_spacingCount: { value: 0 },
3065
+ u_spacings: { value: Array.from({ length: MAX_SPACINGS }, () => new THREE.Vector4(0, 0, 0, 0)) },
3066
+ u_color: { value: new THREE.Vector3(...DEFAULT_SNAP_GUIDE_CONFIG.color) },
3067
+ u_lineWidth: { value: DEFAULT_SNAP_GUIDE_CONFIG.lineWidth },
3068
+ u_guideAlpha: { value: DEFAULT_SNAP_GUIDE_CONFIG.guideAlpha },
3069
+ u_spacingAlpha: { value: DEFAULT_SNAP_GUIDE_CONFIG.spacingAlpha }
3070
+ },
3071
+ transparent: true,
3072
+ depthTest: false,
3073
+ depthWrite: false
3074
+ });
3075
+ const geometry = new THREE.BufferGeometry();
3076
+ const vertices = new Float32Array([
3077
+ -1,
3078
+ -1,
3079
+ 0,
3080
+ 3,
3081
+ -1,
3082
+ 0,
3083
+ -1,
3084
+ 3,
3085
+ 0
3086
+ ]);
3087
+ geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
3088
+ this.mesh = new THREE.Mesh(geometry, this.material);
3089
+ this.scene.add(this.mesh);
3090
+ }
3091
+ setConfig(config) {
3092
+ const u = this.material.uniforms;
3093
+ if (config.color) u.u_color.value.set(...config.color);
3094
+ if (config.lineWidth !== void 0) u.u_lineWidth.value = config.lineWidth;
3095
+ if (config.guideAlpha !== void 0) u.u_guideAlpha.value = config.guideAlpha;
3096
+ if (config.spacingAlpha !== void 0) u.u_spacingAlpha.value = config.spacingAlpha;
3097
+ }
3098
+ setSize(resolution, dpr) {
3099
+ this.material.uniforms.u_resolution.value.copy(resolution);
3100
+ this.material.uniforms.u_dpr.value = dpr;
3101
+ }
3102
+ render(renderer, cameraX, cameraY, zoom, guides, spacings) {
3103
+ const u = this.material.uniforms;
3104
+ u.u_camera.value.set(cameraX, cameraY);
3105
+ u.u_zoom.value = zoom;
3106
+ const gCount = Math.min(guides.length, MAX_GUIDES);
3107
+ u.u_guideCount.value = gCount;
3108
+ for (let i = 0; i < gCount; i++) {
3109
+ const g = guides[i];
3110
+ u.u_guides.value[i].set(g.axis === "x" ? 0 : 1, g.position, 0, 0);
3111
+ }
3112
+ let sIdx = 0;
3113
+ for (const sp of spacings) for (const seg of sp.segments) {
3114
+ if (sIdx >= MAX_SPACINGS) break;
3115
+ u.u_spacings.value[sIdx].set(sp.axis === "x" ? 0 : 1, seg.from, seg.to, sp.perpPosition);
3116
+ sIdx++;
3117
+ }
3118
+ u.u_spacingCount.value = sIdx;
3119
+ const prevAutoClear = renderer.autoClear;
3120
+ renderer.autoClear = false;
3121
+ renderer.render(this.scene, this.camera);
3122
+ renderer.autoClear = prevAutoClear;
3123
+ }
3124
+ dispose() {
3125
+ this.mesh.geometry.dispose();
3126
+ this.material.dispose();
3127
+ }
3128
+ };
3129
+ //#endregion
3130
+ //#region src/webgl/WebGLManager.ts
3131
+ /**
3132
+ * Top-level coordinator for the library's vanilla-WebGL layer.
3133
+ *
3134
+ * Owns a single `THREE.WebGLRenderer` and drives the built-in renderers
3135
+ * (dot grid, selection overlay, snap guides) through it. This replaces
3136
+ * the previous pattern where `GridRenderer` owned the renderer and
3137
+ * `SelectionRenderer` piggy-backed on it via an implicit side-effect —
3138
+ * a manager makes the sharing explicit and gives {@link InfiniteCanvas}
3139
+ * a single surface to talk to instead of three.
3140
+ */
3141
+ var WebGLManager = class {
3142
+ renderer;
3143
+ grid = null;
3144
+ selection;
3145
+ snapGuides;
3146
+ constructor(canvas, opts = {}) {
3147
+ this.renderer = new THREE.WebGLRenderer({
3148
+ canvas,
3149
+ alpha: true,
3150
+ antialias: false,
3151
+ premultipliedAlpha: false
3152
+ });
3153
+ this.renderer.setClearColor(0, 0);
3154
+ this.renderer.info.autoReset = false;
3155
+ if (opts.grid !== false) {
3156
+ this.grid = new GridRenderer();
3157
+ if (opts.grid) this.grid.setConfig(opts.grid);
3158
+ }
3159
+ this.selection = new SelectionRenderer();
3160
+ if (opts.selection) this.selection.setConfig(opts.selection);
3161
+ this.snapGuides = new SnapGuideRenderer();
3162
+ if (opts.snapGuides) this.snapGuides.setConfig(opts.snapGuides);
3163
+ }
3164
+ /** Resize the drawing buffer. Call on mount and ResizeObserver events. */
3165
+ setSize(width, height, dpr = 1) {
3166
+ this.renderer.setSize(width, height, false);
3167
+ this.renderer.setPixelRatio(dpr);
3168
+ const resolution = new THREE.Vector2(width * dpr, height * dpr);
3169
+ this.grid?.setSize(width, height, dpr);
3170
+ this.selection.setSize(resolution, dpr);
3171
+ this.snapGuides.setSize(resolution, dpr);
3172
+ }
3173
+ /** Update grid visuals (colors, spacings, fade ranges, etc.). */
3174
+ setGridConfig(config) {
3175
+ this.grid?.setConfig(config);
3176
+ }
3177
+ /** Update selection overlay visuals (outline color, handle size, etc.). */
3178
+ setSelectionConfig(config) {
3179
+ this.selection.setConfig(config);
3180
+ }
3181
+ /** Update snap-guide visuals (color, line width, alpha). */
3182
+ setSnapGuideConfig(config) {
3183
+ this.snapGuides.setConfig(config);
3184
+ }
3185
+ /** Render one frame: grid → selection → snap guides, in stacking order. */
3186
+ render(input) {
3187
+ const { camera, selection, snap, profiler } = input;
3188
+ this.renderer.info.reset();
3189
+ if (this.grid) {
3190
+ profiler?.beginWebGL("grid");
3191
+ this.grid.render(this.renderer, camera.x, camera.y, camera.zoom);
3192
+ profiler?.endWebGL("grid");
3193
+ }
3194
+ profiler?.beginWebGL("selection");
3195
+ this.selection.render(this.renderer, camera.x, camera.y, camera.zoom, selection.bounds, selection.hovered);
3196
+ profiler?.endWebGL("selection");
3197
+ if (snap.visible) {
3198
+ profiler?.beginWebGL("snap-guides");
3199
+ this.snapGuides.render(this.renderer, camera.x, camera.y, camera.zoom, snap.guides, snap.spacings);
3200
+ profiler?.endWebGL("snap-guides");
3201
+ }
3202
+ if (profiler?.isEnabled()) {
3203
+ const info = this.renderer.info;
3204
+ profiler.recordWebGLStats({
3205
+ drawCalls: info.render.calls,
3206
+ triangles: info.render.triangles,
3207
+ selectionFrames: selection.bounds.length + (selection.hovered ? 1 : 0),
3208
+ snapGuides: snap.guides.length,
3209
+ spacingIndicators: snap.spacings.length,
3210
+ domPositionsUpdated: input.domPositionsUpdated ?? 0
3211
+ });
3212
+ }
3213
+ }
3214
+ /** Release GL resources — call on unmount. */
3215
+ dispose() {
3216
+ this.grid?.dispose();
3217
+ this.selection.dispose();
3218
+ this.snapGuides.dispose();
3219
+ this.renderer.dispose();
3220
+ }
3221
+ /** Escape hatch if an advanced consumer needs the underlying three renderer. */
3222
+ getWebGLRenderer() {
3223
+ return this.renderer;
3224
+ }
3225
+ };
3226
+ //#endregion
3227
+ //#region src/react/widgets/CardChrome.tsx
3228
+ /**
3229
+ * iOS-style card chrome — rounded background, hairline ring, soft drop
3230
+ * shadow, and a smooth lift (scale + stronger shadow) when `lifted` is
3231
+ * true. A single soft radial-gradient glow renders at the hot point
3232
+ * during overlap (no rim, no bloom, no backdrop-filter). Glow color,
3233
+ * alpha, and falloff are tunable via the `--ic-glow-*` CSS vars set by
3234
+ * `<InfiniteCanvas overlapGlow={…}>`.
3235
+ *
3236
+ * Pure presentational component with no ECS or compositor coupling. Used
3237
+ * by both the DOM `createCardWidget` (wrapping inner content) and the R3F
3238
+ * `createGeometryCardWidget` (rendered via a DOM slot beneath the WebGL
3239
+ * canvas, with the 3D content floating on top).
3240
+ */
3241
+ function CardChrome({ lifted = false, radius = 21.67, background, className, style, overlapCandidate = false, overlapTarget = false, hotX = .5, hotY = .5, hotStrength = 0, children }) {
3242
+ const baseShadow = lifted ? "0 30px 60px rgba(0,0,0,0.22), 0 0 0 1px rgba(0,0,0,0.06)" : "0 20px 40px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05)";
3243
+ const baseStyle = {
3244
+ position: "relative",
3245
+ width: "100%",
3246
+ height: "100%",
3247
+ borderRadius: `${radius}px`,
3248
+ overflow: "hidden",
3249
+ background,
3250
+ boxShadow: baseShadow,
3251
+ transform: lifted ? "scale(1.05)" : "scale(1)",
3252
+ transformOrigin: "center center",
3253
+ transition: "transform 180ms cubic-bezier(0.2, 0.9, 0.3, 1.2), box-shadow 220ms ease",
3254
+ willChange: lifted ? "transform, box-shadow" : void 0,
3255
+ ...style
3256
+ };
3257
+ const hotXPct = hotX * 100;
3258
+ const hotYPct = hotY * 100;
3259
+ const glowStyle = {
3260
+ position: "absolute",
3261
+ inset: 0,
3262
+ pointerEvents: "none",
3263
+ borderRadius: "inherit",
3264
+ boxShadow: `inset ${-(hotX - .5) * 16}px ${-(hotY - .5) * 16}px ${overlapTarget ? "var(--ic-glow-size-t, 80px)" : "var(--ic-glow-size-c, 60px)"} rgba(var(--ic-glow-color, 128, 128, 128), ${overlapTarget ? "var(--ic-glow-alpha-t, 0.45)" : "var(--ic-glow-alpha-c, 0.25)"})`,
3265
+ opacity: overlapCandidate ? hotStrength : 0,
3266
+ transition: "opacity 220ms ease, box-shadow 220ms ease"
3267
+ };
3268
+ const rimStyle = {
3269
+ position: "absolute",
3270
+ inset: 0,
3271
+ pointerEvents: "none",
3272
+ borderRadius: "inherit",
3273
+ padding: "var(--ic-rim-width, 1.5px)",
3274
+ background: `radial-gradient(var(--ic-rim-radius, 600px) circle at ${hotXPct}% ${hotYPct}%, ${`rgba(var(--ic-rim-color, 128, 128, 128), ${overlapTarget ? "var(--ic-rim-alpha-t, 0.85)" : "var(--ic-rim-alpha-c, 0.55)"})`}, transparent 40%)`,
3275
+ WebkitMask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
3276
+ WebkitMaskComposite: "xor",
3277
+ mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
3278
+ maskComposite: "exclude",
3279
+ opacity: overlapCandidate ? hotStrength : 0,
3280
+ transition: "opacity 220ms ease, background 220ms ease"
3281
+ };
3282
+ return /* @__PURE__ */ jsxs("div", {
3283
+ className,
3284
+ style: baseStyle,
3285
+ "data-overlap-candidate": overlapCandidate || void 0,
3286
+ "data-overlap-target": overlapTarget || void 0,
3287
+ children: [
3288
+ children,
3289
+ /* @__PURE__ */ jsx("div", {
3290
+ "aria-hidden": true,
3291
+ style: glowStyle
3292
+ }),
3293
+ /* @__PURE__ */ jsx("div", {
3294
+ "aria-hidden": true,
3295
+ style: rimStyle
3296
+ })
3297
+ ]
3298
+ });
3299
+ }
3300
+ //#endregion
3301
+ //#region src/react/overlays/SelectionOverlaySlot.tsx
3302
+ /**
3303
+ * DOM chrome overlay for R3F widgets — renders the selection frame /
3304
+ * card decoration and positions itself at the widget's world AABB.
3305
+ *
3306
+ * Decoration only. Pointer events bypass this wrapper
3307
+ * (`pointer-events: none`) so they reach the R3F canvas underneath,
3308
+ * where the `EventRouter` raycasts the widget's local scene (RFC-006).
3309
+ * Engine semantics — drag, select, resize, double-click — are dispatched
3310
+ * by the canvas-level `PointerEventBus` after the widget's R3F handlers
3311
+ * have had a chance to call `event.stopPropagation()`.
3312
+ */
3313
+ const SelectionOverlaySlot = memo(function SelectionOverlaySlot({ entityId, slotRef }) {
3314
+ const wrapperRef = useRef(null);
3315
+ const engine = useLayoutEngine();
3316
+ const dragging = useTag(entityId, Dragging);
3317
+ const card = useComponent(entityId, Card);
3318
+ const overlapCandidate = useTag(entityId, OverlapCandidate);
3319
+ const overlapTarget = useTag(entityId, OverlapTarget);
3320
+ const hot = useComponent(entityId, CardOverlapHotPoint);
3321
+ useEffect(() => {
3322
+ slotRef(entityId, wrapperRef.current);
3323
+ return () => slotRef(entityId, null);
3324
+ }, [entityId, slotRef]);
3325
+ const t = engine.get(entityId, Transform2D);
3326
+ return /* @__PURE__ */ jsx("div", {
3327
+ ref: wrapperRef,
3328
+ className: "pointer-events-none absolute left-0 top-0 origin-top-left will-change-transform",
3329
+ "data-widget-slot": "",
3330
+ style: t ? {
3331
+ transform: `translate(${t.x}px, ${t.y}px)`,
3332
+ width: `${t.width}px`,
3333
+ height: `${t.height}px`
3334
+ } : {},
3335
+ children: card && /* @__PURE__ */ jsx(CardChrome, {
3336
+ lifted: dragging,
3337
+ background: card.background,
3338
+ overlapCandidate,
3339
+ overlapTarget,
3340
+ hotX: hot?.x,
3341
+ hotY: hot?.y,
3342
+ hotStrength: hot?.strength
3343
+ })
3344
+ });
3345
+ });
3346
+ //#endregion
3347
+ //#region src/react/widgets/WidgetSlot.tsx
3348
+ /**
3349
+ * Wrapper for a DOM widget — owns the slot's positioning, registers its
3350
+ * ref with the rAF batcher, and renders the user's widget component.
3351
+ *
3352
+ * Pointer routing lives in the canvas-level `PointerEventBus` (RFC-006).
3353
+ * The slot does not call the engine; user widget React handlers run on
3354
+ * the natural DOM event path and bubble to the bus, which decides
3355
+ * whether to invoke engine semantics. Authors call `e.stopPropagation()`
3356
+ * from inside their widget to opt out of engine drag/select.
3357
+ */
3358
+ const WidgetSlot = memo(function WidgetSlot({ entityId, slotRef }) {
3359
+ const wrapperRef = useRef(null);
3360
+ const engine = useLayoutEngine();
3361
+ const resolve = useWidgetResolver();
3362
+ const widgetComp = useComponent(entityId, Widget);
3363
+ const resolved = resolve?.(entityId, widgetComp?.type ?? "");
3364
+ const WidgetComponent = resolved && resolved.surface === "dom" ? resolved.component : null;
3365
+ useEffect(() => {
3366
+ slotRef(entityId, wrapperRef.current);
3367
+ return () => slotRef(entityId, null);
3368
+ }, [entityId, slotRef]);
3369
+ const t = engine.get(entityId, Transform2D);
3370
+ return /* @__PURE__ */ jsx("div", {
3371
+ ref: wrapperRef,
3372
+ "data-widget-slot": "",
3373
+ className: "absolute left-0 top-0 origin-top-left will-change-transform",
3374
+ style: t ? {
3375
+ transform: `translate(${t.x}px, ${t.y}px)`,
3376
+ width: `${t.width}px`,
3377
+ height: `${t.height}px`
3378
+ } : {},
3379
+ children: WidgetComponent ? /* @__PURE__ */ jsx(WidgetComponent, { entityId }) : /* @__PURE__ */ jsx("div", { className: "h-full w-full rounded border border-dashed border-gray-300 bg-gray-50" })
3380
+ });
3381
+ });
3382
+ //#endregion
3383
+ //#region src/r3f/compositor/hooks.ts
3384
+ /**
3385
+ * Marks the current R3F widget as actively animating. While `active` is true,
3386
+ * the state machine places the widget in `Hot`; when false, it returns to
3387
+ * `Warm` on the next frame.
3388
+ *
3389
+ * Widgets should call this whenever they want per-frame ticking (e.g. during
3390
+ * a spring settle, hover lerp, or an external animation). Without this
3391
+ * signal, `useFrame` bodies may still fire when the canvas re-renders for
3392
+ * other reasons — check `useWidgetPhase() === 'Hot'` to early-exit work
3393
+ * that's only meaningful during the animation.
3394
+ */
3395
+ function useWidgetAnimation(entityId, active) {
3396
+ const engine = useLayoutEngine();
3397
+ useEffect(() => {
3398
+ if (active) {
3399
+ engine.world.addTag(entityId, R3FAnimationSignal);
3400
+ engine.markDirty();
3401
+ return () => {
3402
+ engine.world.removeTag(entityId, R3FAnimationSignal);
3403
+ engine.markDirty();
3404
+ };
3405
+ }
3406
+ engine.world.removeTag(entityId, R3FAnimationSignal);
3407
+ engine.markDirty();
3408
+ }, [
3409
+ engine,
3410
+ entityId,
3411
+ active
3412
+ ]);
3413
+ }
3414
+ /**
3415
+ * Returns the current compositor phase for the widget. Re-renders when the
3416
+ * phase changes.
3417
+ */
3418
+ function useWidgetPhase(entityId) {
3419
+ return useComponent(entityId, R3FRenderState)?.phase ?? null;
3420
+ }
3421
+ /**
3422
+ * Returns a function that schedules a one-shot repaint of the widget. Use
3423
+ * when widget content changes outside of React's render cycle (e.g., a
3424
+ * subscription to an external store, an imperative WebSocket message).
3425
+ *
3426
+ * Internally bumps `paintGeneration` so the compositor's dirty check picks
3427
+ * the widget up on the next frame, and invalidates the canvas so that
3428
+ * frame is actually scheduled.
3429
+ */
3430
+ function useWidgetInvalidate(entityId) {
3431
+ const engine = useLayoutEngine();
3432
+ const invalidate = useThree((s) => s.invalidate);
3433
+ return useCallback(() => {
3434
+ const current = engine.world.getComponent(entityId, R3FRenderState);
3435
+ if (!current) {
3436
+ invalidate();
3437
+ return;
3438
+ }
3439
+ engine.world.setComponent(entityId, R3FRenderState, {
3440
+ ...current,
3441
+ paintGeneration: current.paintGeneration + 1
3442
+ });
3443
+ invalidate();
3444
+ }, [
3445
+ engine,
3446
+ entityId,
3447
+ invalidate
3448
+ ]);
3449
+ }
3450
+ /**
3451
+ * Acquires a shared geometry from the Compositor's `ResourceRegistry`,
3452
+ * keyed by `cacheKey`. The factory runs only on first acquisition; later
3453
+ * callers with the same key get the same instance. Released automatically
3454
+ * on unmount; the registry disposes when the last holder releases.
3455
+ *
3456
+ * Use for geometries that are expensive to build and frequently identical
3457
+ * across widget instances — e.g. preset card backs.
3458
+ */
3459
+ function useSharedGeometry(cacheKey, factory) {
3460
+ const { registry } = useCompositor();
3461
+ const factoryRef = useRef(factory);
3462
+ factoryRef.current = factory;
3463
+ const geometry = useMemo(() => registry.acquireGeometry(cacheKey, factoryRef.current), [registry, cacheKey]);
3464
+ useEffect(() => {
3465
+ return () => registry.releaseGeometry(cacheKey);
3466
+ }, [registry, cacheKey]);
3467
+ return geometry;
3468
+ }
3469
+ /** Same contract as {@link useSharedGeometry}, for materials. */
3470
+ function useSharedMaterial(cacheKey, factory) {
3471
+ const { registry } = useCompositor();
3472
+ const factoryRef = useRef(factory);
3473
+ factoryRef.current = factory;
3474
+ const material = useMemo(() => registry.acquireMaterial(cacheKey, factoryRef.current), [registry, cacheKey]);
3475
+ useEffect(() => {
3476
+ return () => registry.releaseMaterial(cacheKey);
3477
+ }, [registry, cacheKey]);
3478
+ return material;
3479
+ }
3480
+ /** Same contract as {@link useSharedGeometry}, for textures. */
3481
+ function useSharedTexture(cacheKey, factory) {
3482
+ const { registry } = useCompositor();
3483
+ const factoryRef = useRef(factory);
3484
+ factoryRef.current = factory;
3485
+ const texture = useMemo(() => registry.acquireTexture(cacheKey, factoryRef.current), [registry, cacheKey]);
3486
+ useEffect(() => {
3487
+ return () => registry.releaseTexture(cacheKey);
3488
+ }, [registry, cacheKey]);
3489
+ return texture;
3490
+ }
3491
+ //#endregion
3492
+ export { useCompositor as A, Profiler as B, selectBand as C, R3FRenderState as D, R3FRenderBudget as E, useWidgetResolver as F, ContainerRefProvider as I, useContainerRef as L, inputGroupStart as M, inputLog as N, ResourceRegistry as O, WidgetResolverProvider as P, computeSnapGuides as R, isOutOfBand as S, R3FAnimationSignal as T, ProfilerProbe as _, useWidgetInvalidate as a, Compositor as b, SelectionOverlaySlot as c, DEFAULT_SNAP_GUIDE_CONFIG as d, DEFAULT_SELECTION_CONFIG as f, R3FManager as g, GridRenderer as h, useWidgetAnimation as i, sharedGlowUniforms as j, CompositorContext as k, CardChrome as l, DEFAULT_GRID_CONFIG as m, useSharedMaterial as n, useWidgetPhase as o, SelectionRenderer as p, useSharedTexture as r, WidgetSlot as s, useSharedGeometry as t, WebGLManager as u, WidgetStateMachine as v, WidgetRenderTargetPool as w, ZOOM_BANDS as x, VirtualWidget as y, SpatialIndex as z };
3493
+
3494
+ //# sourceMappingURL=hooks-gsQDDE56.mjs.map