@holoscript/core 1.0.0-alpha.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,1040 @@
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
+ import type {
19
+ VRTraitName,
20
+ VRHand,
21
+ ThrowVelocity,
22
+ CollisionEvent,
23
+ Vector3,
24
+ GrabbableTrait,
25
+ ThrowableTrait,
26
+ PointableTrait,
27
+ HoverableTrait,
28
+ ScalableTrait,
29
+ RotatableTrait,
30
+ StackableTrait,
31
+ SnappableTrait,
32
+ BreakableTrait,
33
+ HSPlusNode,
34
+ } from '../types/HoloScriptPlus';
35
+
36
+ // =============================================================================
37
+ // TRAIT HANDLER TYPES
38
+ // =============================================================================
39
+
40
+ export interface TraitHandler<TConfig = unknown> {
41
+ name: VRTraitName;
42
+ defaultConfig: TConfig;
43
+ onAttach?: (node: HSPlusNode, config: TConfig, context: TraitContext) => void;
44
+ onDetach?: (node: HSPlusNode, config: TConfig, context: TraitContext) => void;
45
+ onUpdate?: (node: HSPlusNode, config: TConfig, context: TraitContext, delta: number) => void;
46
+ onEvent?: (node: HSPlusNode, config: TConfig, context: TraitContext, event: TraitEvent) => void;
47
+ }
48
+
49
+ export interface TraitContext {
50
+ vr: VRContext;
51
+ physics: PhysicsContext;
52
+ audio: AudioContext;
53
+ haptics: HapticsContext;
54
+ emit: (event: string, payload?: unknown) => void;
55
+ getState: () => Record<string, unknown>;
56
+ setState: (updates: Record<string, unknown>) => void;
57
+ }
58
+
59
+ export interface VRContext {
60
+ hands: {
61
+ left: VRHand | null;
62
+ right: VRHand | null;
63
+ };
64
+ headset: {
65
+ position: Vector3;
66
+ rotation: Vector3;
67
+ };
68
+ getPointerRay: (hand: 'left' | 'right') => { origin: Vector3; direction: Vector3 } | null;
69
+ getDominantHand: () => VRHand | null;
70
+ }
71
+
72
+ export interface PhysicsContext {
73
+ applyVelocity: (node: HSPlusNode, velocity: Vector3) => void;
74
+ applyAngularVelocity: (node: HSPlusNode, angularVelocity: Vector3) => void;
75
+ setKinematic: (node: HSPlusNode, kinematic: boolean) => void;
76
+ raycast: (origin: Vector3, direction: Vector3, maxDistance: number) => RaycastHit | null;
77
+ }
78
+
79
+ export interface RaycastHit {
80
+ node: HSPlusNode;
81
+ point: Vector3;
82
+ normal: Vector3;
83
+ distance: number;
84
+ }
85
+
86
+ export interface AudioContext {
87
+ playSound: (source: string, options?: { position?: Vector3; volume?: number; spatial?: boolean }) => void;
88
+ }
89
+
90
+ export interface HapticsContext {
91
+ pulse: (hand: 'left' | 'right', intensity: number, duration?: number) => void;
92
+ rumble: (hand: 'left' | 'right', intensity: number) => void;
93
+ }
94
+
95
+ export type TraitEvent =
96
+ | { type: 'grab_start'; hand: VRHand }
97
+ | { type: 'grab_end'; hand: VRHand; velocity: ThrowVelocity }
98
+ | { type: 'hover_enter'; hand: VRHand }
99
+ | { type: 'hover_exit'; hand: VRHand }
100
+ | { type: 'point_enter'; hand: VRHand }
101
+ | { type: 'point_exit'; hand: VRHand }
102
+ | { type: 'collision'; data: CollisionEvent }
103
+ | { type: 'trigger_enter'; other: HSPlusNode }
104
+ | { type: 'trigger_exit'; other: HSPlusNode }
105
+ | { type: 'click'; hand: VRHand }
106
+ | { type: 'scale_start'; hands: { left: VRHand; right: VRHand } }
107
+ | { type: 'scale_update'; scale: number }
108
+ | { type: 'scale_end'; finalScale: number }
109
+ | { type: 'rotate_start'; hand: VRHand }
110
+ | { type: 'rotate_update'; rotation: Vector3 }
111
+ | { type: 'rotate_end'; finalRotation: Vector3 };
112
+
113
+ // =============================================================================
114
+ // TRAIT STATE
115
+ // =============================================================================
116
+
117
+ interface GrabState {
118
+ isGrabbed: boolean;
119
+ grabbingHand: VRHand | null;
120
+ grabOffset: Vector3;
121
+ grabRotationOffset: Vector3;
122
+ previousHandPositions: Vector3[];
123
+ previousHandTimes: number[];
124
+ }
125
+
126
+ interface HoverState {
127
+ isHovered: boolean;
128
+ hoveringHand: VRHand | null;
129
+ originalScale: number;
130
+ originalColor: string | null;
131
+ }
132
+
133
+ interface PointState {
134
+ isPointed: boolean;
135
+ pointingHand: VRHand | null;
136
+ }
137
+
138
+ interface ScaleState {
139
+ isScaling: boolean;
140
+ initialDistance: number;
141
+ initialScale: number;
142
+ }
143
+
144
+ interface RotateState {
145
+ isRotating: boolean;
146
+ initialHandRotation: Vector3;
147
+ initialObjectRotation: Vector3;
148
+ }
149
+
150
+ interface StackState {
151
+ stackedItems: HSPlusNode[];
152
+ stackParent: HSPlusNode | null;
153
+ }
154
+
155
+ // =============================================================================
156
+ // GRABBABLE TRAIT
157
+ // =============================================================================
158
+
159
+ const grabbableHandler: TraitHandler<GrabbableTrait> = {
160
+ name: 'grabbable',
161
+
162
+ defaultConfig: {
163
+ snap_to_hand: true,
164
+ two_handed: false,
165
+ haptic_on_grab: 0.5,
166
+ grab_points: [],
167
+ preserve_rotation: false,
168
+ distance_grab: false,
169
+ max_grab_distance: 3,
170
+ },
171
+
172
+ onAttach(node, config, context) {
173
+ // Initialize grab state
174
+ const state: GrabState = {
175
+ isGrabbed: false,
176
+ grabbingHand: null,
177
+ grabOffset: [0, 0, 0],
178
+ grabRotationOffset: [0, 0, 0],
179
+ previousHandPositions: [],
180
+ previousHandTimes: [],
181
+ };
182
+ (node as unknown as { __grabState: GrabState }).__grabState = state;
183
+ },
184
+
185
+ onDetach(node) {
186
+ delete (node as unknown as { __grabState?: GrabState }).__grabState;
187
+ },
188
+
189
+ onUpdate(node, config, context, delta) {
190
+ const state = (node as unknown as { __grabState: GrabState }).__grabState;
191
+ if (!state?.isGrabbed || !state.grabbingHand) return;
192
+
193
+ // Follow hand position
194
+ const hand = state.grabbingHand;
195
+ const newPosition: Vector3 = config.snap_to_hand
196
+ ? hand.position
197
+ : [
198
+ hand.position[0] + state.grabOffset[0],
199
+ hand.position[1] + state.grabOffset[1],
200
+ hand.position[2] + state.grabOffset[2],
201
+ ];
202
+
203
+ // Update position
204
+ node.properties.position = newPosition;
205
+
206
+ // Track velocity for throw
207
+ state.previousHandPositions.push([...hand.position]);
208
+ state.previousHandTimes.push(Date.now());
209
+
210
+ // Keep last 10 frames
211
+ if (state.previousHandPositions.length > 10) {
212
+ state.previousHandPositions.shift();
213
+ state.previousHandTimes.shift();
214
+ }
215
+
216
+ // Update rotation if not preserving
217
+ if (!config.preserve_rotation) {
218
+ node.properties.rotation = hand.rotation;
219
+ }
220
+ },
221
+
222
+ onEvent(node, config, context, event) {
223
+ const state = (node as unknown as { __grabState: GrabState }).__grabState;
224
+
225
+ if (event.type === 'grab_start') {
226
+ // Check distance for distance grab
227
+ if (!config.distance_grab) {
228
+ const handPos = event.hand.position;
229
+ const nodePos = node.properties.position as Vector3 || [0, 0, 0];
230
+ const distance = Math.sqrt(
231
+ Math.pow(handPos[0] - nodePos[0], 2) +
232
+ Math.pow(handPos[1] - nodePos[1], 2) +
233
+ Math.pow(handPos[2] - nodePos[2], 2)
234
+ );
235
+ if (distance > (config.max_grab_distance || 3)) return;
236
+ }
237
+
238
+ state.isGrabbed = true;
239
+ state.grabbingHand = event.hand;
240
+
241
+ // Calculate grab offset
242
+ const nodePos = node.properties.position as Vector3 || [0, 0, 0];
243
+ state.grabOffset = [
244
+ nodePos[0] - event.hand.position[0],
245
+ nodePos[1] - event.hand.position[1],
246
+ nodePos[2] - event.hand.position[2],
247
+ ];
248
+
249
+ // Haptic feedback
250
+ if (config.haptic_on_grab) {
251
+ context.haptics.pulse(event.hand.id, config.haptic_on_grab);
252
+ }
253
+
254
+ // Make kinematic while grabbed
255
+ context.physics.setKinematic(node, true);
256
+
257
+ // Emit grab event
258
+ context.emit('grab', { node, hand: event.hand });
259
+ }
260
+
261
+ if (event.type === 'grab_end') {
262
+ state.isGrabbed = false;
263
+ state.grabbingHand = null;
264
+
265
+ // Re-enable physics
266
+ context.physics.setKinematic(node, false);
267
+
268
+ // Calculate throw velocity from tracked positions
269
+ if (state.previousHandPositions.length >= 2) {
270
+ const len = state.previousHandPositions.length;
271
+ const dt = (state.previousHandTimes[len - 1] - state.previousHandTimes[0]) / 1000;
272
+ if (dt > 0) {
273
+ const velocity: Vector3 = [
274
+ (state.previousHandPositions[len - 1][0] - state.previousHandPositions[0][0]) / dt,
275
+ (state.previousHandPositions[len - 1][1] - state.previousHandPositions[0][1]) / dt,
276
+ (state.previousHandPositions[len - 1][2] - state.previousHandPositions[0][2]) / dt,
277
+ ];
278
+
279
+ // Apply velocity if throwable trait exists
280
+ if (node.traits.has('throwable')) {
281
+ const throwConfig = node.traits.get('throwable') as ThrowableTrait;
282
+ const multiplier = throwConfig.velocity_multiplier || 1;
283
+ context.physics.applyVelocity(node, [
284
+ velocity[0] * multiplier,
285
+ velocity[1] * multiplier,
286
+ velocity[2] * multiplier,
287
+ ]);
288
+ }
289
+ }
290
+ }
291
+
292
+ // Clear tracking
293
+ state.previousHandPositions = [];
294
+ state.previousHandTimes = [];
295
+
296
+ // Emit release event
297
+ context.emit('release', { node, velocity: event.velocity });
298
+ }
299
+ },
300
+ };
301
+
302
+ // =============================================================================
303
+ // THROWABLE TRAIT
304
+ // =============================================================================
305
+
306
+ const throwableHandler: TraitHandler<ThrowableTrait> = {
307
+ name: 'throwable',
308
+
309
+ defaultConfig: {
310
+ velocity_multiplier: 1,
311
+ gravity: true,
312
+ max_velocity: 50,
313
+ spin: true,
314
+ bounce: false,
315
+ bounce_factor: 0.5,
316
+ },
317
+
318
+ onAttach(node, config, context) {
319
+ // Throwable works with grabbable - just configures throw behavior
320
+ },
321
+
322
+ onEvent(node, config, context, event) {
323
+ if (event.type === 'collision' && config.bounce) {
324
+ const collision = event.data;
325
+ const bounceFactor = config.bounce_factor || 0.5;
326
+
327
+ // Reflect velocity
328
+ const velocity = collision.relativeVelocity;
329
+ const normal = collision.normal;
330
+ const dot = velocity[0] * normal[0] + velocity[1] * normal[1] + velocity[2] * normal[2];
331
+ const reflected: Vector3 = [
332
+ (velocity[0] - 2 * dot * normal[0]) * bounceFactor,
333
+ (velocity[1] - 2 * dot * normal[1]) * bounceFactor,
334
+ (velocity[2] - 2 * dot * normal[2]) * bounceFactor,
335
+ ];
336
+
337
+ context.physics.applyVelocity(node, reflected);
338
+ }
339
+ },
340
+ };
341
+
342
+ // =============================================================================
343
+ // POINTABLE TRAIT
344
+ // =============================================================================
345
+
346
+ const pointableHandler: TraitHandler<PointableTrait> = {
347
+ name: 'pointable',
348
+
349
+ defaultConfig: {
350
+ highlight_on_point: true,
351
+ highlight_color: '#00ff00',
352
+ cursor_style: 'pointer',
353
+ },
354
+
355
+ onAttach(node, config, context) {
356
+ const state: PointState = {
357
+ isPointed: false,
358
+ pointingHand: null,
359
+ };
360
+ (node as unknown as { __pointState: PointState }).__pointState = state;
361
+ },
362
+
363
+ onDetach(node) {
364
+ delete (node as unknown as { __pointState?: PointState }).__pointState;
365
+ },
366
+
367
+ onEvent(node, config, context, event) {
368
+ const state = (node as unknown as { __pointState: PointState }).__pointState;
369
+
370
+ if (event.type === 'point_enter') {
371
+ state.isPointed = true;
372
+ state.pointingHand = event.hand;
373
+
374
+ if (config.highlight_on_point) {
375
+ node.properties.__originalEmissive = node.properties.emissive;
376
+ node.properties.emissive = config.highlight_color;
377
+ }
378
+
379
+ context.emit('point_enter', { node, hand: event.hand });
380
+ }
381
+
382
+ if (event.type === 'point_exit') {
383
+ state.isPointed = false;
384
+ state.pointingHand = null;
385
+
386
+ if (config.highlight_on_point) {
387
+ node.properties.emissive = node.properties.__originalEmissive || null;
388
+ delete node.properties.__originalEmissive;
389
+ }
390
+
391
+ context.emit('point_exit', { node });
392
+ }
393
+
394
+ if (event.type === 'click') {
395
+ context.emit('click', { node, hand: event.hand });
396
+ }
397
+ },
398
+ };
399
+
400
+ // =============================================================================
401
+ // HOVERABLE TRAIT
402
+ // =============================================================================
403
+
404
+ const hoverableHandler: TraitHandler<HoverableTrait> = {
405
+ name: 'hoverable',
406
+
407
+ defaultConfig: {
408
+ highlight_color: '#ffffff',
409
+ scale_on_hover: 1.1,
410
+ show_tooltip: false,
411
+ tooltip_offset: [0, 0.2, 0],
412
+ glow: false,
413
+ glow_intensity: 0.5,
414
+ },
415
+
416
+ onAttach(node, config, context) {
417
+ const state: HoverState = {
418
+ isHovered: false,
419
+ hoveringHand: null,
420
+ originalScale: typeof node.properties.scale === 'number' ? node.properties.scale : 1,
421
+ originalColor: null,
422
+ };
423
+ (node as unknown as { __hoverState: HoverState }).__hoverState = state;
424
+ },
425
+
426
+ onDetach(node) {
427
+ delete (node as unknown as { __hoverState?: HoverState }).__hoverState;
428
+ },
429
+
430
+ onEvent(node, config, context, event) {
431
+ const state = (node as unknown as { __hoverState: HoverState }).__hoverState;
432
+
433
+ if (event.type === 'hover_enter') {
434
+ state.isHovered = true;
435
+ state.hoveringHand = event.hand;
436
+
437
+ // Scale up
438
+ if (config.scale_on_hover && config.scale_on_hover !== 1) {
439
+ state.originalScale = typeof node.properties.scale === 'number' ? node.properties.scale : 1;
440
+ node.properties.scale = state.originalScale * config.scale_on_hover;
441
+ }
442
+
443
+ // Glow effect
444
+ if (config.glow) {
445
+ state.originalColor = (node.properties.emissive as string) || null;
446
+ node.properties.emissive = config.highlight_color;
447
+ node.properties.emissiveIntensity = config.glow_intensity;
448
+ }
449
+
450
+ // Tooltip
451
+ if (config.show_tooltip) {
452
+ const tooltipText = typeof config.show_tooltip === 'string'
453
+ ? config.show_tooltip
454
+ : node.properties.tooltip || node.id || node.type;
455
+ context.emit('show_tooltip', {
456
+ node,
457
+ text: tooltipText,
458
+ offset: config.tooltip_offset,
459
+ });
460
+ }
461
+
462
+ context.emit('hover_enter', { node, hand: event.hand });
463
+ }
464
+
465
+ if (event.type === 'hover_exit') {
466
+ state.isHovered = false;
467
+ state.hoveringHand = null;
468
+
469
+ // Restore scale
470
+ if (config.scale_on_hover && config.scale_on_hover !== 1) {
471
+ node.properties.scale = state.originalScale;
472
+ }
473
+
474
+ // Remove glow
475
+ if (config.glow) {
476
+ node.properties.emissive = state.originalColor;
477
+ delete node.properties.emissiveIntensity;
478
+ }
479
+
480
+ // Hide tooltip
481
+ if (config.show_tooltip) {
482
+ context.emit('hide_tooltip', { node });
483
+ }
484
+
485
+ context.emit('hover_exit', { node });
486
+ }
487
+ },
488
+ };
489
+
490
+ // =============================================================================
491
+ // SCALABLE TRAIT
492
+ // =============================================================================
493
+
494
+ const scalableHandler: TraitHandler<ScalableTrait> = {
495
+ name: 'scalable',
496
+
497
+ defaultConfig: {
498
+ min_scale: 0.1,
499
+ max_scale: 10,
500
+ uniform: true,
501
+ pivot: [0, 0, 0],
502
+ },
503
+
504
+ onAttach(node, config, context) {
505
+ const state: ScaleState = {
506
+ isScaling: false,
507
+ initialDistance: 0,
508
+ initialScale: 1,
509
+ };
510
+ (node as unknown as { __scaleState: ScaleState }).__scaleState = state;
511
+ },
512
+
513
+ onDetach(node) {
514
+ delete (node as unknown as { __scaleState?: ScaleState }).__scaleState;
515
+ },
516
+
517
+ onUpdate(node, config, context, delta) {
518
+ const state = (node as unknown as { __scaleState: ScaleState }).__scaleState;
519
+ if (!state?.isScaling) return;
520
+
521
+ const { hands } = context.vr;
522
+ if (!hands.left || !hands.right) return;
523
+
524
+ // Calculate current distance between hands
525
+ const currentDistance = Math.sqrt(
526
+ Math.pow(hands.right.position[0] - hands.left.position[0], 2) +
527
+ Math.pow(hands.right.position[1] - hands.left.position[1], 2) +
528
+ Math.pow(hands.right.position[2] - hands.left.position[2], 2)
529
+ );
530
+
531
+ // Calculate scale factor
532
+ const scaleFactor = currentDistance / state.initialDistance;
533
+ let newScale = state.initialScale * scaleFactor;
534
+
535
+ // Clamp scale
536
+ newScale = Math.max(config.min_scale || 0.1, Math.min(config.max_scale || 10, newScale));
537
+
538
+ node.properties.scale = newScale;
539
+
540
+ context.emit('scale_update', { node, scale: newScale });
541
+ },
542
+
543
+ onEvent(node, config, context, event) {
544
+ const state = (node as unknown as { __scaleState: ScaleState }).__scaleState;
545
+
546
+ if (event.type === 'scale_start') {
547
+ state.isScaling = true;
548
+ state.initialScale = typeof node.properties.scale === 'number' ? node.properties.scale : 1;
549
+
550
+ // Calculate initial distance between hands
551
+ const { left, right } = event.hands;
552
+ state.initialDistance = Math.sqrt(
553
+ Math.pow(right.position[0] - left.position[0], 2) +
554
+ Math.pow(right.position[1] - left.position[1], 2) +
555
+ Math.pow(right.position[2] - left.position[2], 2)
556
+ );
557
+
558
+ context.emit('scale_start', { node });
559
+ }
560
+
561
+ if (event.type === 'scale_end') {
562
+ state.isScaling = false;
563
+ context.emit('scale_end', { node, finalScale: node.properties.scale });
564
+ }
565
+ },
566
+ };
567
+
568
+ // =============================================================================
569
+ // ROTATABLE TRAIT
570
+ // =============================================================================
571
+
572
+ const rotatableHandler: TraitHandler<RotatableTrait> = {
573
+ name: 'rotatable',
574
+
575
+ defaultConfig: {
576
+ axis: 'all',
577
+ snap_angles: [],
578
+ speed: 1,
579
+ },
580
+
581
+ onAttach(node, _config, _context) {
582
+ const state: RotateState = {
583
+ isRotating: false,
584
+ initialHandRotation: [0, 0, 0],
585
+ initialObjectRotation: [0, 0, 0],
586
+ };
587
+ (node as unknown as { __rotateState: RotateState }).__rotateState = state;
588
+ },
589
+
590
+ onDetach(node) {
591
+ delete (node as unknown as { __rotateState?: RotateState }).__rotateState;
592
+ },
593
+
594
+ onUpdate(node, config, context, _delta) {
595
+ const state = (node as unknown as { __rotateState: RotateState }).__rotateState;
596
+ if (!state?.isRotating) return;
597
+
598
+ const hand = context.vr.getDominantHand();
599
+ if (!hand) return;
600
+
601
+ // Calculate rotation delta
602
+ const deltaRotation: Vector3 = [
603
+ (hand.rotation[0] - state.initialHandRotation[0]) * (config.speed || 1),
604
+ (hand.rotation[1] - state.initialHandRotation[1]) * (config.speed || 1),
605
+ (hand.rotation[2] - state.initialHandRotation[2]) * (config.speed || 1),
606
+ ];
607
+
608
+ // Apply axis constraint
609
+ let newRotation: Vector3;
610
+ switch (config.axis) {
611
+ case 'x':
612
+ newRotation = [
613
+ state.initialObjectRotation[0] + deltaRotation[0],
614
+ state.initialObjectRotation[1],
615
+ state.initialObjectRotation[2],
616
+ ];
617
+ break;
618
+ case 'y':
619
+ newRotation = [
620
+ state.initialObjectRotation[0],
621
+ state.initialObjectRotation[1] + deltaRotation[1],
622
+ state.initialObjectRotation[2],
623
+ ];
624
+ break;
625
+ case 'z':
626
+ newRotation = [
627
+ state.initialObjectRotation[0],
628
+ state.initialObjectRotation[1],
629
+ state.initialObjectRotation[2] + deltaRotation[2],
630
+ ];
631
+ break;
632
+ default:
633
+ newRotation = [
634
+ state.initialObjectRotation[0] + deltaRotation[0],
635
+ state.initialObjectRotation[1] + deltaRotation[1],
636
+ state.initialObjectRotation[2] + deltaRotation[2],
637
+ ];
638
+ }
639
+
640
+ // Snap to angles if configured
641
+ if (config.snap_angles && config.snap_angles.length > 0) {
642
+ newRotation = newRotation.map((angle) => {
643
+ let closest = config.snap_angles![0];
644
+ let minDiff = Math.abs(angle - closest);
645
+ for (const snapAngle of config.snap_angles!) {
646
+ const diff = Math.abs(angle - snapAngle);
647
+ if (diff < minDiff) {
648
+ minDiff = diff;
649
+ closest = snapAngle;
650
+ }
651
+ }
652
+ // Only snap if close enough
653
+ return minDiff < 10 ? closest : angle;
654
+ }) as Vector3;
655
+ }
656
+
657
+ node.properties.rotation = newRotation;
658
+ context.emit('rotate_update', { node, rotation: newRotation });
659
+ },
660
+
661
+ onEvent(node, _config, context, event) {
662
+ const state = (node as unknown as { __rotateState: RotateState }).__rotateState;
663
+
664
+ if (event.type === 'rotate_start') {
665
+ state.isRotating = true;
666
+ state.initialHandRotation = [...event.hand.rotation];
667
+ state.initialObjectRotation = (node.properties.rotation as Vector3) || [0, 0, 0];
668
+
669
+ context.emit('rotate_start', { node });
670
+ }
671
+
672
+ if (event.type === 'rotate_end') {
673
+ state.isRotating = false;
674
+ context.emit('rotate_end', { node, finalRotation: node.properties.rotation });
675
+ }
676
+ },
677
+ };
678
+
679
+ // =============================================================================
680
+ // STACKABLE TRAIT
681
+ // =============================================================================
682
+
683
+ const stackableHandler: TraitHandler<StackableTrait> = {
684
+ name: 'stackable',
685
+
686
+ defaultConfig: {
687
+ stack_axis: 'y',
688
+ stack_offset: 0,
689
+ max_stack: 10,
690
+ snap_distance: 0.5,
691
+ },
692
+
693
+ onAttach(node, _config, _context) {
694
+ const state: StackState = {
695
+ stackedItems: [],
696
+ stackParent: null,
697
+ };
698
+ (node as unknown as { __stackState: StackState }).__stackState = state;
699
+ },
700
+
701
+ onDetach(node) {
702
+ const state = (node as unknown as { __stackState: StackState }).__stackState;
703
+ // Remove from parent stack
704
+ if (state.stackParent) {
705
+ const parentState = (state.stackParent as unknown as { __stackState: StackState }).__stackState;
706
+ const index = parentState.stackedItems.indexOf(node);
707
+ if (index > -1) {
708
+ parentState.stackedItems.splice(index, 1);
709
+ }
710
+ }
711
+ // Clear children
712
+ state.stackedItems = [];
713
+ delete (node as unknown as { __stackState?: StackState }).__stackState;
714
+ },
715
+
716
+ onEvent(node, config, context, event) {
717
+ const state = (node as unknown as { __stackState: StackState }).__stackState;
718
+
719
+ if (event.type === 'collision' || event.type === 'trigger_enter') {
720
+ const other = event.type === 'collision' ? event.data.target : (event as { other: HSPlusNode }).other;
721
+
722
+ // Check if other is stackable
723
+ if (!other.traits.has('stackable')) return;
724
+
725
+ const otherState = (other as unknown as { __stackState: StackState }).__stackState;
726
+ if (!otherState) return;
727
+
728
+ // Check stack limit
729
+ if (state.stackedItems.length >= (config.max_stack || 10)) return;
730
+
731
+ // Check if close enough
732
+ const nodePos = node.properties.position as Vector3 || [0, 0, 0];
733
+ const otherPos = other.properties.position as Vector3 || [0, 0, 0];
734
+
735
+ const axisIndex = config.stack_axis === 'x' ? 0 : config.stack_axis === 'z' ? 2 : 1;
736
+ const otherAxes = [0, 1, 2].filter((i) => i !== axisIndex);
737
+
738
+ // Check alignment on other axes
739
+ let aligned = true;
740
+ for (const axis of otherAxes) {
741
+ if (Math.abs(nodePos[axis] - otherPos[axis]) > (config.snap_distance || 0.5)) {
742
+ aligned = false;
743
+ break;
744
+ }
745
+ }
746
+
747
+ if (aligned && otherPos[axisIndex] > nodePos[axisIndex]) {
748
+ // Other is above - add to stack
749
+ state.stackedItems.push(other);
750
+ otherState.stackParent = node;
751
+
752
+ // Snap position
753
+ const stackOffset = config.stack_offset || 0;
754
+ const newPos: Vector3 = [...nodePos];
755
+ newPos[axisIndex] = nodePos[axisIndex] + stackOffset;
756
+
757
+ other.properties.position = newPos;
758
+
759
+ context.emit('stack', { parent: node, child: other });
760
+ }
761
+ }
762
+ },
763
+ };
764
+
765
+ // =============================================================================
766
+ // SNAPPABLE TRAIT
767
+ // =============================================================================
768
+
769
+ const snappableHandler: TraitHandler<SnappableTrait> = {
770
+ name: 'snappable',
771
+
772
+ defaultConfig: {
773
+ snap_points: [],
774
+ snap_distance: 0.3,
775
+ snap_rotation: false,
776
+ magnetic: false,
777
+ },
778
+
779
+ onUpdate(node, config, _context, _delta) {
780
+ if (!config.snap_points || config.snap_points.length === 0) return;
781
+ if (!config.magnetic) return;
782
+
783
+ const nodePos = node.properties.position as Vector3 || [0, 0, 0];
784
+
785
+ // Find closest snap point
786
+ let closestPoint: Vector3 | null = null;
787
+ let closestDistance = config.snap_distance || 0.3;
788
+
789
+ for (const snapPoint of config.snap_points) {
790
+ const distance = Math.sqrt(
791
+ Math.pow(nodePos[0] - snapPoint[0], 2) +
792
+ Math.pow(nodePos[1] - snapPoint[1], 2) +
793
+ Math.pow(nodePos[2] - snapPoint[2], 2)
794
+ );
795
+
796
+ if (distance < closestDistance) {
797
+ closestDistance = distance;
798
+ closestPoint = snapPoint;
799
+ }
800
+ }
801
+
802
+ // Apply magnetic pull
803
+ if (closestPoint) {
804
+ const pullStrength = 0.1;
805
+ node.properties.position = [
806
+ nodePos[0] + (closestPoint[0] - nodePos[0]) * pullStrength,
807
+ nodePos[1] + (closestPoint[1] - nodePos[1]) * pullStrength,
808
+ nodePos[2] + (closestPoint[2] - nodePos[2]) * pullStrength,
809
+ ];
810
+ }
811
+ },
812
+
813
+ onEvent(node, config, context, event) {
814
+ if (event.type !== 'grab_end') return;
815
+ if (!config.snap_points || config.snap_points.length === 0) return;
816
+
817
+ const nodePos = node.properties.position as Vector3 || [0, 0, 0];
818
+
819
+ // Find closest snap point
820
+ let closestPoint: Vector3 | null = null;
821
+ let closestDistance = config.snap_distance || 0.3;
822
+
823
+ for (const snapPoint of config.snap_points) {
824
+ const distance = Math.sqrt(
825
+ Math.pow(nodePos[0] - snapPoint[0], 2) +
826
+ Math.pow(nodePos[1] - snapPoint[1], 2) +
827
+ Math.pow(nodePos[2] - snapPoint[2], 2)
828
+ );
829
+
830
+ if (distance < closestDistance) {
831
+ closestDistance = distance;
832
+ closestPoint = snapPoint;
833
+ }
834
+ }
835
+
836
+ // Snap to closest point
837
+ if (closestPoint) {
838
+ node.properties.position = closestPoint;
839
+ context.emit('snap', { node, point: closestPoint });
840
+
841
+ // Haptic feedback
842
+ context.haptics.pulse(event.hand.id, 0.3);
843
+ }
844
+ },
845
+ };
846
+
847
+ // =============================================================================
848
+ // BREAKABLE TRAIT
849
+ // =============================================================================
850
+
851
+ const breakableHandler: TraitHandler<BreakableTrait> = {
852
+ name: 'breakable',
853
+
854
+ defaultConfig: {
855
+ break_velocity: 5,
856
+ fragments: 8,
857
+ fragment_mesh: undefined,
858
+ sound_on_break: undefined,
859
+ respawn: false,
860
+ respawn_delay: '5s',
861
+ },
862
+
863
+ onEvent(node, config, context, event) {
864
+ if (event.type !== 'collision') return;
865
+
866
+ const collision = event.data;
867
+ const impactVelocity = Math.sqrt(
868
+ Math.pow(collision.relativeVelocity[0], 2) +
869
+ Math.pow(collision.relativeVelocity[1], 2) +
870
+ Math.pow(collision.relativeVelocity[2], 2)
871
+ );
872
+
873
+ if (impactVelocity < (config.break_velocity || 5)) return;
874
+
875
+ // Play break sound
876
+ if (config.sound_on_break) {
877
+ context.audio.playSound(config.sound_on_break, {
878
+ position: collision.point,
879
+ spatial: true,
880
+ });
881
+ }
882
+
883
+ // Spawn fragments
884
+ const fragmentCount = config.fragments || 8;
885
+ for (let i = 0; i < fragmentCount; i++) {
886
+ const angle = (i / fragmentCount) * Math.PI * 2;
887
+ const velocity: Vector3 = [
888
+ Math.cos(angle) * 2,
889
+ Math.random() * 3,
890
+ Math.sin(angle) * 2,
891
+ ];
892
+
893
+ context.emit('spawn_fragment', {
894
+ position: collision.point,
895
+ velocity,
896
+ mesh: config.fragment_mesh,
897
+ });
898
+ }
899
+
900
+ // Emit break event
901
+ context.emit('break', { node, impactVelocity, collision });
902
+
903
+ // Handle respawn
904
+ if (config.respawn) {
905
+ const delay = parseDuration(config.respawn_delay || '5s');
906
+ setTimeout(() => {
907
+ context.emit('respawn', { node });
908
+ }, delay);
909
+ }
910
+
911
+ // Mark for destruction
912
+ node.properties.__destroyed = true;
913
+ },
914
+ };
915
+
916
+ // =============================================================================
917
+ // UTILITIES
918
+ // =============================================================================
919
+
920
+ function parseDuration(duration: string): number {
921
+ const match = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m)$/);
922
+ if (!match) return 0;
923
+
924
+ const value = parseFloat(match[1]);
925
+ const unit = match[2];
926
+
927
+ switch (unit) {
928
+ case 'ms':
929
+ return value;
930
+ case 's':
931
+ return value * 1000;
932
+ case 'm':
933
+ return value * 60 * 1000;
934
+ default:
935
+ return value;
936
+ }
937
+ }
938
+
939
+ // =============================================================================
940
+ // TRAIT REGISTRY
941
+ // =============================================================================
942
+
943
+ export class VRTraitRegistry {
944
+ private handlers: Map<VRTraitName, TraitHandler> = new Map();
945
+
946
+ constructor() {
947
+ // Register all built-in handlers
948
+ this.register(grabbableHandler);
949
+ this.register(throwableHandler);
950
+ this.register(pointableHandler);
951
+ this.register(hoverableHandler);
952
+ this.register(scalableHandler);
953
+ this.register(rotatableHandler);
954
+ this.register(stackableHandler);
955
+ this.register(snappableHandler);
956
+ this.register(breakableHandler);
957
+ }
958
+
959
+ register<T>(handler: TraitHandler<T>): void {
960
+ this.handlers.set(handler.name, handler as TraitHandler);
961
+ }
962
+
963
+ getHandler(name: VRTraitName): TraitHandler | undefined {
964
+ return this.handlers.get(name);
965
+ }
966
+
967
+ attachTrait(node: HSPlusNode, traitName: VRTraitName, config: unknown, context: TraitContext): void {
968
+ const handler = this.handlers.get(traitName);
969
+ if (!handler) return;
970
+
971
+ const mergedConfig = { ...(handler.defaultConfig as object), ...(config as object) };
972
+ node.traits.set(traitName, mergedConfig);
973
+
974
+ if (handler.onAttach) {
975
+ handler.onAttach(node, mergedConfig, context);
976
+ }
977
+ }
978
+
979
+ detachTrait(node: HSPlusNode, traitName: VRTraitName, context: TraitContext): void {
980
+ const handler = this.handlers.get(traitName);
981
+ if (!handler) return;
982
+
983
+ const config = node.traits.get(traitName);
984
+ if (config && handler.onDetach) {
985
+ handler.onDetach(node, config, context);
986
+ }
987
+
988
+ node.traits.delete(traitName);
989
+ }
990
+
991
+ updateTrait(node: HSPlusNode, traitName: VRTraitName, context: TraitContext, delta: number): void {
992
+ const handler = this.handlers.get(traitName);
993
+ if (!handler || !handler.onUpdate) return;
994
+
995
+ const config = node.traits.get(traitName);
996
+ if (config) {
997
+ handler.onUpdate(node, config, context, delta);
998
+ }
999
+ }
1000
+
1001
+ handleEvent(node: HSPlusNode, traitName: VRTraitName, context: TraitContext, event: TraitEvent): void {
1002
+ const handler = this.handlers.get(traitName);
1003
+ if (!handler || !handler.onEvent) return;
1004
+
1005
+ const config = node.traits.get(traitName);
1006
+ if (config) {
1007
+ handler.onEvent(node, config, context, event);
1008
+ }
1009
+ }
1010
+
1011
+ updateAllTraits(node: HSPlusNode, context: TraitContext, delta: number): void {
1012
+ for (const traitName of node.traits.keys()) {
1013
+ this.updateTrait(node, traitName, context, delta);
1014
+ }
1015
+ }
1016
+
1017
+ handleEventForAllTraits(node: HSPlusNode, context: TraitContext, event: TraitEvent): void {
1018
+ for (const traitName of node.traits.keys()) {
1019
+ this.handleEvent(node, traitName, context, event);
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ // =============================================================================
1025
+ // EXPORTS
1026
+ // =============================================================================
1027
+
1028
+ export const vrTraitRegistry = new VRTraitRegistry();
1029
+
1030
+ export {
1031
+ grabbableHandler,
1032
+ throwableHandler,
1033
+ pointableHandler,
1034
+ hoverableHandler,
1035
+ scalableHandler,
1036
+ rotatableHandler,
1037
+ stackableHandler,
1038
+ snappableHandler,
1039
+ breakableHandler,
1040
+ };