@holoscript/core 1.0.0-alpha.2 → 2.0.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 (74) hide show
  1. package/package.json +2 -2
  2. package/src/HoloScript2DParser.js +227 -0
  3. package/src/HoloScript2DParser.ts +5 -0
  4. package/src/HoloScriptCodeParser.js +1102 -0
  5. package/src/HoloScriptCodeParser.ts +145 -20
  6. package/src/HoloScriptDebugger.js +458 -0
  7. package/src/HoloScriptParser.js +338 -0
  8. package/src/HoloScriptPlusParser.js +371 -0
  9. package/src/HoloScriptPlusParser.ts +543 -0
  10. package/src/HoloScriptRuntime.js +1399 -0
  11. package/src/HoloScriptRuntime.test.js +351 -0
  12. package/src/HoloScriptRuntime.ts +17 -3
  13. package/src/HoloScriptTypeChecker.js +356 -0
  14. package/src/__tests__/GraphicsServices.test.js +357 -0
  15. package/src/__tests__/GraphicsServices.test.ts +427 -0
  16. package/src/__tests__/HoloScriptPlusParser.test.js +317 -0
  17. package/src/__tests__/HoloScriptPlusParser.test.ts +392 -0
  18. package/src/__tests__/integration.test.js +336 -0
  19. package/src/__tests__/performance.bench.js +218 -0
  20. package/src/__tests__/type-checker.test.js +60 -0
  21. package/src/__tests__/type-checker.test.ts +73 -0
  22. package/src/index.js +217 -0
  23. package/src/index.ts +158 -18
  24. package/src/interop/Interoperability.js +413 -0
  25. package/src/interop/Interoperability.ts +494 -0
  26. package/src/logger.js +42 -0
  27. package/src/parser/EnhancedParser.js +205 -0
  28. package/src/parser/EnhancedParser.ts +251 -0
  29. package/src/parser/HoloScriptPlusParser.js +928 -0
  30. package/src/parser/HoloScriptPlusParser.ts +1089 -0
  31. package/src/runtime/HoloScriptPlusRuntime.js +674 -0
  32. package/src/runtime/HoloScriptPlusRuntime.ts +861 -0
  33. package/src/runtime/PerformanceTelemetry.js +323 -0
  34. package/src/runtime/PerformanceTelemetry.ts +467 -0
  35. package/src/runtime/RuntimeOptimization.js +361 -0
  36. package/src/runtime/RuntimeOptimization.ts +416 -0
  37. package/src/services/HololandGraphicsPipelineService.js +506 -0
  38. package/src/services/HololandGraphicsPipelineService.ts +662 -0
  39. package/src/services/PlatformPerformanceOptimizer.js +356 -0
  40. package/src/services/PlatformPerformanceOptimizer.ts +503 -0
  41. package/src/state/ReactiveState.js +427 -0
  42. package/src/state/ReactiveState.ts +572 -0
  43. package/src/tools/DeveloperExperience.js +376 -0
  44. package/src/tools/DeveloperExperience.ts +438 -0
  45. package/src/traits/AIDriverTrait.js +322 -0
  46. package/src/traits/AIDriverTrait.test.js +329 -0
  47. package/src/traits/AIDriverTrait.test.ts +357 -0
  48. package/src/traits/AIDriverTrait.ts +474 -0
  49. package/src/traits/LightingTrait.js +313 -0
  50. package/src/traits/LightingTrait.test.js +410 -0
  51. package/src/traits/LightingTrait.test.ts +462 -0
  52. package/src/traits/LightingTrait.ts +505 -0
  53. package/src/traits/MaterialTrait.js +194 -0
  54. package/src/traits/MaterialTrait.test.js +286 -0
  55. package/src/traits/MaterialTrait.test.ts +329 -0
  56. package/src/traits/MaterialTrait.ts +324 -0
  57. package/src/traits/RenderingTrait.js +356 -0
  58. package/src/traits/RenderingTrait.test.js +363 -0
  59. package/src/traits/RenderingTrait.test.ts +427 -0
  60. package/src/traits/RenderingTrait.ts +555 -0
  61. package/src/traits/VRTraitSystem.js +740 -0
  62. package/src/traits/VRTraitSystem.ts +1040 -0
  63. package/src/traits/VoiceInputTrait.js +284 -0
  64. package/src/traits/VoiceInputTrait.test.js +226 -0
  65. package/src/traits/VoiceInputTrait.test.ts +252 -0
  66. package/src/traits/VoiceInputTrait.ts +401 -0
  67. package/src/types/AdvancedTypeSystem.js +226 -0
  68. package/src/types/AdvancedTypeSystem.ts +494 -0
  69. package/src/types/HoloScriptPlus.d.ts +853 -0
  70. package/src/types.js +6 -0
  71. package/src/types.ts +96 -1
  72. package/tsconfig.json +1 -1
  73. package/tsup.config.d.ts +2 -0
  74. package/tsup.config.js +18 -0
@@ -0,0 +1,740 @@
1
+ /**
2
+ * VR Trait System
3
+ *
4
+ * Implements VR interaction traits for HoloScript+ objects:
5
+ * - @grabbable - Hand grab interactions
6
+ * - @throwable - Physics-based throwing
7
+ * - @pointable - Laser pointer interactions
8
+ * - @hoverable - Hover state and highlights
9
+ * - @scalable - Two-handed scaling
10
+ * - @rotatable - Rotation interactions
11
+ * - @stackable - Stacking behavior
12
+ * - @snappable - Snap-to-point behavior
13
+ * - @breakable - Destruction on impact
14
+ *
15
+ * @version 1.0.0
16
+ */
17
+ // =============================================================================
18
+ // GRABBABLE TRAIT
19
+ // =============================================================================
20
+ const grabbableHandler = {
21
+ name: 'grabbable',
22
+ defaultConfig: {
23
+ snap_to_hand: true,
24
+ two_handed: false,
25
+ haptic_on_grab: 0.5,
26
+ grab_points: [],
27
+ preserve_rotation: false,
28
+ distance_grab: false,
29
+ max_grab_distance: 3,
30
+ },
31
+ onAttach(node, config, context) {
32
+ // Initialize grab state
33
+ const state = {
34
+ isGrabbed: false,
35
+ grabbingHand: null,
36
+ grabOffset: [0, 0, 0],
37
+ grabRotationOffset: [0, 0, 0],
38
+ previousHandPositions: [],
39
+ previousHandTimes: [],
40
+ };
41
+ node.__grabState = state;
42
+ },
43
+ onDetach(node) {
44
+ delete node.__grabState;
45
+ },
46
+ onUpdate(node, config, context, delta) {
47
+ const state = node.__grabState;
48
+ if (!state?.isGrabbed || !state.grabbingHand)
49
+ return;
50
+ // Follow hand position
51
+ const hand = state.grabbingHand;
52
+ const newPosition = config.snap_to_hand
53
+ ? hand.position
54
+ : [
55
+ hand.position[0] + state.grabOffset[0],
56
+ hand.position[1] + state.grabOffset[1],
57
+ hand.position[2] + state.grabOffset[2],
58
+ ];
59
+ // Update position
60
+ node.properties.position = newPosition;
61
+ // Track velocity for throw
62
+ state.previousHandPositions.push([...hand.position]);
63
+ state.previousHandTimes.push(Date.now());
64
+ // Keep last 10 frames
65
+ if (state.previousHandPositions.length > 10) {
66
+ state.previousHandPositions.shift();
67
+ state.previousHandTimes.shift();
68
+ }
69
+ // Update rotation if not preserving
70
+ if (!config.preserve_rotation) {
71
+ node.properties.rotation = hand.rotation;
72
+ }
73
+ },
74
+ onEvent(node, config, context, event) {
75
+ const state = node.__grabState;
76
+ if (event.type === 'grab_start') {
77
+ // Check distance for distance grab
78
+ if (!config.distance_grab) {
79
+ const handPos = event.hand.position;
80
+ const nodePos = node.properties.position || [0, 0, 0];
81
+ const distance = Math.sqrt(Math.pow(handPos[0] - nodePos[0], 2) +
82
+ Math.pow(handPos[1] - nodePos[1], 2) +
83
+ Math.pow(handPos[2] - nodePos[2], 2));
84
+ if (distance > (config.max_grab_distance || 3))
85
+ return;
86
+ }
87
+ state.isGrabbed = true;
88
+ state.grabbingHand = event.hand;
89
+ // Calculate grab offset
90
+ const nodePos = node.properties.position || [0, 0, 0];
91
+ state.grabOffset = [
92
+ nodePos[0] - event.hand.position[0],
93
+ nodePos[1] - event.hand.position[1],
94
+ nodePos[2] - event.hand.position[2],
95
+ ];
96
+ // Haptic feedback
97
+ if (config.haptic_on_grab) {
98
+ context.haptics.pulse(event.hand.id, config.haptic_on_grab);
99
+ }
100
+ // Make kinematic while grabbed
101
+ context.physics.setKinematic(node, true);
102
+ // Emit grab event
103
+ context.emit('grab', { node, hand: event.hand });
104
+ }
105
+ if (event.type === 'grab_end') {
106
+ state.isGrabbed = false;
107
+ state.grabbingHand = null;
108
+ // Re-enable physics
109
+ context.physics.setKinematic(node, false);
110
+ // Calculate throw velocity from tracked positions
111
+ if (state.previousHandPositions.length >= 2) {
112
+ const len = state.previousHandPositions.length;
113
+ const dt = (state.previousHandTimes[len - 1] - state.previousHandTimes[0]) / 1000;
114
+ if (dt > 0) {
115
+ const velocity = [
116
+ (state.previousHandPositions[len - 1][0] - state.previousHandPositions[0][0]) / dt,
117
+ (state.previousHandPositions[len - 1][1] - state.previousHandPositions[0][1]) / dt,
118
+ (state.previousHandPositions[len - 1][2] - state.previousHandPositions[0][2]) / dt,
119
+ ];
120
+ // Apply velocity if throwable trait exists
121
+ if (node.traits.has('throwable')) {
122
+ const throwConfig = node.traits.get('throwable');
123
+ const multiplier = throwConfig.velocity_multiplier || 1;
124
+ context.physics.applyVelocity(node, [
125
+ velocity[0] * multiplier,
126
+ velocity[1] * multiplier,
127
+ velocity[2] * multiplier,
128
+ ]);
129
+ }
130
+ }
131
+ }
132
+ // Clear tracking
133
+ state.previousHandPositions = [];
134
+ state.previousHandTimes = [];
135
+ // Emit release event
136
+ context.emit('release', { node, velocity: event.velocity });
137
+ }
138
+ },
139
+ };
140
+ // =============================================================================
141
+ // THROWABLE TRAIT
142
+ // =============================================================================
143
+ const throwableHandler = {
144
+ name: 'throwable',
145
+ defaultConfig: {
146
+ velocity_multiplier: 1,
147
+ gravity: true,
148
+ max_velocity: 50,
149
+ spin: true,
150
+ bounce: false,
151
+ bounce_factor: 0.5,
152
+ },
153
+ onAttach(node, config, context) {
154
+ // Throwable works with grabbable - just configures throw behavior
155
+ },
156
+ onEvent(node, config, context, event) {
157
+ if (event.type === 'collision' && config.bounce) {
158
+ const collision = event.data;
159
+ const bounceFactor = config.bounce_factor || 0.5;
160
+ // Reflect velocity
161
+ const velocity = collision.relativeVelocity;
162
+ const normal = collision.normal;
163
+ const dot = velocity[0] * normal[0] + velocity[1] * normal[1] + velocity[2] * normal[2];
164
+ const reflected = [
165
+ (velocity[0] - 2 * dot * normal[0]) * bounceFactor,
166
+ (velocity[1] - 2 * dot * normal[1]) * bounceFactor,
167
+ (velocity[2] - 2 * dot * normal[2]) * bounceFactor,
168
+ ];
169
+ context.physics.applyVelocity(node, reflected);
170
+ }
171
+ },
172
+ };
173
+ // =============================================================================
174
+ // POINTABLE TRAIT
175
+ // =============================================================================
176
+ const pointableHandler = {
177
+ name: 'pointable',
178
+ defaultConfig: {
179
+ highlight_on_point: true,
180
+ highlight_color: '#00ff00',
181
+ cursor_style: 'pointer',
182
+ },
183
+ onAttach(node, config, context) {
184
+ const state = {
185
+ isPointed: false,
186
+ pointingHand: null,
187
+ };
188
+ node.__pointState = state;
189
+ },
190
+ onDetach(node) {
191
+ delete node.__pointState;
192
+ },
193
+ onEvent(node, config, context, event) {
194
+ const state = node.__pointState;
195
+ if (event.type === 'point_enter') {
196
+ state.isPointed = true;
197
+ state.pointingHand = event.hand;
198
+ if (config.highlight_on_point) {
199
+ node.properties.__originalEmissive = node.properties.emissive;
200
+ node.properties.emissive = config.highlight_color;
201
+ }
202
+ context.emit('point_enter', { node, hand: event.hand });
203
+ }
204
+ if (event.type === 'point_exit') {
205
+ state.isPointed = false;
206
+ state.pointingHand = null;
207
+ if (config.highlight_on_point) {
208
+ node.properties.emissive = node.properties.__originalEmissive || null;
209
+ delete node.properties.__originalEmissive;
210
+ }
211
+ context.emit('point_exit', { node });
212
+ }
213
+ if (event.type === 'click') {
214
+ context.emit('click', { node, hand: event.hand });
215
+ }
216
+ },
217
+ };
218
+ // =============================================================================
219
+ // HOVERABLE TRAIT
220
+ // =============================================================================
221
+ const hoverableHandler = {
222
+ name: 'hoverable',
223
+ defaultConfig: {
224
+ highlight_color: '#ffffff',
225
+ scale_on_hover: 1.1,
226
+ show_tooltip: false,
227
+ tooltip_offset: [0, 0.2, 0],
228
+ glow: false,
229
+ glow_intensity: 0.5,
230
+ },
231
+ onAttach(node, config, context) {
232
+ const state = {
233
+ isHovered: false,
234
+ hoveringHand: null,
235
+ originalScale: typeof node.properties.scale === 'number' ? node.properties.scale : 1,
236
+ originalColor: null,
237
+ };
238
+ node.__hoverState = state;
239
+ },
240
+ onDetach(node) {
241
+ delete node.__hoverState;
242
+ },
243
+ onEvent(node, config, context, event) {
244
+ const state = node.__hoverState;
245
+ if (event.type === 'hover_enter') {
246
+ state.isHovered = true;
247
+ state.hoveringHand = event.hand;
248
+ // Scale up
249
+ if (config.scale_on_hover && config.scale_on_hover !== 1) {
250
+ state.originalScale = typeof node.properties.scale === 'number' ? node.properties.scale : 1;
251
+ node.properties.scale = state.originalScale * config.scale_on_hover;
252
+ }
253
+ // Glow effect
254
+ if (config.glow) {
255
+ state.originalColor = node.properties.emissive || null;
256
+ node.properties.emissive = config.highlight_color;
257
+ node.properties.emissiveIntensity = config.glow_intensity;
258
+ }
259
+ // Tooltip
260
+ if (config.show_tooltip) {
261
+ const tooltipText = typeof config.show_tooltip === 'string'
262
+ ? config.show_tooltip
263
+ : node.properties.tooltip || node.id || node.type;
264
+ context.emit('show_tooltip', {
265
+ node,
266
+ text: tooltipText,
267
+ offset: config.tooltip_offset,
268
+ });
269
+ }
270
+ context.emit('hover_enter', { node, hand: event.hand });
271
+ }
272
+ if (event.type === 'hover_exit') {
273
+ state.isHovered = false;
274
+ state.hoveringHand = null;
275
+ // Restore scale
276
+ if (config.scale_on_hover && config.scale_on_hover !== 1) {
277
+ node.properties.scale = state.originalScale;
278
+ }
279
+ // Remove glow
280
+ if (config.glow) {
281
+ node.properties.emissive = state.originalColor;
282
+ delete node.properties.emissiveIntensity;
283
+ }
284
+ // Hide tooltip
285
+ if (config.show_tooltip) {
286
+ context.emit('hide_tooltip', { node });
287
+ }
288
+ context.emit('hover_exit', { node });
289
+ }
290
+ },
291
+ };
292
+ // =============================================================================
293
+ // SCALABLE TRAIT
294
+ // =============================================================================
295
+ const scalableHandler = {
296
+ name: 'scalable',
297
+ defaultConfig: {
298
+ min_scale: 0.1,
299
+ max_scale: 10,
300
+ uniform: true,
301
+ pivot: [0, 0, 0],
302
+ },
303
+ onAttach(node, config, context) {
304
+ const state = {
305
+ isScaling: false,
306
+ initialDistance: 0,
307
+ initialScale: 1,
308
+ };
309
+ node.__scaleState = state;
310
+ },
311
+ onDetach(node) {
312
+ delete node.__scaleState;
313
+ },
314
+ onUpdate(node, config, context, delta) {
315
+ const state = node.__scaleState;
316
+ if (!state?.isScaling)
317
+ return;
318
+ const { hands } = context.vr;
319
+ if (!hands.left || !hands.right)
320
+ return;
321
+ // Calculate current distance between hands
322
+ const currentDistance = Math.sqrt(Math.pow(hands.right.position[0] - hands.left.position[0], 2) +
323
+ Math.pow(hands.right.position[1] - hands.left.position[1], 2) +
324
+ Math.pow(hands.right.position[2] - hands.left.position[2], 2));
325
+ // Calculate scale factor
326
+ const scaleFactor = currentDistance / state.initialDistance;
327
+ let newScale = state.initialScale * scaleFactor;
328
+ // Clamp scale
329
+ newScale = Math.max(config.min_scale || 0.1, Math.min(config.max_scale || 10, newScale));
330
+ node.properties.scale = newScale;
331
+ context.emit('scale_update', { node, scale: newScale });
332
+ },
333
+ onEvent(node, config, context, event) {
334
+ const state = node.__scaleState;
335
+ if (event.type === 'scale_start') {
336
+ state.isScaling = true;
337
+ state.initialScale = typeof node.properties.scale === 'number' ? node.properties.scale : 1;
338
+ // Calculate initial distance between hands
339
+ const { left, right } = event.hands;
340
+ state.initialDistance = Math.sqrt(Math.pow(right.position[0] - left.position[0], 2) +
341
+ Math.pow(right.position[1] - left.position[1], 2) +
342
+ Math.pow(right.position[2] - left.position[2], 2));
343
+ context.emit('scale_start', { node });
344
+ }
345
+ if (event.type === 'scale_end') {
346
+ state.isScaling = false;
347
+ context.emit('scale_end', { node, finalScale: node.properties.scale });
348
+ }
349
+ },
350
+ };
351
+ // =============================================================================
352
+ // ROTATABLE TRAIT
353
+ // =============================================================================
354
+ const rotatableHandler = {
355
+ name: 'rotatable',
356
+ defaultConfig: {
357
+ axis: 'all',
358
+ snap_angles: [],
359
+ speed: 1,
360
+ },
361
+ onAttach(node, config, context) {
362
+ const state = {
363
+ isRotating: false,
364
+ initialHandRotation: [0, 0, 0],
365
+ initialObjectRotation: [0, 0, 0],
366
+ };
367
+ node.__rotateState = state;
368
+ },
369
+ onDetach(node) {
370
+ delete node.__rotateState;
371
+ },
372
+ onUpdate(node, config, context, delta) {
373
+ const state = node.__rotateState;
374
+ if (!state?.isRotating)
375
+ return;
376
+ const hand = context.vr.getDominantHand();
377
+ if (!hand)
378
+ return;
379
+ // Calculate rotation delta
380
+ const deltaRotation = [
381
+ (hand.rotation[0] - state.initialHandRotation[0]) * (config.speed || 1),
382
+ (hand.rotation[1] - state.initialHandRotation[1]) * (config.speed || 1),
383
+ (hand.rotation[2] - state.initialHandRotation[2]) * (config.speed || 1),
384
+ ];
385
+ // Apply axis constraint
386
+ let newRotation;
387
+ switch (config.axis) {
388
+ case 'x':
389
+ newRotation = [
390
+ state.initialObjectRotation[0] + deltaRotation[0],
391
+ state.initialObjectRotation[1],
392
+ state.initialObjectRotation[2],
393
+ ];
394
+ break;
395
+ case 'y':
396
+ newRotation = [
397
+ state.initialObjectRotation[0],
398
+ state.initialObjectRotation[1] + deltaRotation[1],
399
+ state.initialObjectRotation[2],
400
+ ];
401
+ break;
402
+ case 'z':
403
+ newRotation = [
404
+ state.initialObjectRotation[0],
405
+ state.initialObjectRotation[1],
406
+ state.initialObjectRotation[2] + deltaRotation[2],
407
+ ];
408
+ break;
409
+ default:
410
+ newRotation = [
411
+ state.initialObjectRotation[0] + deltaRotation[0],
412
+ state.initialObjectRotation[1] + deltaRotation[1],
413
+ state.initialObjectRotation[2] + deltaRotation[2],
414
+ ];
415
+ }
416
+ // Snap to angles if configured
417
+ if (config.snap_angles && config.snap_angles.length > 0) {
418
+ newRotation = newRotation.map((angle) => {
419
+ let closest = config.snap_angles[0];
420
+ let minDiff = Math.abs(angle - closest);
421
+ for (const snapAngle of config.snap_angles) {
422
+ const diff = Math.abs(angle - snapAngle);
423
+ if (diff < minDiff) {
424
+ minDiff = diff;
425
+ closest = snapAngle;
426
+ }
427
+ }
428
+ // Only snap if close enough
429
+ return minDiff < 10 ? closest : angle;
430
+ });
431
+ }
432
+ node.properties.rotation = newRotation;
433
+ context.emit('rotate_update', { node, rotation: newRotation });
434
+ },
435
+ onEvent(node, config, context, event) {
436
+ const state = node.__rotateState;
437
+ if (event.type === 'rotate_start') {
438
+ state.isRotating = true;
439
+ state.initialHandRotation = [...event.hand.rotation];
440
+ state.initialObjectRotation = node.properties.rotation || [0, 0, 0];
441
+ context.emit('rotate_start', { node });
442
+ }
443
+ if (event.type === 'rotate_end') {
444
+ state.isRotating = false;
445
+ context.emit('rotate_end', { node, finalRotation: node.properties.rotation });
446
+ }
447
+ },
448
+ };
449
+ // =============================================================================
450
+ // STACKABLE TRAIT
451
+ // =============================================================================
452
+ const stackableHandler = {
453
+ name: 'stackable',
454
+ defaultConfig: {
455
+ stack_axis: 'y',
456
+ stack_offset: 0,
457
+ max_stack: 10,
458
+ snap_distance: 0.5,
459
+ },
460
+ onAttach(node, config, context) {
461
+ const state = {
462
+ stackedItems: [],
463
+ stackParent: null,
464
+ };
465
+ node.__stackState = state;
466
+ },
467
+ onDetach(node) {
468
+ const state = node.__stackState;
469
+ // Remove from parent stack
470
+ if (state.stackParent) {
471
+ const parentState = state.stackParent.__stackState;
472
+ const index = parentState.stackedItems.indexOf(node);
473
+ if (index > -1) {
474
+ parentState.stackedItems.splice(index, 1);
475
+ }
476
+ }
477
+ // Clear children
478
+ state.stackedItems = [];
479
+ delete node.__stackState;
480
+ },
481
+ onEvent(node, config, context, event) {
482
+ const state = node.__stackState;
483
+ if (event.type === 'collision' || event.type === 'trigger_enter') {
484
+ const other = event.type === 'collision' ? event.data.target : event.other;
485
+ // Check if other is stackable
486
+ if (!other.traits.has('stackable'))
487
+ return;
488
+ const otherState = other.__stackState;
489
+ if (!otherState)
490
+ return;
491
+ // Check stack limit
492
+ if (state.stackedItems.length >= (config.max_stack || 10))
493
+ return;
494
+ // Check if close enough
495
+ const nodePos = node.properties.position || [0, 0, 0];
496
+ const otherPos = other.properties.position || [0, 0, 0];
497
+ const axisIndex = config.stack_axis === 'x' ? 0 : config.stack_axis === 'z' ? 2 : 1;
498
+ const otherAxes = [0, 1, 2].filter((i) => i !== axisIndex);
499
+ // Check alignment on other axes
500
+ let aligned = true;
501
+ for (const axis of otherAxes) {
502
+ if (Math.abs(nodePos[axis] - otherPos[axis]) > (config.snap_distance || 0.5)) {
503
+ aligned = false;
504
+ break;
505
+ }
506
+ }
507
+ if (aligned && otherPos[axisIndex] > nodePos[axisIndex]) {
508
+ // Other is above - add to stack
509
+ state.stackedItems.push(other);
510
+ otherState.stackParent = node;
511
+ // Snap position
512
+ const stackOffset = config.stack_offset || 0;
513
+ const newPos = [...nodePos];
514
+ newPos[axisIndex] = nodePos[axisIndex] + stackOffset;
515
+ other.properties.position = newPos;
516
+ context.emit('stack', { parent: node, child: other });
517
+ }
518
+ }
519
+ },
520
+ };
521
+ // =============================================================================
522
+ // SNAPPABLE TRAIT
523
+ // =============================================================================
524
+ const snappableHandler = {
525
+ name: 'snappable',
526
+ defaultConfig: {
527
+ snap_points: [],
528
+ snap_distance: 0.3,
529
+ snap_rotation: false,
530
+ magnetic: false,
531
+ },
532
+ onUpdate(node, config, context, delta) {
533
+ if (!config.snap_points || config.snap_points.length === 0)
534
+ return;
535
+ if (!config.magnetic)
536
+ return;
537
+ const nodePos = node.properties.position || [0, 0, 0];
538
+ // Find closest snap point
539
+ let closestPoint = null;
540
+ let closestDistance = config.snap_distance || 0.3;
541
+ for (const snapPoint of config.snap_points) {
542
+ const distance = Math.sqrt(Math.pow(nodePos[0] - snapPoint[0], 2) +
543
+ Math.pow(nodePos[1] - snapPoint[1], 2) +
544
+ Math.pow(nodePos[2] - snapPoint[2], 2));
545
+ if (distance < closestDistance) {
546
+ closestDistance = distance;
547
+ closestPoint = snapPoint;
548
+ }
549
+ }
550
+ // Apply magnetic pull
551
+ if (closestPoint) {
552
+ const pullStrength = 0.1;
553
+ node.properties.position = [
554
+ nodePos[0] + (closestPoint[0] - nodePos[0]) * pullStrength,
555
+ nodePos[1] + (closestPoint[1] - nodePos[1]) * pullStrength,
556
+ nodePos[2] + (closestPoint[2] - nodePos[2]) * pullStrength,
557
+ ];
558
+ }
559
+ },
560
+ onEvent(node, config, context, event) {
561
+ if (event.type !== 'grab_end')
562
+ return;
563
+ if (!config.snap_points || config.snap_points.length === 0)
564
+ return;
565
+ const nodePos = node.properties.position || [0, 0, 0];
566
+ // Find closest snap point
567
+ let closestPoint = null;
568
+ let closestDistance = config.snap_distance || 0.3;
569
+ for (const snapPoint of config.snap_points) {
570
+ const distance = Math.sqrt(Math.pow(nodePos[0] - snapPoint[0], 2) +
571
+ Math.pow(nodePos[1] - snapPoint[1], 2) +
572
+ Math.pow(nodePos[2] - snapPoint[2], 2));
573
+ if (distance < closestDistance) {
574
+ closestDistance = distance;
575
+ closestPoint = snapPoint;
576
+ }
577
+ }
578
+ // Snap to closest point
579
+ if (closestPoint) {
580
+ node.properties.position = closestPoint;
581
+ context.emit('snap', { node, point: closestPoint });
582
+ // Haptic feedback
583
+ context.haptics.pulse(event.hand.id, 0.3);
584
+ }
585
+ },
586
+ };
587
+ // =============================================================================
588
+ // BREAKABLE TRAIT
589
+ // =============================================================================
590
+ const breakableHandler = {
591
+ name: 'breakable',
592
+ defaultConfig: {
593
+ break_velocity: 5,
594
+ fragments: 8,
595
+ fragment_mesh: undefined,
596
+ sound_on_break: undefined,
597
+ respawn: false,
598
+ respawn_delay: '5s',
599
+ },
600
+ onEvent(node, config, context, event) {
601
+ if (event.type !== 'collision')
602
+ return;
603
+ const collision = event.data;
604
+ const impactVelocity = Math.sqrt(Math.pow(collision.relativeVelocity[0], 2) +
605
+ Math.pow(collision.relativeVelocity[1], 2) +
606
+ Math.pow(collision.relativeVelocity[2], 2));
607
+ if (impactVelocity < (config.break_velocity || 5))
608
+ return;
609
+ // Play break sound
610
+ if (config.sound_on_break) {
611
+ context.audio.playSound(config.sound_on_break, {
612
+ position: collision.point,
613
+ spatial: true,
614
+ });
615
+ }
616
+ // Spawn fragments
617
+ const fragmentCount = config.fragments || 8;
618
+ for (let i = 0; i < fragmentCount; i++) {
619
+ const angle = (i / fragmentCount) * Math.PI * 2;
620
+ const velocity = [
621
+ Math.cos(angle) * 2,
622
+ Math.random() * 3,
623
+ Math.sin(angle) * 2,
624
+ ];
625
+ context.emit('spawn_fragment', {
626
+ position: collision.point,
627
+ velocity,
628
+ mesh: config.fragment_mesh,
629
+ });
630
+ }
631
+ // Emit break event
632
+ context.emit('break', { node, impactVelocity, collision });
633
+ // Handle respawn
634
+ if (config.respawn) {
635
+ const delay = parseDuration(config.respawn_delay || '5s');
636
+ setTimeout(() => {
637
+ context.emit('respawn', { node });
638
+ }, delay);
639
+ }
640
+ // Mark for destruction
641
+ node.properties.__destroyed = true;
642
+ },
643
+ };
644
+ // =============================================================================
645
+ // UTILITIES
646
+ // =============================================================================
647
+ function parseDuration(duration) {
648
+ const match = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m)$/);
649
+ if (!match)
650
+ return 0;
651
+ const value = parseFloat(match[1]);
652
+ const unit = match[2];
653
+ switch (unit) {
654
+ case 'ms':
655
+ return value;
656
+ case 's':
657
+ return value * 1000;
658
+ case 'm':
659
+ return value * 60 * 1000;
660
+ default:
661
+ return value;
662
+ }
663
+ }
664
+ // =============================================================================
665
+ // TRAIT REGISTRY
666
+ // =============================================================================
667
+ export class VRTraitRegistry {
668
+ constructor() {
669
+ this.handlers = new Map();
670
+ // Register all built-in handlers
671
+ this.register(grabbableHandler);
672
+ this.register(throwableHandler);
673
+ this.register(pointableHandler);
674
+ this.register(hoverableHandler);
675
+ this.register(scalableHandler);
676
+ this.register(rotatableHandler);
677
+ this.register(stackableHandler);
678
+ this.register(snappableHandler);
679
+ this.register(breakableHandler);
680
+ }
681
+ register(handler) {
682
+ this.handlers.set(handler.name, handler);
683
+ }
684
+ getHandler(name) {
685
+ return this.handlers.get(name);
686
+ }
687
+ attachTrait(node, traitName, config, context) {
688
+ const handler = this.handlers.get(traitName);
689
+ if (!handler)
690
+ return;
691
+ const mergedConfig = { ...handler.defaultConfig, ...config };
692
+ node.traits.set(traitName, mergedConfig);
693
+ if (handler.onAttach) {
694
+ handler.onAttach(node, mergedConfig, context);
695
+ }
696
+ }
697
+ detachTrait(node, traitName, context) {
698
+ const handler = this.handlers.get(traitName);
699
+ if (!handler)
700
+ return;
701
+ const config = node.traits.get(traitName);
702
+ if (config && handler.onDetach) {
703
+ handler.onDetach(node, config, context);
704
+ }
705
+ node.traits.delete(traitName);
706
+ }
707
+ updateTrait(node, traitName, context, delta) {
708
+ const handler = this.handlers.get(traitName);
709
+ if (!handler || !handler.onUpdate)
710
+ return;
711
+ const config = node.traits.get(traitName);
712
+ if (config) {
713
+ handler.onUpdate(node, config, context, delta);
714
+ }
715
+ }
716
+ handleEvent(node, traitName, context, event) {
717
+ const handler = this.handlers.get(traitName);
718
+ if (!handler || !handler.onEvent)
719
+ return;
720
+ const config = node.traits.get(traitName);
721
+ if (config) {
722
+ handler.onEvent(node, config, context, event);
723
+ }
724
+ }
725
+ updateAllTraits(node, context, delta) {
726
+ for (const traitName of node.traits.keys()) {
727
+ this.updateTrait(node, traitName, context, delta);
728
+ }
729
+ }
730
+ handleEventForAllTraits(node, context, event) {
731
+ for (const traitName of node.traits.keys()) {
732
+ this.handleEvent(node, traitName, context, event);
733
+ }
734
+ }
735
+ }
736
+ // =============================================================================
737
+ // EXPORTS
738
+ // =============================================================================
739
+ export const vrTraitRegistry = new VRTraitRegistry();
740
+ export { grabbableHandler, throwableHandler, pointableHandler, hoverableHandler, scalableHandler, rotatableHandler, stackableHandler, snappableHandler, breakableHandler, };