@vib3code/sdk 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.
- package/CHANGELOG.md +118 -0
- package/DOCS/BLUEPRINT_EXECUTION_PLAN_2026-01-07.md +34 -0
- package/DOCS/CI_TESTING.md +38 -0
- package/DOCS/CLI_ONBOARDING.md +75 -0
- package/DOCS/CONTROL_REFERENCE.md +64 -0
- package/DOCS/DEV_TRACK_ANALYSIS.md +77 -0
- package/DOCS/DEV_TRACK_PLAN_2026-01-07.md +42 -0
- package/DOCS/DEV_TRACK_SESSION_2026-01-31.md +220 -0
- package/DOCS/ENV_SETUP.md +189 -0
- package/DOCS/EXPORT_FORMATS.md +417 -0
- package/DOCS/GPU_DISPOSAL_GUIDE.md +21 -0
- package/DOCS/LICENSING_TIERS.md +275 -0
- package/DOCS/MASTER_PLAN_2026-01-31.md +570 -0
- package/DOCS/OBS_SETUP_GUIDE.md +98 -0
- package/DOCS/PROJECT_SETUP.md +66 -0
- package/DOCS/RENDERER_LIFECYCLE.md +40 -0
- package/DOCS/REPO_MANIFEST.md +121 -0
- package/DOCS/SESSION_014_PLAN.md +195 -0
- package/DOCS/SESSION_LOG_2026-01-07.md +56 -0
- package/DOCS/STRATEGIC_BLUEPRINT_2026-01-07.md +72 -0
- package/DOCS/SYSTEM_AUDIT_2026-01-30.md +738 -0
- package/DOCS/SYSTEM_INVENTORY.md +520 -0
- package/DOCS/TELEMETRY_EXPORTS.md +34 -0
- package/DOCS/WEBGPU_STATUS.md +38 -0
- package/DOCS/XR_BENCHMARKS.md +608 -0
- package/LICENSE +21 -0
- package/README.md +426 -0
- package/docs/.nojekyll +0 -0
- package/docs/01-dissolution_of_euclidean_hegemony.html +346 -0
- package/docs/02-hyperspatial_ego_death.html +346 -0
- package/docs/03-post_cartesian_sublime.html +346 -0
- package/docs/04-crystalline_void_meditation.html +346 -0
- package/docs/05-quantum_decoherence_ballet.html +346 -0
- package/docs/06-dissolution_of_euclidean_hegemony.html +346 -0
- package/docs/07-hyperspatial_ego_death.html +346 -0
- package/docs/08-post_cartesian_sublime.html +346 -0
- package/docs/09-crystalline_void_meditation.html +346 -0
- package/docs/10-quantum_decoherence_ballet.html +346 -0
- package/docs/11-dissolution_of_euclidean_hegemony.html +346 -0
- package/docs/12-hyperspatial_ego_death.html +346 -0
- package/docs/13-post_cartesian_sublime.html +346 -0
- package/docs/index.html +794 -0
- package/docs/test-hub.html +441 -0
- package/docs/url-state.js +102 -0
- package/docs/vib3-exports/01-quantum-quantum-tetrahedron-lattice.html +489 -0
- package/docs/vib3-exports/02-quantum-quantum-hypersphere-matrix.html +489 -0
- package/docs/vib3-exports/03-quantum-quantum-hypertetra-fractal.html +489 -0
- package/docs/vib3-exports/04-faceted-faceted-crystal-structure.html +407 -0
- package/docs/vib3-exports/05-faceted-faceted-klein-bottle.html +407 -0
- package/docs/vib3-exports/06-faceted-faceted-hypertetra-torus.html +407 -0
- package/docs/vib3-exports/07-holographic-holographic-wave-field.html +457 -0
- package/docs/vib3-exports/08-holographic-holographic-hypersphere-sphere.html +457 -0
- package/docs/vib3-exports/09-holographic-holographic-hypertetra-crystal.html +457 -0
- package/docs/vib3-exports/index.html +238 -0
- package/docs/webgpu-live.html +702 -0
- package/package.json +367 -0
- package/src/advanced/AIPresetGenerator.js +777 -0
- package/src/advanced/MIDIController.js +703 -0
- package/src/advanced/OffscreenWorker.js +1051 -0
- package/src/advanced/WebGPUCompute.js +1051 -0
- package/src/advanced/WebXRRenderer.js +680 -0
- package/src/agent/cli/AgentCLI.js +615 -0
- package/src/agent/cli/index.js +14 -0
- package/src/agent/index.js +73 -0
- package/src/agent/mcp/MCPServer.js +950 -0
- package/src/agent/mcp/index.js +9 -0
- package/src/agent/mcp/tools.js +548 -0
- package/src/agent/telemetry/EventStream.js +669 -0
- package/src/agent/telemetry/Instrumentation.js +618 -0
- package/src/agent/telemetry/TelemetryExporters.js +427 -0
- package/src/agent/telemetry/TelemetryService.js +464 -0
- package/src/agent/telemetry/index.js +52 -0
- package/src/benchmarks/BenchmarkRunner.js +381 -0
- package/src/benchmarks/MetricsCollector.js +299 -0
- package/src/benchmarks/index.js +9 -0
- package/src/benchmarks/scenes.js +259 -0
- package/src/cli/index.js +675 -0
- package/src/config/ApiConfig.js +88 -0
- package/src/core/CanvasManager.js +217 -0
- package/src/core/ErrorReporter.js +117 -0
- package/src/core/ParameterMapper.js +333 -0
- package/src/core/Parameters.js +396 -0
- package/src/core/RendererContracts.js +200 -0
- package/src/core/UnifiedResourceManager.js +370 -0
- package/src/core/VIB3Engine.js +636 -0
- package/src/core/renderers/FacetedRendererAdapter.js +32 -0
- package/src/core/renderers/HolographicRendererAdapter.js +29 -0
- package/src/core/renderers/QuantumRendererAdapter.js +29 -0
- package/src/core/renderers/RendererLifecycleManager.js +63 -0
- package/src/creative/ColorPresetsSystem.js +980 -0
- package/src/creative/ParameterTimeline.js +1061 -0
- package/src/creative/PostProcessingPipeline.js +1113 -0
- package/src/creative/TransitionAnimator.js +683 -0
- package/src/export/CSSExporter.js +226 -0
- package/src/export/CardGeneratorBase.js +279 -0
- package/src/export/ExportManager.js +580 -0
- package/src/export/FacetedCardGenerator.js +279 -0
- package/src/export/HolographicCardGenerator.js +543 -0
- package/src/export/LottieExporter.js +552 -0
- package/src/export/QuantumCardGenerator.js +315 -0
- package/src/export/SVGExporter.js +519 -0
- package/src/export/ShaderExporter.js +903 -0
- package/src/export/TradingCardGenerator.js +3055 -0
- package/src/export/TradingCardManager.js +181 -0
- package/src/export/VIB3PackageExporter.js +559 -0
- package/src/export/index.js +14 -0
- package/src/export/systems/TradingCardSystemFaceted.js +494 -0
- package/src/export/systems/TradingCardSystemHolographic.js +452 -0
- package/src/export/systems/TradingCardSystemQuantum.js +411 -0
- package/src/faceted/FacetedSystem.js +963 -0
- package/src/features/CollectionManager.js +433 -0
- package/src/gallery/CollectionManager.js +240 -0
- package/src/gallery/GallerySystem.js +485 -0
- package/src/geometry/GeometryFactory.js +314 -0
- package/src/geometry/GeometryLibrary.js +72 -0
- package/src/geometry/buffers/BufferBuilder.js +338 -0
- package/src/geometry/buffers/index.js +18 -0
- package/src/geometry/generators/Crystal.js +420 -0
- package/src/geometry/generators/Fractal.js +298 -0
- package/src/geometry/generators/KleinBottle.js +197 -0
- package/src/geometry/generators/Sphere.js +192 -0
- package/src/geometry/generators/Tesseract.js +160 -0
- package/src/geometry/generators/Tetrahedron.js +225 -0
- package/src/geometry/generators/Torus.js +304 -0
- package/src/geometry/generators/Wave.js +341 -0
- package/src/geometry/index.js +142 -0
- package/src/geometry/warp/HypersphereCore.js +211 -0
- package/src/geometry/warp/HypertetraCore.js +386 -0
- package/src/geometry/warp/index.js +57 -0
- package/src/holograms/HolographicVisualizer.js +1073 -0
- package/src/holograms/RealHolographicSystem.js +966 -0
- package/src/holograms/variantRegistry.js +69 -0
- package/src/integrations/FigmaPlugin.js +854 -0
- package/src/integrations/OBSMode.js +754 -0
- package/src/integrations/ThreeJsPackage.js +660 -0
- package/src/integrations/TouchDesignerExport.js +552 -0
- package/src/integrations/frameworks/Vib3React.js +591 -0
- package/src/integrations/frameworks/Vib3Svelte.js +654 -0
- package/src/integrations/frameworks/Vib3Vue.js +628 -0
- package/src/llm/LLMParameterInterface.js +240 -0
- package/src/llm/LLMParameterUI.js +577 -0
- package/src/math/Mat4x4.js +708 -0
- package/src/math/Projection.js +341 -0
- package/src/math/Rotor4D.js +637 -0
- package/src/math/Vec4.js +476 -0
- package/src/math/constants.js +164 -0
- package/src/math/index.js +68 -0
- package/src/math/projections.js +54 -0
- package/src/math/rotations.js +196 -0
- package/src/quantum/QuantumEngine.js +906 -0
- package/src/quantum/QuantumVisualizer.js +1103 -0
- package/src/reactivity/ReactivityConfig.js +499 -0
- package/src/reactivity/ReactivityManager.js +586 -0
- package/src/reactivity/SpatialInputSystem.js +1783 -0
- package/src/reactivity/index.js +93 -0
- package/src/render/CommandBuffer.js +465 -0
- package/src/render/MultiCanvasBridge.js +340 -0
- package/src/render/RenderCommand.js +514 -0
- package/src/render/RenderResourceRegistry.js +523 -0
- package/src/render/RenderState.js +552 -0
- package/src/render/RenderTarget.js +512 -0
- package/src/render/ShaderLoader.js +253 -0
- package/src/render/ShaderProgram.js +599 -0
- package/src/render/UnifiedRenderBridge.js +496 -0
- package/src/render/backends/WebGLBackend.js +1108 -0
- package/src/render/backends/WebGPUBackend.js +1409 -0
- package/src/render/commands/CommandBufferExecutor.js +607 -0
- package/src/render/commands/RenderCommandBuffer.js +661 -0
- package/src/render/commands/index.js +17 -0
- package/src/render/index.js +367 -0
- package/src/scene/Disposable.js +498 -0
- package/src/scene/MemoryPool.js +618 -0
- package/src/scene/Node4D.js +697 -0
- package/src/scene/ResourceManager.js +599 -0
- package/src/scene/Scene4D.js +540 -0
- package/src/scene/index.js +98 -0
- package/src/schemas/error.schema.json +84 -0
- package/src/schemas/extension.schema.json +88 -0
- package/src/schemas/index.js +214 -0
- package/src/schemas/parameters.schema.json +142 -0
- package/src/schemas/tool-pack.schema.json +44 -0
- package/src/schemas/tool-response.schema.json +127 -0
- package/src/shaders/common/fullscreen.vert.glsl +5 -0
- package/src/shaders/common/fullscreen.vert.wgsl +17 -0
- package/src/shaders/common/geometry24.glsl +65 -0
- package/src/shaders/common/geometry24.wgsl +54 -0
- package/src/shaders/common/rotation4d.glsl +85 -0
- package/src/shaders/common/rotation4d.wgsl +86 -0
- package/src/shaders/common/uniforms.glsl +44 -0
- package/src/shaders/common/uniforms.wgsl +48 -0
- package/src/shaders/faceted/faceted.frag.glsl +129 -0
- package/src/shaders/faceted/faceted.frag.wgsl +164 -0
- package/src/shaders/holographic/holographic.frag.glsl +406 -0
- package/src/shaders/holographic/holographic.frag.wgsl +185 -0
- package/src/shaders/quantum/quantum.frag.glsl +513 -0
- package/src/shaders/quantum/quantum.frag.wgsl +361 -0
- package/src/testing/ParallelTestFramework.js +519 -0
- package/src/testing/__snapshots__/exportFormats.test.js.snap +24 -0
- package/src/testing/exportFormats.test.js +8 -0
- package/src/testing/projections.test.js +14 -0
- package/src/testing/rotations.test.js +37 -0
- package/src/ui/InteractivityMenu.js +516 -0
- package/src/ui/StatusManager.js +96 -0
- package/src/ui/adaptive/renderers/webgpu/BufferLayout.ts +252 -0
- package/src/ui/adaptive/renderers/webgpu/PolytopeInstanceBuffer.ts +144 -0
- package/src/ui/adaptive/renderers/webgpu/TripleBufferedUniform.ts +170 -0
- package/src/ui/adaptive/renderers/webgpu/WebGPURenderer.ts +735 -0
- package/src/ui/adaptive/renderers/webgpu/index.ts +112 -0
- package/src/variations/VariationManager.js +431 -0
- package/src/viewer/AudioReactivity.js +505 -0
- package/src/viewer/CardBending.js +481 -0
- package/src/viewer/GalleryUI.js +832 -0
- package/src/viewer/ReactivityManager.js +590 -0
- package/src/viewer/TradingCardExporter.js +600 -0
- package/src/viewer/ViewerPortal.js +374 -0
- package/src/viewer/index.js +12 -0
- package/src/wasm/WasmLoader.js +296 -0
- package/src/wasm/index.js +132 -0
- package/tools/agentic/mcpTools.js +88 -0
- package/tools/cli/agent-cli.js +92 -0
- package/tools/export/formats.js +24 -0
- package/tools/math/rotation-baseline.mjs +64 -0
- package/tools/shader-sync-verify.js +937 -0
- package/tools/telemetry/manifestPipeline.js +141 -0
- package/tools/telemetry/telemetryEvents.js +35 -0
- package/types/adaptive-sdk.d.ts +185 -0
- package/types/advanced/AIPresetGenerator.d.ts +81 -0
- package/types/advanced/MIDIController.d.ts +100 -0
- package/types/advanced/OffscreenWorker.d.ts +82 -0
- package/types/advanced/WebGPUCompute.d.ts +52 -0
- package/types/advanced/WebXRRenderer.d.ts +77 -0
- package/types/advanced/index.d.ts +46 -0
- package/types/core/ErrorReporter.d.ts +50 -0
- package/types/core/VIB3Engine.d.ts +204 -0
- package/types/creative/ColorPresetsSystem.d.ts +91 -0
- package/types/creative/ParameterTimeline.d.ts +74 -0
- package/types/creative/PostProcessingPipeline.d.ts +109 -0
- package/types/creative/TransitionAnimator.d.ts +71 -0
- package/types/creative/index.d.ts +35 -0
- package/types/integrations/FigmaPlugin.d.ts +46 -0
- package/types/integrations/OBSMode.d.ts +74 -0
- package/types/integrations/ThreeJsPackage.d.ts +62 -0
- package/types/integrations/TouchDesignerExport.d.ts +36 -0
- package/types/integrations/Vib3React.d.ts +74 -0
- package/types/integrations/Vib3Svelte.d.ts +63 -0
- package/types/integrations/Vib3Vue.d.ts +55 -0
- package/types/integrations/index.d.ts +52 -0
- package/types/reactivity/SpatialInputSystem.d.ts +173 -0
- package/types/reactivity/index.d.ts +394 -0
- package/types/render/CommandBuffer.d.ts +169 -0
- package/types/render/RenderCommand.d.ts +312 -0
- package/types/render/RenderState.d.ts +279 -0
- package/types/render/RenderTarget.d.ts +254 -0
- package/types/render/ShaderProgram.d.ts +277 -0
- package/types/render/UnifiedRenderBridge.d.ts +143 -0
- package/types/render/WebGLBackend.d.ts +168 -0
- package/types/render/WebGPUBackend.d.ts +186 -0
- package/types/render/index.d.ts +141 -0
|
@@ -0,0 +1,1783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIB3+ SpatialInputSystem
|
|
3
|
+
*
|
|
4
|
+
* Evolved spatial orientation input abstraction that maps ANY input source
|
|
5
|
+
* to visualization parameters. Provides universal "card tilting" behavior
|
|
6
|
+
* decoupled from physical device tilt -- mouse, gamepad, gyroscope, audio,
|
|
7
|
+
* MIDI, AR perspective, and programmatic API all feed the same spatial state.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* Input Sources --> Spatial State (pitch/yaw/roll/x/y/z) --> Smoothing --> Mapping --> Parameter Updates
|
|
11
|
+
*
|
|
12
|
+
* @module reactivity/SpatialInputSystem
|
|
13
|
+
* @version 1.0.0
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { TARGETABLE_PARAMETERS, BLEND_MODES } from './ReactivityConfig.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recognised input source types.
|
|
24
|
+
* @readonly
|
|
25
|
+
* @enum {string}
|
|
26
|
+
*/
|
|
27
|
+
export const SOURCE_TYPES = Object.freeze({
|
|
28
|
+
DEVICE_TILT: 'deviceTilt',
|
|
29
|
+
MOUSE_POSITION: 'mousePosition',
|
|
30
|
+
GYROSCOPE: 'gyroscope',
|
|
31
|
+
GAMEPAD: 'gamepad',
|
|
32
|
+
PERSPECTIVE: 'perspective',
|
|
33
|
+
PROGRAMMATIC: 'programmatic',
|
|
34
|
+
AUDIO: 'audio',
|
|
35
|
+
MIDI: 'midi'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Named spatial axes that form the normalised intermediate state.
|
|
40
|
+
* All values are kept in the range -1 .. 1 (translations) or -1 .. 1
|
|
41
|
+
* (rotational axes). `intensity` and `velocity` are 0 .. 1.
|
|
42
|
+
* @readonly
|
|
43
|
+
* @enum {string}
|
|
44
|
+
*/
|
|
45
|
+
export const SPATIAL_AXES = Object.freeze([
|
|
46
|
+
'pitch', 'yaw', 'roll',
|
|
47
|
+
'x', 'y', 'z',
|
|
48
|
+
'intensity', 'velocity'
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Dramatic mode amplification factor (matches legacy DeviceTiltHandler).
|
|
53
|
+
* @constant {number}
|
|
54
|
+
*/
|
|
55
|
+
const DRAMATIC_MULTIPLIER = 8;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Maximum number of concurrently registered input sources.
|
|
59
|
+
* @constant {number}
|
|
60
|
+
*/
|
|
61
|
+
const MAX_SOURCES = 32;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Maximum number of active mappings.
|
|
65
|
+
* @constant {number}
|
|
66
|
+
*/
|
|
67
|
+
const MAX_MAPPINGS = 256;
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clamp a number between min and max.
|
|
75
|
+
* @param {number} v
|
|
76
|
+
* @param {number} lo
|
|
77
|
+
* @param {number} hi
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
function clamp(v, lo, hi) {
|
|
81
|
+
return v < lo ? lo : v > hi ? hi : v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Linear interpolation.
|
|
86
|
+
* @param {number} a - Start value
|
|
87
|
+
* @param {number} b - End value
|
|
88
|
+
* @param {number} t - Interpolant (0..1)
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
function lerp(a, b, t) {
|
|
92
|
+
return a + (b - a) * t;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a fresh zero-valued spatial state object.
|
|
97
|
+
* @returns {SpatialState}
|
|
98
|
+
*/
|
|
99
|
+
function createSpatialState() {
|
|
100
|
+
return {
|
|
101
|
+
pitch: 0,
|
|
102
|
+
yaw: 0,
|
|
103
|
+
roll: 0,
|
|
104
|
+
x: 0,
|
|
105
|
+
y: 0,
|
|
106
|
+
z: 0,
|
|
107
|
+
intensity: 0,
|
|
108
|
+
velocity: 0
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Type definitions (JSDoc only -- no TypeScript)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {Object} SpatialState
|
|
118
|
+
* @property {number} pitch - Forward/back tilt (-1..1)
|
|
119
|
+
* @property {number} yaw - Left/right rotation (-1..1)
|
|
120
|
+
* @property {number} roll - Left/right tilt (-1..1)
|
|
121
|
+
* @property {number} x - Translation X (-1..1)
|
|
122
|
+
* @property {number} y - Translation Y (-1..1)
|
|
123
|
+
* @property {number} z - Translation Z / depth (-1..1)
|
|
124
|
+
* @property {number} intensity - Overall spatial intensity (0..1)
|
|
125
|
+
* @property {number} velocity - Rate of change (0..1)
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {Object} MappingEntry
|
|
130
|
+
* @property {string} axis - Source spatial axis name
|
|
131
|
+
* @property {string} target - Target VIB3+ parameter name
|
|
132
|
+
* @property {number} scale - Multiplier applied to axis value
|
|
133
|
+
* @property {number[]} [clamp] - Optional [min, max] to clamp mapped output
|
|
134
|
+
* @property {string} [blendMode] - One of BLEND_MODES ('add'|'replace'|'multiply'|'max'|'min')
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @typedef {Object} ProfileDef
|
|
139
|
+
* @property {string} name - Human-readable name
|
|
140
|
+
* @property {string} description - What this profile does
|
|
141
|
+
* @property {MappingEntry[]} mappings - Array of axis-to-parameter mappings
|
|
142
|
+
* @property {string[]} sources - Recommended input source types
|
|
143
|
+
* @property {number} smoothing - Suggested smoothing factor (0..1)
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @typedef {Object} SourceEntry
|
|
148
|
+
* @property {string} name - Unique source name
|
|
149
|
+
* @property {string} type - One of SOURCE_TYPES
|
|
150
|
+
* @property {Object} config - Source-specific configuration
|
|
151
|
+
* @property {boolean} active - Whether this source is currently feeding data
|
|
152
|
+
* @property {Function|null} _cleanup - Cleanup function for event listeners
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @typedef {Object} SpatialInputOptions
|
|
157
|
+
* @property {number} [sensitivity=1.0] - Global sensitivity multiplier
|
|
158
|
+
* @property {number} [smoothing=0.15] - Default smoothing factor (0..1)
|
|
159
|
+
* @property {Function} [onParameterUpdate] - Callback invoked as fn(paramName, value)
|
|
160
|
+
* @property {boolean} [autoRegisterGlobals=true] - Expose window helpers
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// SpatialInputSystem
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* SpatialInputSystem -- universal spatial orientation input for VIB3+.
|
|
169
|
+
*
|
|
170
|
+
* Abstracts spatial orientation from any combination of input sources
|
|
171
|
+
* (device tilt, mouse, gamepad, gyroscope, audio, MIDI, AR perspective,
|
|
172
|
+
* programmatic API) and maps the resulting spatial state to any VIB3+
|
|
173
|
+
* visualisation parameter.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* const spatial = new SpatialInputSystem({
|
|
177
|
+
* onParameterUpdate: (name, value) => engine.setParameter(name, value),
|
|
178
|
+
* smoothing: 0.12
|
|
179
|
+
* });
|
|
180
|
+
* spatial.addSource('tilt', 'deviceTilt');
|
|
181
|
+
* spatial.addSource('mouse', 'mousePosition');
|
|
182
|
+
* spatial.loadProfile('cardTilt');
|
|
183
|
+
* spatial.enable();
|
|
184
|
+
*/
|
|
185
|
+
export class SpatialInputSystem {
|
|
186
|
+
|
|
187
|
+
// ------------------------------------------------------------------
|
|
188
|
+
// Construction
|
|
189
|
+
// ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create a new SpatialInputSystem.
|
|
193
|
+
* @param {SpatialInputOptions} [options={}]
|
|
194
|
+
*/
|
|
195
|
+
constructor(options = {}) {
|
|
196
|
+
/** @type {Map<string, SourceEntry>} Active input sources keyed by name */
|
|
197
|
+
this.sources = new Map();
|
|
198
|
+
|
|
199
|
+
/** @type {Map<string, MappingEntry>} Active mappings keyed by "axis->target" */
|
|
200
|
+
this.mappings = new Map();
|
|
201
|
+
|
|
202
|
+
/** @type {Map<string, ProfileDef>} Named mapping profiles */
|
|
203
|
+
this.profiles = new Map();
|
|
204
|
+
|
|
205
|
+
/** @type {Map<string, number>} Per-axis smoothing state */
|
|
206
|
+
this.smoothing = new Map();
|
|
207
|
+
|
|
208
|
+
/** @type {boolean} Whether the frame loop is running */
|
|
209
|
+
this.enabled = false;
|
|
210
|
+
|
|
211
|
+
/** @type {number} Global sensitivity multiplier (0.1 .. 10) */
|
|
212
|
+
this.sensitivity = clamp(Number(options.sensitivity) || 1.0, 0.1, 10);
|
|
213
|
+
|
|
214
|
+
/** @type {number} Default smoothing factor (0 .. 1) */
|
|
215
|
+
this.smoothingFactor = clamp(Number(options.smoothing) || 0.15, 0, 1);
|
|
216
|
+
|
|
217
|
+
/** @type {boolean} Whether dramatic mode (8x amplification) is active */
|
|
218
|
+
this.dramaticMode = false;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Callback invoked when a mapped parameter should be updated.
|
|
222
|
+
* Signature: `(parameterName: string, value: number) => void`
|
|
223
|
+
* @type {Function|null}
|
|
224
|
+
*/
|
|
225
|
+
this.parameterUpdateFn = typeof options.onParameterUpdate === 'function'
|
|
226
|
+
? options.onParameterUpdate
|
|
227
|
+
: null;
|
|
228
|
+
|
|
229
|
+
/** @type {Set<string>} Custom user-defined target names beyond TARGETABLE_PARAMETERS */
|
|
230
|
+
this._customTargets = new Set();
|
|
231
|
+
|
|
232
|
+
// -- Spatial state (raw, pre-smoothing) --
|
|
233
|
+
/** @type {SpatialState} */
|
|
234
|
+
this.spatialState = createSpatialState();
|
|
235
|
+
|
|
236
|
+
// -- Smoothed output state --
|
|
237
|
+
/** @type {SpatialState} */
|
|
238
|
+
this.smoothedState = createSpatialState();
|
|
239
|
+
|
|
240
|
+
// -- Previous smoothed state (for velocity calculation) --
|
|
241
|
+
/** @private @type {SpatialState} */
|
|
242
|
+
this._prevSmoothedState = createSpatialState();
|
|
243
|
+
|
|
244
|
+
// -- Frame loop bookkeeping --
|
|
245
|
+
/** @private */
|
|
246
|
+
this._frameId = null;
|
|
247
|
+
/** @private */
|
|
248
|
+
this._lastFrameTime = 0;
|
|
249
|
+
|
|
250
|
+
// -- Event listeners --
|
|
251
|
+
/** @private @type {Map<string, Function[]>} */
|
|
252
|
+
this._listeners = new Map();
|
|
253
|
+
|
|
254
|
+
// -- Bound handlers stored for cleanup --
|
|
255
|
+
/** @private */
|
|
256
|
+
this._boundDeviceOrientation = null;
|
|
257
|
+
/** @private */
|
|
258
|
+
this._boundMouseMove = null;
|
|
259
|
+
/** @private */
|
|
260
|
+
this._boundGamepadPoll = null;
|
|
261
|
+
/** @private */
|
|
262
|
+
this._gyroscopeSensor = null;
|
|
263
|
+
|
|
264
|
+
// Initialise built-in profiles
|
|
265
|
+
this._initBuiltInProfiles();
|
|
266
|
+
|
|
267
|
+
/** @type {string|null} Name of the currently active profile */
|
|
268
|
+
this.activeProfile = null;
|
|
269
|
+
|
|
270
|
+
// Optionally register global helpers
|
|
271
|
+
if (options.autoRegisterGlobals !== false && typeof window !== 'undefined') {
|
|
272
|
+
this._registerGlobals();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
console.log('SpatialInputSystem: Initialised');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ------------------------------------------------------------------
|
|
279
|
+
// Global browser helpers
|
|
280
|
+
// ------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Register convenience functions on the window object.
|
|
284
|
+
* @private
|
|
285
|
+
*/
|
|
286
|
+
_registerGlobals() {
|
|
287
|
+
if (typeof window === 'undefined') return;
|
|
288
|
+
|
|
289
|
+
window.spatialInputSystem = this;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Enable spatial input with an optional profile.
|
|
293
|
+
* @param {string} [profile]
|
|
294
|
+
*/
|
|
295
|
+
window.enableSpatialInput = (profile) => {
|
|
296
|
+
if (profile) this.loadProfile(profile);
|
|
297
|
+
this.enable();
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Disable spatial input processing.
|
|
302
|
+
*/
|
|
303
|
+
window.disableSpatialInput = () => {
|
|
304
|
+
this.disable();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Switch the active spatial profile.
|
|
309
|
+
* @param {string} name
|
|
310
|
+
*/
|
|
311
|
+
window.setSpatialProfile = (name) => {
|
|
312
|
+
this.loadProfile(name);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Feed manual spatial data (programmatic source).
|
|
317
|
+
* @param {Partial<SpatialState>} data
|
|
318
|
+
*/
|
|
319
|
+
window.feedSpatialInput = (data) => {
|
|
320
|
+
this.feedInput(SOURCE_TYPES.PROGRAMMATIC, data);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get the current smoothed spatial state.
|
|
325
|
+
* @returns {SpatialState}
|
|
326
|
+
*/
|
|
327
|
+
window.getSpatialState = () => {
|
|
328
|
+
return this.getState();
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ------------------------------------------------------------------
|
|
333
|
+
// Built-in profiles
|
|
334
|
+
// ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Register all built-in mapping profiles.
|
|
338
|
+
* @private
|
|
339
|
+
*/
|
|
340
|
+
_initBuiltInProfiles() {
|
|
341
|
+
|
|
342
|
+
// ---- Card Tilt ----
|
|
343
|
+
this.profiles.set('cardTilt', {
|
|
344
|
+
name: 'Card Tilt',
|
|
345
|
+
description: 'Physical tilt controls 4D hyperspace rotation',
|
|
346
|
+
mappings: [
|
|
347
|
+
{ axis: 'pitch', target: 'rot4dXW', scale: 0.02, clamp: [-1.5, 1.5], blendMode: 'replace' },
|
|
348
|
+
{ axis: 'roll', target: 'rot4dYW', scale: 0.025, clamp: [-1.5, 1.5], blendMode: 'replace' },
|
|
349
|
+
{ axis: 'yaw', target: 'rot4dZW', scale: 0.015, clamp: [-2.0, 2.0], blendMode: 'replace' },
|
|
350
|
+
{ axis: 'intensity', target: 'chaos', scale: 0.3, clamp: [0, 1], blendMode: 'replace' }
|
|
351
|
+
],
|
|
352
|
+
sources: [SOURCE_TYPES.DEVICE_TILT, SOURCE_TYPES.MOUSE_POSITION],
|
|
353
|
+
smoothing: 0.12
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ---- Wearable Perspective ----
|
|
357
|
+
this.profiles.set('wearablePerspective', {
|
|
358
|
+
name: 'Wearable Perspective',
|
|
359
|
+
description: 'Viewing angle affects visuals without physical device tilt; designed for AR glasses and headsets',
|
|
360
|
+
mappings: [
|
|
361
|
+
{ axis: 'pitch', target: 'rot4dXW', scale: 0.015, clamp: [-1.0, 1.0], blendMode: 'replace' },
|
|
362
|
+
{ axis: 'yaw', target: 'rot4dXY', scale: 0.02, clamp: [-3.14, 3.14], blendMode: 'replace' },
|
|
363
|
+
{ axis: 'roll', target: 'rot4dYW', scale: 0.01, clamp: [-0.8, 0.8], blendMode: 'replace' },
|
|
364
|
+
{ axis: 'z', target: 'dimension', scale: 0.5, clamp: [3.0, 4.5], blendMode: 'replace' },
|
|
365
|
+
{ axis: 'intensity', target: 'intensity', scale: 0.4, clamp: [0.2, 1.0], blendMode: 'replace' }
|
|
366
|
+
],
|
|
367
|
+
sources: [SOURCE_TYPES.PERSPECTIVE, SOURCE_TYPES.DEVICE_TILT],
|
|
368
|
+
smoothing: 0.2
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ---- Game Asset ----
|
|
372
|
+
this.profiles.set('gameAsset', {
|
|
373
|
+
name: 'Game Asset',
|
|
374
|
+
description: 'Game object orientation in world space maps to all 6 rotation planes',
|
|
375
|
+
mappings: [
|
|
376
|
+
{ axis: 'pitch', target: 'rot4dYZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
|
|
377
|
+
{ axis: 'yaw', target: 'rot4dXZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
|
|
378
|
+
{ axis: 'roll', target: 'rot4dXY', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
|
|
379
|
+
{ axis: 'x', target: 'rot4dXW', scale: 0.5, clamp: [-2.0, 2.0], blendMode: 'replace' },
|
|
380
|
+
{ axis: 'y', target: 'rot4dYW', scale: 0.5, clamp: [-2.0, 2.0], blendMode: 'replace' },
|
|
381
|
+
{ axis: 'z', target: 'rot4dZW', scale: 0.5, clamp: [-2.0, 2.0], blendMode: 'replace' }
|
|
382
|
+
],
|
|
383
|
+
sources: [SOURCE_TYPES.PROGRAMMATIC, SOURCE_TYPES.GAMEPAD],
|
|
384
|
+
smoothing: 0.08
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ---- VJ Audio Spatial ----
|
|
388
|
+
this.profiles.set('vjAudioSpatial', {
|
|
389
|
+
name: 'VJ Audio Spatial',
|
|
390
|
+
description: 'Audio energy creates spatial movement for live VJ performance',
|
|
391
|
+
mappings: [
|
|
392
|
+
{ axis: 'pitch', target: 'rot4dXW', scale: 0.8, clamp: [-1.5, 1.5], blendMode: 'add' },
|
|
393
|
+
{ axis: 'yaw', target: 'rot4dYW', scale: 0.6, clamp: [-1.5, 1.5], blendMode: 'add' },
|
|
394
|
+
{ axis: 'roll', target: 'rot4dZW', scale: 0.5, clamp: [-1.0, 1.0], blendMode: 'add' },
|
|
395
|
+
{ axis: 'intensity', target: 'morphFactor', scale: 1.5, clamp: [0, 2], blendMode: 'replace' },
|
|
396
|
+
{ axis: 'velocity', target: 'chaos', scale: 0.8, clamp: [0, 1], blendMode: 'replace' },
|
|
397
|
+
{ axis: 'x', target: 'hue', scale: 180, clamp: [0, 360], blendMode: 'add' },
|
|
398
|
+
{ axis: 'y', target: 'speed', scale: 2.0, clamp: [0.1, 3.0], blendMode: 'replace' }
|
|
399
|
+
],
|
|
400
|
+
sources: [SOURCE_TYPES.AUDIO, SOURCE_TYPES.MIDI],
|
|
401
|
+
smoothing: 0.08
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ---- UI Element ----
|
|
405
|
+
this.profiles.set('uiElement', {
|
|
406
|
+
name: 'UI Element',
|
|
407
|
+
description: 'Mouse and scroll position changes visuals without physical movement',
|
|
408
|
+
mappings: [
|
|
409
|
+
{ axis: 'pitch', target: 'rot4dXW', scale: 0.8, clamp: [-1.0, 1.0], blendMode: 'replace' },
|
|
410
|
+
{ axis: 'roll', target: 'rot4dYW', scale: 0.8, clamp: [-1.0, 1.0], blendMode: 'replace' },
|
|
411
|
+
{ axis: 'yaw', target: 'rot4dXY', scale: 0.5, clamp: [-1.5, 1.5], blendMode: 'replace' },
|
|
412
|
+
{ axis: 'z', target: 'dimension', scale: 0.3, clamp: [3.0, 4.5], blendMode: 'add' },
|
|
413
|
+
{ axis: 'intensity', target: 'intensity', scale: 0.5, clamp: [0.1, 1.0], blendMode: 'replace' }
|
|
414
|
+
],
|
|
415
|
+
sources: [SOURCE_TYPES.MOUSE_POSITION],
|
|
416
|
+
smoothing: 0.18
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ---- Immersive XR ----
|
|
420
|
+
this.profiles.set('immersiveXR', {
|
|
421
|
+
name: 'Immersive XR',
|
|
422
|
+
description: 'Full 6DOF mapping for WebXR headsets and controllers',
|
|
423
|
+
mappings: [
|
|
424
|
+
// Rotational axes map to all 6 rotation planes
|
|
425
|
+
{ axis: 'pitch', target: 'rot4dYZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
|
|
426
|
+
{ axis: 'yaw', target: 'rot4dXZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
|
|
427
|
+
{ axis: 'roll', target: 'rot4dXY', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
|
|
428
|
+
// Translational axes map to 4D hyperspace rotations
|
|
429
|
+
{ axis: 'x', target: 'rot4dXW', scale: 1.0, clamp: [-2.0, 2.0], blendMode: 'replace' },
|
|
430
|
+
{ axis: 'y', target: 'rot4dYW', scale: 1.0, clamp: [-2.0, 2.0], blendMode: 'replace' },
|
|
431
|
+
{ axis: 'z', target: 'rot4dZW', scale: 1.0, clamp: [-2.0, 2.0], blendMode: 'replace' },
|
|
432
|
+
// Movement intensity drives visual intensity
|
|
433
|
+
{ axis: 'intensity', target: 'intensity', scale: 0.6, clamp: [0.2, 1.0], blendMode: 'replace' },
|
|
434
|
+
{ axis: 'velocity', target: 'speed', scale: 2.0, clamp: [0.1, 3.0], blendMode: 'replace' }
|
|
435
|
+
],
|
|
436
|
+
sources: [SOURCE_TYPES.PERSPECTIVE, SOURCE_TYPES.GYROSCOPE, SOURCE_TYPES.GAMEPAD],
|
|
437
|
+
smoothing: 0.06
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ------------------------------------------------------------------
|
|
442
|
+
// Source management
|
|
443
|
+
// ------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Register an input source.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} name - Unique identifier for this source instance
|
|
449
|
+
* @param {string} type - One of SOURCE_TYPES values
|
|
450
|
+
* @param {Object} [config={}] - Source-specific configuration
|
|
451
|
+
* @returns {boolean} True if the source was added successfully
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* spatial.addSource('tilt', 'deviceTilt');
|
|
455
|
+
* spatial.addSource('mouse', 'mousePosition', { element: myCanvas });
|
|
456
|
+
* spatial.addSource('pad', 'gamepad', { index: 0, deadzone: 0.1 });
|
|
457
|
+
*/
|
|
458
|
+
addSource(name, type, config = {}) {
|
|
459
|
+
if (!name || typeof name !== 'string') {
|
|
460
|
+
console.warn('SpatialInputSystem.addSource: name must be a non-empty string');
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
if (!Object.values(SOURCE_TYPES).includes(type)) {
|
|
464
|
+
console.warn(`SpatialInputSystem.addSource: unknown source type "${type}"`);
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
if (this.sources.size >= MAX_SOURCES) {
|
|
468
|
+
console.warn('SpatialInputSystem.addSource: maximum source count reached');
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Remove existing source with same name first
|
|
473
|
+
if (this.sources.has(name)) {
|
|
474
|
+
this.removeSource(name);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** @type {SourceEntry} */
|
|
478
|
+
const entry = {
|
|
479
|
+
name,
|
|
480
|
+
type,
|
|
481
|
+
config: { ...config },
|
|
482
|
+
active: true,
|
|
483
|
+
_cleanup: null
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
this.sources.set(name, entry);
|
|
487
|
+
|
|
488
|
+
// If already enabled, start listening immediately
|
|
489
|
+
if (this.enabled) {
|
|
490
|
+
this._attachSourceListeners(entry);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this._emit('sourceAdded', { name, type });
|
|
494
|
+
console.log(`SpatialInputSystem: Source added "${name}" (${type})`);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Remove an input source and clean up its listeners.
|
|
500
|
+
* @param {string} name
|
|
501
|
+
* @returns {boolean} True if a source was removed
|
|
502
|
+
*/
|
|
503
|
+
removeSource(name) {
|
|
504
|
+
const entry = this.sources.get(name);
|
|
505
|
+
if (!entry) return false;
|
|
506
|
+
|
|
507
|
+
this._detachSourceListeners(entry);
|
|
508
|
+
this.sources.delete(name);
|
|
509
|
+
|
|
510
|
+
this._emit('sourceRemoved', { name, type: entry.type });
|
|
511
|
+
console.log(`SpatialInputSystem: Source removed "${name}"`);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Check whether a source with the given name is registered.
|
|
517
|
+
* @param {string} name
|
|
518
|
+
* @returns {boolean}
|
|
519
|
+
*/
|
|
520
|
+
hasSource(name) {
|
|
521
|
+
return this.sources.has(name);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Get a list of all registered source names and types.
|
|
526
|
+
* @returns {{ name: string, type: string, active: boolean }[]}
|
|
527
|
+
*/
|
|
528
|
+
listSources() {
|
|
529
|
+
const list = [];
|
|
530
|
+
for (const [name, entry] of this.sources) {
|
|
531
|
+
list.push({ name, type: entry.type, active: entry.active });
|
|
532
|
+
}
|
|
533
|
+
return list;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ------------------------------------------------------------------
|
|
537
|
+
// Mapping management
|
|
538
|
+
// ------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Map a spatial axis to a VIB3+ parameter (or custom target).
|
|
542
|
+
*
|
|
543
|
+
* @param {string} sourceAxis - Spatial axis name (e.g. 'pitch', 'yaw')
|
|
544
|
+
* @param {string} targetParam - VIB3+ parameter name (e.g. 'rot4dXW')
|
|
545
|
+
* @param {number} [scale=1.0] - Scaling multiplier
|
|
546
|
+
* @param {number[]} [clampRange=null] - Optional [min, max]
|
|
547
|
+
* @param {string} [blendMode='replace'] - One of BLEND_MODES
|
|
548
|
+
* @returns {boolean} True if the mapping was added
|
|
549
|
+
*/
|
|
550
|
+
setMapping(sourceAxis, targetParam, scale = 1.0, clampRange = null, blendMode = 'replace') {
|
|
551
|
+
if (!SPATIAL_AXES.includes(sourceAxis)) {
|
|
552
|
+
console.warn(`SpatialInputSystem.setMapping: unknown axis "${sourceAxis}"`);
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
if (!this._isValidTarget(targetParam)) {
|
|
556
|
+
console.warn(`SpatialInputSystem.setMapping: unknown target "${targetParam}"`);
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
if (!BLEND_MODES.includes(blendMode)) {
|
|
560
|
+
console.warn(`SpatialInputSystem.setMapping: unknown blend mode "${blendMode}", defaulting to "replace"`);
|
|
561
|
+
blendMode = 'replace';
|
|
562
|
+
}
|
|
563
|
+
if (this.mappings.size >= MAX_MAPPINGS) {
|
|
564
|
+
console.warn('SpatialInputSystem.setMapping: maximum mapping count reached');
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const key = `${sourceAxis}->${targetParam}`;
|
|
569
|
+
|
|
570
|
+
/** @type {MappingEntry} */
|
|
571
|
+
const entry = {
|
|
572
|
+
axis: sourceAxis,
|
|
573
|
+
target: targetParam,
|
|
574
|
+
scale: Number.isFinite(scale) ? scale : 1.0,
|
|
575
|
+
clamp: Array.isArray(clampRange) && clampRange.length === 2 ? clampRange : null,
|
|
576
|
+
blendMode
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
this.mappings.set(key, entry);
|
|
580
|
+
this._emit('mappingChanged', entry);
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Remove a specific axis-to-parameter mapping.
|
|
586
|
+
* @param {string} sourceAxis
|
|
587
|
+
* @param {string} targetParam
|
|
588
|
+
* @returns {boolean}
|
|
589
|
+
*/
|
|
590
|
+
removeMapping(sourceAxis, targetParam) {
|
|
591
|
+
const key = `${sourceAxis}->${targetParam}`;
|
|
592
|
+
const removed = this.mappings.delete(key);
|
|
593
|
+
if (removed) {
|
|
594
|
+
this._emit('mappingRemoved', { axis: sourceAxis, target: targetParam });
|
|
595
|
+
}
|
|
596
|
+
return removed;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Clear all active mappings.
|
|
601
|
+
*/
|
|
602
|
+
clearMappings() {
|
|
603
|
+
this.mappings.clear();
|
|
604
|
+
this._emit('mappingsCleared');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Get all current mappings as an array.
|
|
609
|
+
* @returns {MappingEntry[]}
|
|
610
|
+
*/
|
|
611
|
+
listMappings() {
|
|
612
|
+
return Array.from(this.mappings.values());
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ------------------------------------------------------------------
|
|
616
|
+
// Profiles
|
|
617
|
+
// ------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Load a mapping profile by name, replacing all current mappings.
|
|
621
|
+
*
|
|
622
|
+
* @param {string} profileName
|
|
623
|
+
* @returns {boolean} True if the profile was loaded
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* spatial.loadProfile('cardTilt');
|
|
627
|
+
*/
|
|
628
|
+
loadProfile(profileName) {
|
|
629
|
+
const profile = this.profiles.get(profileName);
|
|
630
|
+
if (!profile) {
|
|
631
|
+
console.warn(`SpatialInputSystem.loadProfile: unknown profile "${profileName}"`);
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Clear existing mappings
|
|
636
|
+
this.clearMappings();
|
|
637
|
+
|
|
638
|
+
// Apply profile smoothing
|
|
639
|
+
if (typeof profile.smoothing === 'number') {
|
|
640
|
+
this.smoothingFactor = clamp(profile.smoothing, 0, 1);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Install all mappings from the profile
|
|
644
|
+
for (const m of profile.mappings) {
|
|
645
|
+
this.setMapping(m.axis, m.target, m.scale, m.clamp || null, m.blendMode || 'replace');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
this.activeProfile = profileName;
|
|
649
|
+
this._emit('profileLoaded', { name: profileName, profile });
|
|
650
|
+
console.log(`SpatialInputSystem: Profile loaded "${profileName}"`);
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Create (or overwrite) a custom mapping profile.
|
|
656
|
+
*
|
|
657
|
+
* @param {string} name - Profile identifier
|
|
658
|
+
* @param {MappingEntry[]} mappings - Array of mapping definitions
|
|
659
|
+
* @param {Object} [meta={}] - Optional metadata
|
|
660
|
+
* @param {string} [meta.description]
|
|
661
|
+
* @param {string[]} [meta.sources]
|
|
662
|
+
* @param {number} [meta.smoothing]
|
|
663
|
+
* @returns {boolean}
|
|
664
|
+
*/
|
|
665
|
+
createProfile(name, mappings, meta = {}) {
|
|
666
|
+
if (!name || typeof name !== 'string') {
|
|
667
|
+
console.warn('SpatialInputSystem.createProfile: name must be a non-empty string');
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
if (!Array.isArray(mappings) || mappings.length === 0) {
|
|
671
|
+
console.warn('SpatialInputSystem.createProfile: mappings must be a non-empty array');
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** @type {ProfileDef} */
|
|
676
|
+
const profile = {
|
|
677
|
+
name: meta.name || name,
|
|
678
|
+
description: meta.description || `Custom profile: ${name}`,
|
|
679
|
+
mappings: mappings.map(m => ({
|
|
680
|
+
axis: m.axis,
|
|
681
|
+
target: m.target,
|
|
682
|
+
scale: Number.isFinite(m.scale) ? m.scale : 1.0,
|
|
683
|
+
clamp: Array.isArray(m.clamp) ? m.clamp : null,
|
|
684
|
+
blendMode: m.blendMode || 'replace'
|
|
685
|
+
})),
|
|
686
|
+
sources: Array.isArray(meta.sources) ? meta.sources : [],
|
|
687
|
+
smoothing: typeof meta.smoothing === 'number' ? clamp(meta.smoothing, 0, 1) : this.smoothingFactor
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
this.profiles.set(name, profile);
|
|
691
|
+
this._emit('profileCreated', { name, profile });
|
|
692
|
+
console.log(`SpatialInputSystem: Profile created "${name}"`);
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Remove a custom profile (built-in profiles cannot be removed).
|
|
698
|
+
* @param {string} name
|
|
699
|
+
* @returns {boolean}
|
|
700
|
+
*/
|
|
701
|
+
removeProfile(name) {
|
|
702
|
+
const builtIn = ['cardTilt', 'wearablePerspective', 'gameAsset', 'vjAudioSpatial', 'uiElement', 'immersiveXR'];
|
|
703
|
+
if (builtIn.includes(name)) {
|
|
704
|
+
console.warn(`SpatialInputSystem.removeProfile: cannot remove built-in profile "${name}"`);
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
return this.profiles.delete(name);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Get a list of all available profile names.
|
|
712
|
+
* @returns {string[]}
|
|
713
|
+
*/
|
|
714
|
+
listProfiles() {
|
|
715
|
+
return Array.from(this.profiles.keys());
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Get a profile definition by name.
|
|
720
|
+
* @param {string} name
|
|
721
|
+
* @returns {ProfileDef|null}
|
|
722
|
+
*/
|
|
723
|
+
getProfile(name) {
|
|
724
|
+
const p = this.profiles.get(name);
|
|
725
|
+
return p ? { ...p, mappings: p.mappings.map(m => ({ ...m })) } : null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ------------------------------------------------------------------
|
|
729
|
+
// Custom targets
|
|
730
|
+
// ------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Register a custom target parameter name (beyond TARGETABLE_PARAMETERS).
|
|
734
|
+
* @param {string} name
|
|
735
|
+
*/
|
|
736
|
+
addCustomTarget(name) {
|
|
737
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
738
|
+
this._customTargets.add(name);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Remove a custom target parameter name.
|
|
744
|
+
* @param {string} name
|
|
745
|
+
*/
|
|
746
|
+
removeCustomTarget(name) {
|
|
747
|
+
this._customTargets.delete(name);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Check whether a target name is valid (built-in or custom).
|
|
752
|
+
* @private
|
|
753
|
+
* @param {string} name
|
|
754
|
+
* @returns {boolean}
|
|
755
|
+
*/
|
|
756
|
+
_isValidTarget(name) {
|
|
757
|
+
return TARGETABLE_PARAMETERS.includes(name) || this._customTargets.has(name);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ------------------------------------------------------------------
|
|
761
|
+
// Enable / Disable
|
|
762
|
+
// ------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Start processing input and updating parameters each frame.
|
|
766
|
+
*/
|
|
767
|
+
enable() {
|
|
768
|
+
if (this.enabled) return;
|
|
769
|
+
|
|
770
|
+
this.enabled = true;
|
|
771
|
+
this._lastFrameTime = performance.now();
|
|
772
|
+
|
|
773
|
+
// Attach listeners for all registered sources
|
|
774
|
+
for (const entry of this.sources.values()) {
|
|
775
|
+
this._attachSourceListeners(entry);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Start frame loop
|
|
779
|
+
this._scheduleFrame();
|
|
780
|
+
|
|
781
|
+
this._emit('enabled');
|
|
782
|
+
console.log('SpatialInputSystem: Enabled');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Stop processing and detach all listeners.
|
|
787
|
+
*/
|
|
788
|
+
disable() {
|
|
789
|
+
if (!this.enabled) return;
|
|
790
|
+
|
|
791
|
+
this.enabled = false;
|
|
792
|
+
|
|
793
|
+
// Cancel frame loop
|
|
794
|
+
if (this._frameId !== null) {
|
|
795
|
+
cancelAnimationFrame(this._frameId);
|
|
796
|
+
this._frameId = null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Detach listeners for all registered sources
|
|
800
|
+
for (const entry of this.sources.values()) {
|
|
801
|
+
this._detachSourceListeners(entry);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
this._emit('disabled');
|
|
805
|
+
console.log('SpatialInputSystem: Disabled');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Whether the system is currently active.
|
|
810
|
+
* @returns {boolean}
|
|
811
|
+
*/
|
|
812
|
+
isEnabled() {
|
|
813
|
+
return this.enabled;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ------------------------------------------------------------------
|
|
817
|
+
// Configuration setters
|
|
818
|
+
// ------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Set the global sensitivity multiplier.
|
|
822
|
+
* @param {number} value - 0.1 .. 10
|
|
823
|
+
*/
|
|
824
|
+
setSensitivity(value) {
|
|
825
|
+
this.sensitivity = clamp(Number(value) || 1.0, 0.1, 10);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Set the default smoothing factor.
|
|
830
|
+
* @param {number} value - 0 .. 1 (0 = no smoothing, 1 = maximum smoothing)
|
|
831
|
+
*/
|
|
832
|
+
setSmoothing(value) {
|
|
833
|
+
this.smoothingFactor = clamp(Number(value) || 0.15, 0, 1);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Set per-axis smoothing override.
|
|
838
|
+
* @param {string} axis - Spatial axis name
|
|
839
|
+
* @param {number} value - 0 .. 1
|
|
840
|
+
*/
|
|
841
|
+
setAxisSmoothing(axis, value) {
|
|
842
|
+
if (SPATIAL_AXES.includes(axis)) {
|
|
843
|
+
this.smoothing.set(axis, clamp(Number(value) || 0, 0, 1));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Enable or disable dramatic mode (8x amplification).
|
|
849
|
+
* @param {boolean} enabled
|
|
850
|
+
*/
|
|
851
|
+
setDramaticMode(enabled) {
|
|
852
|
+
this.dramaticMode = !!enabled;
|
|
853
|
+
this._emit('dramaticModeChanged', this.dramaticMode);
|
|
854
|
+
console.log(`SpatialInputSystem: Dramatic mode ${this.dramaticMode ? 'ON' : 'OFF'}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Set the parameter update callback.
|
|
859
|
+
* @param {Function} fn - (paramName: string, value: number) => void
|
|
860
|
+
*/
|
|
861
|
+
setParameterUpdateFn(fn) {
|
|
862
|
+
this.parameterUpdateFn = typeof fn === 'function' ? fn : null;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ------------------------------------------------------------------
|
|
866
|
+
// Manual data injection
|
|
867
|
+
// ------------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Feed spatial data from an external/programmatic source.
|
|
871
|
+
*
|
|
872
|
+
* @param {string} sourceType - One of SOURCE_TYPES
|
|
873
|
+
* @param {Object} data - Partial spatial state or source-specific data
|
|
874
|
+
*
|
|
875
|
+
* @example
|
|
876
|
+
* // Programmatic: set pitch and roll directly
|
|
877
|
+
* spatial.feedInput('programmatic', { pitch: 0.5, roll: -0.3 });
|
|
878
|
+
*
|
|
879
|
+
* // Audio: provide frequency band levels
|
|
880
|
+
* spatial.feedInput('audio', { bass: 0.8, mid: 0.4, high: 0.2 });
|
|
881
|
+
*
|
|
882
|
+
* // MIDI: provide channel, CC number, and value
|
|
883
|
+
* spatial.feedInput('midi', { channel: 0, cc: 1, value: 64 });
|
|
884
|
+
*/
|
|
885
|
+
feedInput(sourceType, data) {
|
|
886
|
+
if (!data || typeof data !== 'object') return;
|
|
887
|
+
|
|
888
|
+
switch (sourceType) {
|
|
889
|
+
case SOURCE_TYPES.PROGRAMMATIC:
|
|
890
|
+
this._processProgrammatic(data);
|
|
891
|
+
break;
|
|
892
|
+
case SOURCE_TYPES.DEVICE_TILT:
|
|
893
|
+
this._processDeviceTilt(data);
|
|
894
|
+
break;
|
|
895
|
+
case SOURCE_TYPES.MOUSE_POSITION:
|
|
896
|
+
this._processMousePosition(
|
|
897
|
+
Number(data.x) || 0,
|
|
898
|
+
Number(data.y) || 0,
|
|
899
|
+
data.width || (typeof window !== 'undefined' ? window.innerWidth : 1920),
|
|
900
|
+
data.height || (typeof window !== 'undefined' ? window.innerHeight : 1080)
|
|
901
|
+
);
|
|
902
|
+
break;
|
|
903
|
+
case SOURCE_TYPES.GYROSCOPE:
|
|
904
|
+
this._processGyroscope(data);
|
|
905
|
+
break;
|
|
906
|
+
case SOURCE_TYPES.GAMEPAD:
|
|
907
|
+
this._processGamepad(data);
|
|
908
|
+
break;
|
|
909
|
+
case SOURCE_TYPES.PERSPECTIVE:
|
|
910
|
+
this._processPerspective(data);
|
|
911
|
+
break;
|
|
912
|
+
case SOURCE_TYPES.AUDIO:
|
|
913
|
+
this._processAudioSpatial(
|
|
914
|
+
Number(data.bass) || 0,
|
|
915
|
+
Number(data.mid) || 0,
|
|
916
|
+
Number(data.high) || 0
|
|
917
|
+
);
|
|
918
|
+
break;
|
|
919
|
+
case SOURCE_TYPES.MIDI:
|
|
920
|
+
this._processMIDI(
|
|
921
|
+
Number(data.channel) || 0,
|
|
922
|
+
Number(data.cc) || 0,
|
|
923
|
+
Number(data.value) || 0
|
|
924
|
+
);
|
|
925
|
+
break;
|
|
926
|
+
default:
|
|
927
|
+
console.warn(`SpatialInputSystem.feedInput: unknown source type "${sourceType}"`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ------------------------------------------------------------------
|
|
932
|
+
// State retrieval
|
|
933
|
+
// ------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Get the current smoothed spatial state.
|
|
937
|
+
* @returns {SpatialState}
|
|
938
|
+
*/
|
|
939
|
+
getState() {
|
|
940
|
+
return { ...this.smoothedState };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Get the raw (pre-smoothing) spatial state.
|
|
945
|
+
* @returns {SpatialState}
|
|
946
|
+
*/
|
|
947
|
+
getRawState() {
|
|
948
|
+
return { ...this.spatialState };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ------------------------------------------------------------------
|
|
952
|
+
// Serialisation
|
|
953
|
+
// ------------------------------------------------------------------
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Export the full configuration (sources, mappings, profile, settings).
|
|
957
|
+
* @returns {Object}
|
|
958
|
+
*/
|
|
959
|
+
exportConfig() {
|
|
960
|
+
return {
|
|
961
|
+
version: '1.0',
|
|
962
|
+
sensitivity: this.sensitivity,
|
|
963
|
+
smoothingFactor: this.smoothingFactor,
|
|
964
|
+
dramaticMode: this.dramaticMode,
|
|
965
|
+
activeProfile: this.activeProfile,
|
|
966
|
+
sources: this.listSources(),
|
|
967
|
+
mappings: this.listMappings(),
|
|
968
|
+
customTargets: Array.from(this._customTargets),
|
|
969
|
+
axisSmoothing: Object.fromEntries(this.smoothing)
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Import a previously exported configuration.
|
|
975
|
+
*
|
|
976
|
+
* @param {Object} config - Configuration object from exportConfig()
|
|
977
|
+
* @returns {boolean} True if import succeeded
|
|
978
|
+
*/
|
|
979
|
+
importConfig(config) {
|
|
980
|
+
if (!config || typeof config !== 'object') {
|
|
981
|
+
console.warn('SpatialInputSystem.importConfig: invalid config');
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
try {
|
|
986
|
+
// Settings
|
|
987
|
+
if (Number.isFinite(config.sensitivity)) {
|
|
988
|
+
this.setSensitivity(config.sensitivity);
|
|
989
|
+
}
|
|
990
|
+
if (Number.isFinite(config.smoothingFactor)) {
|
|
991
|
+
this.setSmoothing(config.smoothingFactor);
|
|
992
|
+
}
|
|
993
|
+
if (typeof config.dramaticMode === 'boolean') {
|
|
994
|
+
this.setDramaticMode(config.dramaticMode);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Custom targets
|
|
998
|
+
if (Array.isArray(config.customTargets)) {
|
|
999
|
+
this._customTargets.clear();
|
|
1000
|
+
for (const t of config.customTargets) {
|
|
1001
|
+
this.addCustomTarget(t);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Axis smoothing overrides
|
|
1006
|
+
if (config.axisSmoothing && typeof config.axisSmoothing === 'object') {
|
|
1007
|
+
this.smoothing.clear();
|
|
1008
|
+
for (const [axis, val] of Object.entries(config.axisSmoothing)) {
|
|
1009
|
+
this.setAxisSmoothing(axis, val);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Sources
|
|
1014
|
+
if (Array.isArray(config.sources)) {
|
|
1015
|
+
// Remove all existing sources first
|
|
1016
|
+
for (const name of Array.from(this.sources.keys())) {
|
|
1017
|
+
this.removeSource(name);
|
|
1018
|
+
}
|
|
1019
|
+
for (const s of config.sources) {
|
|
1020
|
+
if (s.name && s.type) {
|
|
1021
|
+
this.addSource(s.name, s.type, s.config || {});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Mappings
|
|
1027
|
+
if (Array.isArray(config.mappings)) {
|
|
1028
|
+
this.clearMappings();
|
|
1029
|
+
for (const m of config.mappings) {
|
|
1030
|
+
this.setMapping(m.axis, m.target, m.scale, m.clamp, m.blendMode);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Profile (load after mappings -- if a profile is specified, it
|
|
1035
|
+
// replaces the explicit mappings above)
|
|
1036
|
+
if (config.activeProfile && this.profiles.has(config.activeProfile)) {
|
|
1037
|
+
this.loadProfile(config.activeProfile);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
this._emit('configImported', config);
|
|
1041
|
+
console.log('SpatialInputSystem: Config imported');
|
|
1042
|
+
return true;
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
console.error('SpatialInputSystem.importConfig: error during import', err);
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ------------------------------------------------------------------
|
|
1050
|
+
// Frame processing
|
|
1051
|
+
// ------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Schedule the next animation frame.
|
|
1055
|
+
* @private
|
|
1056
|
+
*/
|
|
1057
|
+
_scheduleFrame() {
|
|
1058
|
+
if (!this.enabled) return;
|
|
1059
|
+
this._frameId = requestAnimationFrame((ts) => this._onFrame(ts));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Animation frame callback.
|
|
1064
|
+
* @private
|
|
1065
|
+
* @param {number} timestamp - High-resolution timestamp from rAF
|
|
1066
|
+
*/
|
|
1067
|
+
_onFrame(timestamp) {
|
|
1068
|
+
if (!this.enabled) return;
|
|
1069
|
+
|
|
1070
|
+
const deltaTime = Math.min((timestamp - this._lastFrameTime) / 1000, 0.1); // cap at 100ms
|
|
1071
|
+
this._lastFrameTime = timestamp;
|
|
1072
|
+
|
|
1073
|
+
// Poll sources that need polling (e.g. gamepad)
|
|
1074
|
+
this._pollSources();
|
|
1075
|
+
|
|
1076
|
+
// Run the main processing pipeline
|
|
1077
|
+
this.processFrame(deltaTime);
|
|
1078
|
+
|
|
1079
|
+
// Schedule next
|
|
1080
|
+
this._scheduleFrame();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Main per-frame processing pipeline.
|
|
1085
|
+
*
|
|
1086
|
+
* 1. Compute velocity from state delta
|
|
1087
|
+
* 2. Compute intensity from overall displacement
|
|
1088
|
+
* 3. Apply smoothing
|
|
1089
|
+
* 4. Apply dramatic mode amplification
|
|
1090
|
+
* 5. Map smoothed state to parameters via active mappings
|
|
1091
|
+
* 6. Invoke parameter update callbacks
|
|
1092
|
+
*
|
|
1093
|
+
* @param {number} deltaTime - Seconds since last frame
|
|
1094
|
+
*/
|
|
1095
|
+
processFrame(deltaTime) {
|
|
1096
|
+
const dt = Number.isFinite(deltaTime) && deltaTime > 0 ? deltaTime : 1 / 60;
|
|
1097
|
+
|
|
1098
|
+
// -- Compute derived values --
|
|
1099
|
+
|
|
1100
|
+
// Intensity: Euclidean distance of orientation + translation from origin
|
|
1101
|
+
const rawIntensity = Math.sqrt(
|
|
1102
|
+
this.spatialState.pitch * this.spatialState.pitch +
|
|
1103
|
+
this.spatialState.yaw * this.spatialState.yaw +
|
|
1104
|
+
this.spatialState.roll * this.spatialState.roll +
|
|
1105
|
+
this.spatialState.x * this.spatialState.x +
|
|
1106
|
+
this.spatialState.y * this.spatialState.y +
|
|
1107
|
+
this.spatialState.z * this.spatialState.z
|
|
1108
|
+
);
|
|
1109
|
+
this.spatialState.intensity = clamp(rawIntensity / Math.SQRT2, 0, 1);
|
|
1110
|
+
|
|
1111
|
+
// -- Smooth all axes --
|
|
1112
|
+
for (const axis of SPATIAL_AXES) {
|
|
1113
|
+
const factor = this.smoothing.has(axis)
|
|
1114
|
+
? this.smoothing.get(axis)
|
|
1115
|
+
: this.smoothingFactor;
|
|
1116
|
+
|
|
1117
|
+
// Lerp factor: lower smoothingFactor = more responsive
|
|
1118
|
+
const lerpFactor = 1.0 - factor;
|
|
1119
|
+
|
|
1120
|
+
this.smoothedState[axis] = lerp(
|
|
1121
|
+
this.smoothedState[axis],
|
|
1122
|
+
this.spatialState[axis],
|
|
1123
|
+
clamp(lerpFactor, 0, 1)
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Velocity: rate of change of the smoothed state
|
|
1128
|
+
let velocitySum = 0;
|
|
1129
|
+
for (const axis of ['pitch', 'yaw', 'roll', 'x', 'y', 'z']) {
|
|
1130
|
+
const delta = this.smoothedState[axis] - this._prevSmoothedState[axis];
|
|
1131
|
+
velocitySum += delta * delta;
|
|
1132
|
+
}
|
|
1133
|
+
this.smoothedState.velocity = clamp(Math.sqrt(velocitySum) / (dt * 10), 0, 1);
|
|
1134
|
+
|
|
1135
|
+
// Store previous state
|
|
1136
|
+
Object.assign(this._prevSmoothedState, this.smoothedState);
|
|
1137
|
+
|
|
1138
|
+
// -- Apply mappings --
|
|
1139
|
+
this._applyMappings();
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Apply all active mappings, reading from smoothedState and invoking
|
|
1144
|
+
* the parameter update callback.
|
|
1145
|
+
* @private
|
|
1146
|
+
*/
|
|
1147
|
+
_applyMappings() {
|
|
1148
|
+
if (!this.parameterUpdateFn) return;
|
|
1149
|
+
|
|
1150
|
+
// Accumulate per-target to handle multiple mappings to the same target
|
|
1151
|
+
/** @type {Map<string, { value: number, mode: string }>} */
|
|
1152
|
+
const accumulator = new Map();
|
|
1153
|
+
|
|
1154
|
+
for (const mapping of this.mappings.values()) {
|
|
1155
|
+
const rawValue = this.smoothedState[mapping.axis] || 0;
|
|
1156
|
+
|
|
1157
|
+
// Apply sensitivity and scale
|
|
1158
|
+
let value = rawValue * mapping.scale * this.sensitivity;
|
|
1159
|
+
|
|
1160
|
+
// Dramatic mode amplification
|
|
1161
|
+
if (this.dramaticMode) {
|
|
1162
|
+
value *= DRAMATIC_MULTIPLIER;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Clamp if configured
|
|
1166
|
+
if (mapping.clamp) {
|
|
1167
|
+
value = clamp(value, mapping.clamp[0], mapping.clamp[1]);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Guard against NaN
|
|
1171
|
+
if (!Number.isFinite(value)) continue;
|
|
1172
|
+
|
|
1173
|
+
const mode = mapping.blendMode || 'replace';
|
|
1174
|
+
const existing = accumulator.get(mapping.target);
|
|
1175
|
+
|
|
1176
|
+
if (!existing) {
|
|
1177
|
+
accumulator.set(mapping.target, { value, mode });
|
|
1178
|
+
} else {
|
|
1179
|
+
// Combine multiple mappings targeting the same parameter
|
|
1180
|
+
switch (mode) {
|
|
1181
|
+
case 'add':
|
|
1182
|
+
existing.value += value;
|
|
1183
|
+
break;
|
|
1184
|
+
case 'multiply':
|
|
1185
|
+
existing.value *= value;
|
|
1186
|
+
break;
|
|
1187
|
+
case 'max':
|
|
1188
|
+
existing.value = Math.max(existing.value, value);
|
|
1189
|
+
break;
|
|
1190
|
+
case 'min':
|
|
1191
|
+
existing.value = Math.min(existing.value, value);
|
|
1192
|
+
break;
|
|
1193
|
+
case 'replace':
|
|
1194
|
+
default:
|
|
1195
|
+
existing.value = value;
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Invoke callback for each accumulated target
|
|
1202
|
+
for (const [param, { value }] of accumulator) {
|
|
1203
|
+
try {
|
|
1204
|
+
this.parameterUpdateFn(param, value);
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
console.error(`SpatialInputSystem: Error updating parameter "${param}"`, err);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// ------------------------------------------------------------------
|
|
1212
|
+
// Input source listeners
|
|
1213
|
+
// ------------------------------------------------------------------
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Attach native event listeners for a source.
|
|
1217
|
+
* @private
|
|
1218
|
+
* @param {SourceEntry} entry
|
|
1219
|
+
*/
|
|
1220
|
+
_attachSourceListeners(entry) {
|
|
1221
|
+
if (typeof window === 'undefined') return;
|
|
1222
|
+
|
|
1223
|
+
switch (entry.type) {
|
|
1224
|
+
case SOURCE_TYPES.DEVICE_TILT:
|
|
1225
|
+
this._attachDeviceTilt(entry);
|
|
1226
|
+
break;
|
|
1227
|
+
case SOURCE_TYPES.MOUSE_POSITION:
|
|
1228
|
+
this._attachMousePosition(entry);
|
|
1229
|
+
break;
|
|
1230
|
+
case SOURCE_TYPES.GYROSCOPE:
|
|
1231
|
+
this._attachGyroscope(entry);
|
|
1232
|
+
break;
|
|
1233
|
+
case SOURCE_TYPES.GAMEPAD:
|
|
1234
|
+
// Gamepad is polled, not event-driven; mark for polling
|
|
1235
|
+
entry._cleanup = null;
|
|
1236
|
+
break;
|
|
1237
|
+
case SOURCE_TYPES.PERSPECTIVE:
|
|
1238
|
+
case SOURCE_TYPES.PROGRAMMATIC:
|
|
1239
|
+
case SOURCE_TYPES.AUDIO:
|
|
1240
|
+
case SOURCE_TYPES.MIDI:
|
|
1241
|
+
// These are feed-based; no automatic listeners
|
|
1242
|
+
entry._cleanup = null;
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Detach native event listeners for a source.
|
|
1249
|
+
* @private
|
|
1250
|
+
* @param {SourceEntry} entry
|
|
1251
|
+
*/
|
|
1252
|
+
_detachSourceListeners(entry) {
|
|
1253
|
+
if (typeof entry._cleanup === 'function') {
|
|
1254
|
+
entry._cleanup();
|
|
1255
|
+
entry._cleanup = null;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Attach DeviceOrientationEvent listener.
|
|
1261
|
+
* @private
|
|
1262
|
+
* @param {SourceEntry} entry
|
|
1263
|
+
*/
|
|
1264
|
+
_attachDeviceTilt(entry) {
|
|
1265
|
+
if (typeof window === 'undefined') return;
|
|
1266
|
+
|
|
1267
|
+
const handler = (event) => {
|
|
1268
|
+
if (!entry.active) return;
|
|
1269
|
+
this._processDeviceTilt(event);
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
// Request permission on iOS 13+
|
|
1273
|
+
if (typeof DeviceOrientationEvent !== 'undefined' &&
|
|
1274
|
+
typeof DeviceOrientationEvent.requestPermission === 'function') {
|
|
1275
|
+
DeviceOrientationEvent.requestPermission()
|
|
1276
|
+
.then((permission) => {
|
|
1277
|
+
if (permission === 'granted') {
|
|
1278
|
+
window.addEventListener('deviceorientation', handler);
|
|
1279
|
+
} else {
|
|
1280
|
+
console.warn('SpatialInputSystem: Device orientation permission denied');
|
|
1281
|
+
}
|
|
1282
|
+
})
|
|
1283
|
+
.catch((err) => {
|
|
1284
|
+
console.warn('SpatialInputSystem: Device orientation permission error', err);
|
|
1285
|
+
});
|
|
1286
|
+
} else {
|
|
1287
|
+
window.addEventListener('deviceorientation', handler);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
entry._cleanup = () => {
|
|
1291
|
+
window.removeEventListener('deviceorientation', handler);
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Attach mousemove listener.
|
|
1297
|
+
* @private
|
|
1298
|
+
* @param {SourceEntry} entry
|
|
1299
|
+
*/
|
|
1300
|
+
_attachMousePosition(entry) {
|
|
1301
|
+
if (typeof window === 'undefined') return;
|
|
1302
|
+
|
|
1303
|
+
const element = entry.config.element || window;
|
|
1304
|
+
const handler = (event) => {
|
|
1305
|
+
if (!entry.active) return;
|
|
1306
|
+
this._processMousePosition(
|
|
1307
|
+
event.clientX,
|
|
1308
|
+
event.clientY,
|
|
1309
|
+
window.innerWidth,
|
|
1310
|
+
window.innerHeight
|
|
1311
|
+
);
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
element.addEventListener('mousemove', handler);
|
|
1315
|
+
entry._cleanup = () => {
|
|
1316
|
+
element.removeEventListener('mousemove', handler);
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Attach GyroscopeSensor API (Generic Sensor API).
|
|
1322
|
+
* @private
|
|
1323
|
+
* @param {SourceEntry} entry
|
|
1324
|
+
*/
|
|
1325
|
+
_attachGyroscope(entry) {
|
|
1326
|
+
if (typeof window === 'undefined' || typeof window.Gyroscope === 'undefined') {
|
|
1327
|
+
console.warn('SpatialInputSystem: Gyroscope API not available');
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
try {
|
|
1332
|
+
const frequency = entry.config.frequency || 60;
|
|
1333
|
+
const sensor = new window.Gyroscope({ frequency });
|
|
1334
|
+
|
|
1335
|
+
sensor.addEventListener('reading', () => {
|
|
1336
|
+
if (!entry.active) return;
|
|
1337
|
+
this._processGyroscope({
|
|
1338
|
+
x: sensor.x,
|
|
1339
|
+
y: sensor.y,
|
|
1340
|
+
z: sensor.z
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
sensor.addEventListener('error', (event) => {
|
|
1345
|
+
console.warn('SpatialInputSystem: Gyroscope error', event.error);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
sensor.start();
|
|
1349
|
+
this._gyroscopeSensor = sensor;
|
|
1350
|
+
|
|
1351
|
+
entry._cleanup = () => {
|
|
1352
|
+
sensor.stop();
|
|
1353
|
+
this._gyroscopeSensor = null;
|
|
1354
|
+
};
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
console.warn('SpatialInputSystem: Failed to initialise Gyroscope', err);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Poll-based sources (called every frame).
|
|
1362
|
+
* @private
|
|
1363
|
+
*/
|
|
1364
|
+
_pollSources() {
|
|
1365
|
+
for (const entry of this.sources.values()) {
|
|
1366
|
+
if (!entry.active) continue;
|
|
1367
|
+
|
|
1368
|
+
switch (entry.type) {
|
|
1369
|
+
case SOURCE_TYPES.GAMEPAD:
|
|
1370
|
+
this._pollGamepad(entry);
|
|
1371
|
+
break;
|
|
1372
|
+
// Other poll-based sources can be added here
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Poll the Gamepad API for analog stick data.
|
|
1379
|
+
* @private
|
|
1380
|
+
* @param {SourceEntry} entry
|
|
1381
|
+
*/
|
|
1382
|
+
_pollGamepad(entry) {
|
|
1383
|
+
if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') return;
|
|
1384
|
+
|
|
1385
|
+
const gamepads = navigator.getGamepads();
|
|
1386
|
+
const index = entry.config.index || 0;
|
|
1387
|
+
const gamepad = gamepads[index];
|
|
1388
|
+
|
|
1389
|
+
if (!gamepad || !gamepad.connected) return;
|
|
1390
|
+
|
|
1391
|
+
this._processGamepad(gamepad, entry.config);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ------------------------------------------------------------------
|
|
1395
|
+
// Input processors
|
|
1396
|
+
// ------------------------------------------------------------------
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Process DeviceOrientationEvent data.
|
|
1400
|
+
*
|
|
1401
|
+
* Maps:
|
|
1402
|
+
* beta (-180..180) --> pitch (-1..1)
|
|
1403
|
+
* gamma (-90..90) --> roll (-1..1)
|
|
1404
|
+
* alpha (0..360) --> yaw (-1..1)
|
|
1405
|
+
*
|
|
1406
|
+
* @private
|
|
1407
|
+
* @param {DeviceOrientationEvent|Object} event
|
|
1408
|
+
*/
|
|
1409
|
+
_processDeviceTilt(event) {
|
|
1410
|
+
const alpha = Number(event.alpha) || 0; // 0..360
|
|
1411
|
+
const beta = Number(event.beta) || 0; // -180..180
|
|
1412
|
+
const gamma = Number(event.gamma) || 0; // -90..90
|
|
1413
|
+
|
|
1414
|
+
// Normalise to -1..1
|
|
1415
|
+
this.spatialState.pitch = clamp(beta / 90, -1, 1);
|
|
1416
|
+
this.spatialState.roll = clamp(gamma / 45, -1, 1);
|
|
1417
|
+
this.spatialState.yaw = clamp((alpha - 180) / 180, -1, 1);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Process mouse position as spatial orientation.
|
|
1422
|
+
*
|
|
1423
|
+
* Maps screen-relative position to pitch (vertical) and roll (horizontal).
|
|
1424
|
+
* Centre of screen = neutral (0, 0).
|
|
1425
|
+
*
|
|
1426
|
+
* @private
|
|
1427
|
+
* @param {number} x - Mouse X pixel coordinate
|
|
1428
|
+
* @param {number} y - Mouse Y pixel coordinate
|
|
1429
|
+
* @param {number} [width] - Viewport width
|
|
1430
|
+
* @param {number} [height] - Viewport height
|
|
1431
|
+
*/
|
|
1432
|
+
_processMousePosition(x, y, width, height) {
|
|
1433
|
+
const w = Number(width) || (typeof window !== 'undefined' ? window.innerWidth : 1920);
|
|
1434
|
+
const h = Number(height) || (typeof window !== 'undefined' ? window.innerHeight : 1080);
|
|
1435
|
+
|
|
1436
|
+
if (w === 0 || h === 0) return;
|
|
1437
|
+
|
|
1438
|
+
// Normalise to -1..1 (centre = 0)
|
|
1439
|
+
const normX = clamp(((x / w) - 0.5) * 2, -1, 1);
|
|
1440
|
+
const normY = clamp(((y / h) - 0.5) * 2, -1, 1);
|
|
1441
|
+
|
|
1442
|
+
this.spatialState.roll = normX;
|
|
1443
|
+
this.spatialState.pitch = -normY; // Inverted: mouse up = positive pitch
|
|
1444
|
+
// Yaw stays unchanged by mouse (no horizontal rotation equivalent)
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Process GyroscopeSensor reading.
|
|
1449
|
+
*
|
|
1450
|
+
* Maps angular velocity (rad/s) to spatial axes.
|
|
1451
|
+
* Integrates velocity over time to produce position-like values
|
|
1452
|
+
* with natural decay.
|
|
1453
|
+
*
|
|
1454
|
+
* @private
|
|
1455
|
+
* @param {Object} event
|
|
1456
|
+
* @param {number} event.x - Angular velocity around X axis (rad/s)
|
|
1457
|
+
* @param {number} event.y - Angular velocity around Y axis (rad/s)
|
|
1458
|
+
* @param {number} event.z - Angular velocity around Z axis (rad/s)
|
|
1459
|
+
*/
|
|
1460
|
+
_processGyroscope(event) {
|
|
1461
|
+
const gx = Number(event.x) || 0;
|
|
1462
|
+
const gy = Number(event.y) || 0;
|
|
1463
|
+
const gz = Number(event.z) || 0;
|
|
1464
|
+
|
|
1465
|
+
// Scale factor to normalise typical angular velocity range (-10..10 rad/s) to -1..1
|
|
1466
|
+
const scale = 0.1;
|
|
1467
|
+
// Decay factor for integration (prevents drift)
|
|
1468
|
+
const decay = 0.95;
|
|
1469
|
+
|
|
1470
|
+
this.spatialState.pitch = clamp(this.spatialState.pitch * decay + gx * scale, -1, 1);
|
|
1471
|
+
this.spatialState.roll = clamp(this.spatialState.roll * decay + gy * scale, -1, 1);
|
|
1472
|
+
this.spatialState.yaw = clamp(this.spatialState.yaw * decay + gz * scale, -1, 1);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Process Gamepad API data.
|
|
1477
|
+
*
|
|
1478
|
+
* Standard mapping:
|
|
1479
|
+
* Left stick X --> roll
|
|
1480
|
+
* Left stick Y --> pitch
|
|
1481
|
+
* Right stick X --> yaw
|
|
1482
|
+
* Right stick Y --> z (depth)
|
|
1483
|
+
* Left trigger --> negative x
|
|
1484
|
+
* Right trigger --> positive x
|
|
1485
|
+
*
|
|
1486
|
+
* @private
|
|
1487
|
+
* @param {Gamepad|Object} gamepad
|
|
1488
|
+
* @param {Object} [config={}]
|
|
1489
|
+
* @param {number} [config.deadzone=0.1] - Analog stick deadzone
|
|
1490
|
+
*/
|
|
1491
|
+
_processGamepad(gamepad, config = {}) {
|
|
1492
|
+
const deadzone = Number(config.deadzone) || 0.1;
|
|
1493
|
+
const axes = gamepad.axes || [];
|
|
1494
|
+
const buttons = gamepad.buttons || [];
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Apply deadzone to a value.
|
|
1498
|
+
* @param {number} v
|
|
1499
|
+
* @returns {number}
|
|
1500
|
+
*/
|
|
1501
|
+
const applyDeadzone = (v) => {
|
|
1502
|
+
return Math.abs(v) < deadzone ? 0 : v;
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
// Left stick
|
|
1506
|
+
if (axes.length >= 2) {
|
|
1507
|
+
this.spatialState.roll = clamp(applyDeadzone(axes[0]), -1, 1);
|
|
1508
|
+
this.spatialState.pitch = clamp(applyDeadzone(-axes[1]), -1, 1); // Invert Y
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Right stick
|
|
1512
|
+
if (axes.length >= 4) {
|
|
1513
|
+
this.spatialState.yaw = clamp(applyDeadzone(axes[2]), -1, 1);
|
|
1514
|
+
this.spatialState.z = clamp(applyDeadzone(-axes[3]), -1, 1);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Triggers (L2 = button 6, R2 = button 7 in standard mapping)
|
|
1518
|
+
if (buttons.length >= 8) {
|
|
1519
|
+
const lt = typeof buttons[6] === 'object' ? buttons[6].value : Number(buttons[6]) || 0;
|
|
1520
|
+
const rt = typeof buttons[7] === 'object' ? buttons[7].value : Number(buttons[7]) || 0;
|
|
1521
|
+
this.spatialState.x = clamp(rt - lt, -1, 1);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Process AR/wearable perspective data.
|
|
1527
|
+
*
|
|
1528
|
+
* Expects pre-normalised orientation and position data from a
|
|
1529
|
+
* perspective tracking system (WebXR, custom AR SDK, etc.).
|
|
1530
|
+
*
|
|
1531
|
+
* @private
|
|
1532
|
+
* @param {Object} data
|
|
1533
|
+
* @param {number} [data.pitch] - Viewing pitch (-1..1)
|
|
1534
|
+
* @param {number} [data.yaw] - Viewing yaw (-1..1)
|
|
1535
|
+
* @param {number} [data.roll] - Viewing roll (-1..1)
|
|
1536
|
+
* @param {number} [data.x] - Position X (-1..1)
|
|
1537
|
+
* @param {number} [data.y] - Position Y (-1..1)
|
|
1538
|
+
* @param {number} [data.z] - Position Z (-1..1)
|
|
1539
|
+
*/
|
|
1540
|
+
_processPerspective(data) {
|
|
1541
|
+
if (Number.isFinite(data.pitch)) {
|
|
1542
|
+
this.spatialState.pitch = clamp(data.pitch, -1, 1);
|
|
1543
|
+
}
|
|
1544
|
+
if (Number.isFinite(data.yaw)) {
|
|
1545
|
+
this.spatialState.yaw = clamp(data.yaw, -1, 1);
|
|
1546
|
+
}
|
|
1547
|
+
if (Number.isFinite(data.roll)) {
|
|
1548
|
+
this.spatialState.roll = clamp(data.roll, -1, 1);
|
|
1549
|
+
}
|
|
1550
|
+
if (Number.isFinite(data.x)) {
|
|
1551
|
+
this.spatialState.x = clamp(data.x, -1, 1);
|
|
1552
|
+
}
|
|
1553
|
+
if (Number.isFinite(data.y)) {
|
|
1554
|
+
this.spatialState.y = clamp(data.y, -1, 1);
|
|
1555
|
+
}
|
|
1556
|
+
if (Number.isFinite(data.z)) {
|
|
1557
|
+
this.spatialState.z = clamp(data.z, -1, 1);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Process audio frequency band levels as spatial movement.
|
|
1563
|
+
*
|
|
1564
|
+
* Maps:
|
|
1565
|
+
* bass --> pitch (deep frequencies drive forward tilt)
|
|
1566
|
+
* mid --> yaw (mid-range drives lateral movement)
|
|
1567
|
+
* high --> roll (high frequencies drive shimmer/roll)
|
|
1568
|
+
* energy --> x (overall volume drives translation)
|
|
1569
|
+
*
|
|
1570
|
+
* Values are normalised 0..1 and mapped to -1..1 spatial range
|
|
1571
|
+
* using oscillation modulated by the input level.
|
|
1572
|
+
*
|
|
1573
|
+
* @private
|
|
1574
|
+
* @param {number} bass - Bass band level (0..1)
|
|
1575
|
+
* @param {number} mid - Mid band level (0..1)
|
|
1576
|
+
* @param {number} high - High band level (0..1)
|
|
1577
|
+
*/
|
|
1578
|
+
_processAudioSpatial(bass, mid, high) {
|
|
1579
|
+
const b = clamp(bass, 0, 1);
|
|
1580
|
+
const m = clamp(mid, 0, 1);
|
|
1581
|
+
const h = clamp(high, 0, 1);
|
|
1582
|
+
const energy = (b + m + h) / 3;
|
|
1583
|
+
|
|
1584
|
+
// Use time-based oscillation modulated by audio levels.
|
|
1585
|
+
// This creates organic movement rather than static positioning.
|
|
1586
|
+
const t = typeof performance !== 'undefined' ? performance.now() * 0.001 : Date.now() * 0.001;
|
|
1587
|
+
|
|
1588
|
+
this.spatialState.pitch = clamp(Math.sin(t * 1.3) * b, -1, 1);
|
|
1589
|
+
this.spatialState.yaw = clamp(Math.sin(t * 0.7) * m, -1, 1);
|
|
1590
|
+
this.spatialState.roll = clamp(Math.sin(t * 2.1) * h, -1, 1);
|
|
1591
|
+
this.spatialState.x = clamp(Math.cos(t * 0.5) * energy, -1, 1);
|
|
1592
|
+
this.spatialState.y = clamp(energy * 2 - 1, -1, 1);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Process MIDI Control Change data.
|
|
1597
|
+
*
|
|
1598
|
+
* Default mapping (configurable by adding sources with config):
|
|
1599
|
+
* CC 1 (Mod Wheel) --> pitch
|
|
1600
|
+
* CC 2 (Breath) --> yaw
|
|
1601
|
+
* CC 11 (Expression)--> roll
|
|
1602
|
+
* CC 74 (Brightness)--> x
|
|
1603
|
+
* CC 71 (Resonance) --> y
|
|
1604
|
+
* CC 7 (Volume) --> z
|
|
1605
|
+
*
|
|
1606
|
+
* MIDI CC values are 0..127, normalised to -1..1.
|
|
1607
|
+
*
|
|
1608
|
+
* @private
|
|
1609
|
+
* @param {number} channel - MIDI channel (0..15)
|
|
1610
|
+
* @param {number} cc - Control Change number (0..127)
|
|
1611
|
+
* @param {number} value - CC value (0..127)
|
|
1612
|
+
*/
|
|
1613
|
+
_processMIDI(channel, cc, value) {
|
|
1614
|
+
// Normalise 0..127 to -1..1
|
|
1615
|
+
const norm = clamp((value / 63.5) - 1.0, -1, 1);
|
|
1616
|
+
// Normalise 0..127 to 0..1 (for unipolar axes)
|
|
1617
|
+
const normUni = clamp(value / 127, 0, 1);
|
|
1618
|
+
|
|
1619
|
+
switch (cc) {
|
|
1620
|
+
case 1: // Mod Wheel
|
|
1621
|
+
this.spatialState.pitch = norm;
|
|
1622
|
+
break;
|
|
1623
|
+
case 2: // Breath Controller
|
|
1624
|
+
this.spatialState.yaw = norm;
|
|
1625
|
+
break;
|
|
1626
|
+
case 11: // Expression Controller
|
|
1627
|
+
this.spatialState.roll = norm;
|
|
1628
|
+
break;
|
|
1629
|
+
case 74: // Brightness (MPE slide)
|
|
1630
|
+
this.spatialState.x = norm;
|
|
1631
|
+
break;
|
|
1632
|
+
case 71: // Resonance / Timbre
|
|
1633
|
+
this.spatialState.y = norm;
|
|
1634
|
+
break;
|
|
1635
|
+
case 7: // Channel Volume
|
|
1636
|
+
this.spatialState.z = norm;
|
|
1637
|
+
break;
|
|
1638
|
+
default: {
|
|
1639
|
+
// For unmapped CCs, distribute across axes by CC group
|
|
1640
|
+
// This allows any CC to have some spatial effect
|
|
1641
|
+
const axisIndex = cc % 6;
|
|
1642
|
+
const axes = ['pitch', 'yaw', 'roll', 'x', 'y', 'z'];
|
|
1643
|
+
this.spatialState[axes[axisIndex]] = norm;
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
this._emit('midiInput', { channel, cc, value, normalized: norm });
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Process programmatic / direct API input.
|
|
1653
|
+
* Accepts a partial SpatialState and merges it.
|
|
1654
|
+
*
|
|
1655
|
+
* @private
|
|
1656
|
+
* @param {Partial<SpatialState>} data
|
|
1657
|
+
*/
|
|
1658
|
+
_processProgrammatic(data) {
|
|
1659
|
+
for (const axis of SPATIAL_AXES) {
|
|
1660
|
+
if (Number.isFinite(data[axis])) {
|
|
1661
|
+
if (axis === 'intensity' || axis === 'velocity') {
|
|
1662
|
+
this.spatialState[axis] = clamp(data[axis], 0, 1);
|
|
1663
|
+
} else {
|
|
1664
|
+
this.spatialState[axis] = clamp(data[axis], -1, 1);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// ------------------------------------------------------------------
|
|
1671
|
+
// Event system
|
|
1672
|
+
// ------------------------------------------------------------------
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Subscribe to an event.
|
|
1676
|
+
* @param {string} event - Event name
|
|
1677
|
+
* @param {Function} callback - Handler function
|
|
1678
|
+
*/
|
|
1679
|
+
on(event, callback) {
|
|
1680
|
+
if (typeof callback !== 'function') return;
|
|
1681
|
+
if (!this._listeners.has(event)) {
|
|
1682
|
+
this._listeners.set(event, []);
|
|
1683
|
+
}
|
|
1684
|
+
this._listeners.get(event).push(callback);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Unsubscribe from an event.
|
|
1689
|
+
* @param {string} event - Event name
|
|
1690
|
+
* @param {Function} callback - Handler to remove
|
|
1691
|
+
*/
|
|
1692
|
+
off(event, callback) {
|
|
1693
|
+
if (!this._listeners.has(event)) return;
|
|
1694
|
+
const arr = this._listeners.get(event);
|
|
1695
|
+
const idx = arr.indexOf(callback);
|
|
1696
|
+
if (idx !== -1) {
|
|
1697
|
+
arr.splice(idx, 1);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Emit an event to all registered listeners.
|
|
1703
|
+
* @private
|
|
1704
|
+
* @param {string} event
|
|
1705
|
+
* @param {*} [data]
|
|
1706
|
+
*/
|
|
1707
|
+
_emit(event, data) {
|
|
1708
|
+
if (!this._listeners.has(event)) return;
|
|
1709
|
+
for (const fn of this._listeners.get(event)) {
|
|
1710
|
+
try {
|
|
1711
|
+
fn(data);
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
console.error(`SpatialInputSystem: Event handler error for "${event}"`, err);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// ------------------------------------------------------------------
|
|
1719
|
+
// Cleanup
|
|
1720
|
+
// ------------------------------------------------------------------
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Completely destroy the system, removing all listeners, sources,
|
|
1724
|
+
* and global references.
|
|
1725
|
+
*/
|
|
1726
|
+
destroy() {
|
|
1727
|
+
this.disable();
|
|
1728
|
+
|
|
1729
|
+
// Remove all sources (which cleans up their listeners)
|
|
1730
|
+
for (const name of Array.from(this.sources.keys())) {
|
|
1731
|
+
this.removeSource(name);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
this.mappings.clear();
|
|
1735
|
+
this._listeners.clear();
|
|
1736
|
+
this._customTargets.clear();
|
|
1737
|
+
this.smoothing.clear();
|
|
1738
|
+
|
|
1739
|
+
// Clean up global references
|
|
1740
|
+
if (typeof window !== 'undefined') {
|
|
1741
|
+
if (window.spatialInputSystem === this) {
|
|
1742
|
+
delete window.spatialInputSystem;
|
|
1743
|
+
}
|
|
1744
|
+
delete window.enableSpatialInput;
|
|
1745
|
+
delete window.disableSpatialInput;
|
|
1746
|
+
delete window.setSpatialProfile;
|
|
1747
|
+
delete window.feedSpatialInput;
|
|
1748
|
+
delete window.getSpatialState;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
console.log('SpatialInputSystem: Destroyed');
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// ---------------------------------------------------------------------------
|
|
1756
|
+
// Default instance setup
|
|
1757
|
+
// ---------------------------------------------------------------------------
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Create and return a default SpatialInputSystem instance.
|
|
1761
|
+
*
|
|
1762
|
+
* The instance is created with default settings and registers global helpers
|
|
1763
|
+
* on the window object. The caller should then add sources, load a profile,
|
|
1764
|
+
* and call `enable()`.
|
|
1765
|
+
*
|
|
1766
|
+
* @param {SpatialInputOptions} [options={}]
|
|
1767
|
+
* @returns {SpatialInputSystem}
|
|
1768
|
+
*
|
|
1769
|
+
* @example
|
|
1770
|
+
* import { createSpatialInputSystem } from './SpatialInputSystem.js';
|
|
1771
|
+
*
|
|
1772
|
+
* const spatial = createSpatialInputSystem({
|
|
1773
|
+
* onParameterUpdate: (name, value) => engine.setParameter(name, value)
|
|
1774
|
+
* });
|
|
1775
|
+
* spatial.addSource('mouse', 'mousePosition');
|
|
1776
|
+
* spatial.loadProfile('uiElement');
|
|
1777
|
+
* spatial.enable();
|
|
1778
|
+
*/
|
|
1779
|
+
export function createSpatialInputSystem(options = {}) {
|
|
1780
|
+
return new SpatialInputSystem(options);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
export default SpatialInputSystem;
|