@cognipilot/rumoca-core 0.9.4 → 0.9.5

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.
@@ -0,0 +1,1098 @@
1
+ import { ensureParsedSourceRootCache } from './rumoca_runtime.js';
2
+
3
+ const GAMEPAD_AXES = {
4
+ LeftStickX: { index: 0, sign: 1 },
5
+ LeftStickY: { index: 1, sign: -1 },
6
+ RightStickX: { index: 2, sign: 1 },
7
+ RightStickY: { index: 3, sign: -1 },
8
+ };
9
+
10
+ const GAMEPAD_BUTTONS = {
11
+ South: 0,
12
+ East: 1,
13
+ West: 2,
14
+ North: 3,
15
+ Select: 8,
16
+ Start: 9,
17
+ LeftShoulder: 4,
18
+ RightShoulder: 5,
19
+ };
20
+
21
+ function trimMaybeString(value) {
22
+ return typeof value === 'string' ? value.trim() : '';
23
+ }
24
+
25
+ function hasJsonObjectPayload(value) {
26
+ const text = trimMaybeString(value);
27
+ return Boolean(text && text !== '{}' && text !== 'null');
28
+ }
29
+
30
+ function finiteNumber(value, fallback = 0) {
31
+ const number = Number(value);
32
+ return Number.isFinite(number) ? number : fallback;
33
+ }
34
+
35
+ function sortedEntries(object) {
36
+ return object && typeof object === 'object'
37
+ ? Object.entries(object).sort(([a], [b]) => a.localeCompare(b))
38
+ : [];
39
+ }
40
+
41
+ function clamp(value, limits) {
42
+ return Array.isArray(limits) && limits.length >= 2
43
+ ? Math.max(finiteNumber(limits[0]), Math.min(finiteNumber(limits[1]), value))
44
+ : value;
45
+ }
46
+
47
+ function localDefault(def) {
48
+ if (!def || typeof def !== 'object') {
49
+ return 0;
50
+ }
51
+ if (trimMaybeString(def.type).toLowerCase() === 'bool') {
52
+ return Boolean(def.default);
53
+ }
54
+ return finiteNumber(def.default, 0);
55
+ }
56
+
57
+ function sourceValue(source, locals, stepper, runtime) {
58
+ const text = trimMaybeString(source);
59
+ if (!text) {
60
+ return 0;
61
+ }
62
+ if (text.startsWith('local:')) {
63
+ return locals.get(text.slice('local:'.length)) ?? 0;
64
+ }
65
+ if (text.startsWith('stepper:')) {
66
+ const name = text.slice('stepper:'.length);
67
+ return name === 'time' ? stepper.time() : (stepper.get(name) ?? 0);
68
+ }
69
+ if (text.startsWith('runtime:')) {
70
+ return runtime[text.slice('runtime:'.length)] ?? 0;
71
+ }
72
+ return locals.get(text) ?? stepper.get(text) ?? 0;
73
+ }
74
+
75
+ function routeValue(route, locals, stepper, runtime) {
76
+ if (typeof route === 'string') {
77
+ return sourceValue(route, locals, stepper, runtime);
78
+ }
79
+ if (!route || typeof route !== 'object') {
80
+ return 0;
81
+ }
82
+ if (Object.prototype.hasOwnProperty.call(route, 'const')) {
83
+ return finiteNumber(route.const, 0);
84
+ }
85
+ const value = sourceValue(route.from, locals, stepper, runtime);
86
+ if (typeof value === 'boolean') {
87
+ return value ? finiteNumber(route.when_true, 1) : finiteNumber(route.when_false, 0);
88
+ }
89
+ return Number.isFinite(Number(value)) ? Number(value) : finiteNumber(route.default, 0);
90
+ }
91
+
92
+ function comparePrecondition(left, op, right) {
93
+ switch (op) {
94
+ case '<': return left < right;
95
+ case '<=': return left <= right;
96
+ case '>': return left > right;
97
+ case '>=': return left >= right;
98
+ case '==': return left === right;
99
+ case '!=': return left !== right;
100
+ default: return false;
101
+ }
102
+ }
103
+
104
+ function preconditionAllows(expression, locals) {
105
+ const text = trimMaybeString(expression);
106
+ if (!text) {
107
+ return true;
108
+ }
109
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*(<=|>=|==|!=|<|>)\s*(-?(?:\d+\.?\d*|\.\d+))$/.exec(text);
110
+ if (!match) {
111
+ return false;
112
+ }
113
+ return comparePrecondition(finiteNumber(locals.get(match[1]), 0), match[2], Number(match[3]));
114
+ }
115
+
116
+ function sourceGamepadAxis(source, gamepad) {
117
+ const axis = GAMEPAD_AXES[trimMaybeString(source)];
118
+ if (!axis || !gamepad || !Array.isArray(gamepad.axes)) {
119
+ return 0;
120
+ }
121
+ return finiteNumber(gamepad.axes[axis.index], 0) * axis.sign;
122
+ }
123
+
124
+ function sourceGamepadButton(source, gamepad) {
125
+ const index = GAMEPAD_BUTTONS[trimMaybeString(source)];
126
+ return index !== undefined && Boolean(gamepad?.buttons?.[index]?.pressed);
127
+ }
128
+
129
+ function normalizedKeyboardKey(eventOrKey) {
130
+ const key = typeof eventOrKey === 'string' ? eventOrKey : eventOrKey?.key;
131
+ if (key === ' ') {
132
+ return 'Space';
133
+ }
134
+ if (key === 'Esc') {
135
+ return 'Escape';
136
+ }
137
+ return typeof key === 'string' && key.length === 1 ? key.toLowerCase() : trimMaybeString(key);
138
+ }
139
+
140
+ function createInputRuntime(config) {
141
+ const locals = new Map();
142
+ const keyboardBindings = {};
143
+ for (const [key, binding] of sortedEntries(config?.input?.keyboard?.keys)) {
144
+ const normalized = normalizedKeyboardKey(key);
145
+ if (normalized) {
146
+ keyboardBindings[normalized] = binding;
147
+ }
148
+ }
149
+ for (const [name, def] of sortedEntries(config?.locals)) {
150
+ locals.set(name, localDefault(def));
151
+ }
152
+ const pressedKeys = new Set();
153
+ const signals = new Set();
154
+ const debounceUntil = new Map();
155
+ let connectedGamepad = null;
156
+ let lastMode = 'keyboard';
157
+
158
+ function resetLocals() {
159
+ locals.clear();
160
+ for (const [name, def] of sortedEntries(config?.locals)) {
161
+ locals.set(name, localDefault(def));
162
+ }
163
+ signals.clear();
164
+ }
165
+
166
+ function signal(name) {
167
+ const text = trimMaybeString(name);
168
+ if (text) {
169
+ signals.add(text);
170
+ }
171
+ }
172
+
173
+ function applyAction(id, binding, active, nowMs) {
174
+ const action = trimMaybeString(binding?.action).toLowerCase();
175
+ if (action === 'set') {
176
+ if (active) {
177
+ locals.set(trimMaybeString(binding.target), finiteNumber(binding.value, 0));
178
+ }
179
+ return;
180
+ }
181
+ if (!active) {
182
+ return;
183
+ }
184
+ const waitUntil = debounceUntil.get(id) || 0;
185
+ if (nowMs < waitUntil || !preconditionAllows(binding?.precondition, locals)) {
186
+ return;
187
+ }
188
+ const debounceMs = Math.max(0, finiteNumber(binding?.debounce_ms ?? binding?.debounceMs, 0));
189
+ if (debounceMs > 0) {
190
+ debounceUntil.set(id, nowMs + debounceMs);
191
+ }
192
+ if (action === 'toggle') {
193
+ const state = trimMaybeString(binding.state);
194
+ locals.set(state, !Boolean(locals.get(state)));
195
+ } else if (action === 'signal') {
196
+ signal(binding.signal);
197
+ }
198
+ }
199
+
200
+ function keyDown(event) {
201
+ const key = normalizedKeyboardKey(event);
202
+ const binding = keyboardBindings[key];
203
+ if (!binding) {
204
+ return false;
205
+ }
206
+ pressedKeys.add(key);
207
+ applyAction(`key:${key}`, binding, true, performance.now());
208
+ event.preventDefault();
209
+ return true;
210
+ }
211
+
212
+ function keyUp(event) {
213
+ const key = normalizedKeyboardKey(event);
214
+ const binding = keyboardBindings[key];
215
+ if (!binding) {
216
+ return false;
217
+ }
218
+ pressedKeys.delete(key);
219
+ applyAction(`key:${key}`, binding, false, performance.now());
220
+ event.preventDefault();
221
+ return true;
222
+ }
223
+
224
+ function pollGamepad() {
225
+ const pads = typeof navigator !== 'undefined' && navigator.getGamepads
226
+ ? Array.from(navigator.getGamepads()).filter(Boolean)
227
+ : [];
228
+ connectedGamepad = pads[0] || null;
229
+ if (connectedGamepad) {
230
+ lastMode = 'gamepad';
231
+ }
232
+ return connectedGamepad;
233
+ }
234
+
235
+ function update(dt) {
236
+ const input = config?.input || {};
237
+ const gamepad = input.mode === 'keyboard' ? null : pollGamepad();
238
+ if (!gamepad && pressedKeys.size > 0) {
239
+ lastMode = 'keyboard';
240
+ }
241
+ const nowMs = performance.now();
242
+ for (const [key, binding] of sortedEntries(keyboardBindings)) {
243
+ if (pressedKeys.has(key)) {
244
+ applyAction(`key:${key}`, binding, true, nowMs);
245
+ }
246
+ }
247
+ applyIntegrators(input.keyboard?.integrators, dt, null);
248
+ if (gamepad) {
249
+ applyGamepadAxes(input.gamepad?.axes, gamepad);
250
+ applyIntegrators(input.gamepad?.integrators, dt, gamepad);
251
+ applyGamepadButtons(input.gamepad?.buttons, gamepad, nowMs);
252
+ }
253
+ }
254
+
255
+ function applyIntegrators(integrators, dt, gamepad) {
256
+ for (const [name, spec] of sortedEntries(integrators)) {
257
+ const raw = gamepad ? sourceGamepadAxis(spec.source || name, gamepad) : sourceValue(spec.source, locals, { get: () => 0, time: () => 0 }, {});
258
+ const deadband = Math.abs(finiteNumber(spec.deadband, 0));
259
+ const active = Math.abs(raw) > deadband ? raw : 0;
260
+ const write = trimMaybeString(spec.write || name);
261
+ const next = finiteNumber(locals.get(write), 0) + active * finiteNumber(spec.rate, 1) * dt;
262
+ locals.set(write, clamp(next, spec.clamp));
263
+ }
264
+ }
265
+
266
+ function applyGamepadAxes(axes, gamepad) {
267
+ for (const [name, spec] of sortedEntries(axes)) {
268
+ const value = sourceGamepadAxis(spec.source || name, gamepad)
269
+ * finiteNumber(spec.scale, 1)
270
+ * (spec.invert ? -1 : 1);
271
+ locals.set(trimMaybeString(spec.write || name), value);
272
+ }
273
+ }
274
+
275
+ function applyGamepadButtons(buttons, gamepad, nowMs) {
276
+ for (const [name, spec] of sortedEntries(buttons)) {
277
+ applyAction(`button:${name}`, spec, sourceGamepadButton(spec.source || name, gamepad), nowMs);
278
+ }
279
+ }
280
+
281
+ function takeSignal(name) {
282
+ const text = trimMaybeString(name);
283
+ const found = signals.has(text);
284
+ signals.delete(text);
285
+ return found;
286
+ }
287
+
288
+ return {
289
+ locals,
290
+ keyDown,
291
+ keyUp,
292
+ hasKeyboardBinding(eventOrKey) {
293
+ return Boolean(keyboardBindings[normalizedKeyboardKey(eventOrKey)]);
294
+ },
295
+ releaseKeys() {
296
+ pressedKeys.clear();
297
+ },
298
+ resetLocals,
299
+ takeSignal,
300
+ update,
301
+ runtimeFields(frameNum, stepperTime = 0) {
302
+ return {
303
+ frame_num: frameNum,
304
+ wall_ms: performance.now(),
305
+ input_connected: Boolean(connectedGamepad),
306
+ input_mode: lastMode,
307
+ stepper_time: stepperTime,
308
+ };
309
+ },
310
+ };
311
+ }
312
+
313
+ function createViewerRuntime({ THREE, container, viewerSignals, assetBaseUrl, pointer, config }) {
314
+ const ownerDocument = container.ownerDocument || document;
315
+ const ownerWindow = ownerDocument.defaultView || globalThis;
316
+ const canvas = ownerDocument.createElement('canvas');
317
+ canvas.className = 'rumoca-interactive-canvas';
318
+ const flightHud = ownerDocument.createElement('canvas');
319
+ flightHud.className = 'rumoca-interactive-flight-hud';
320
+ container.replaceChildren(canvas, flightHud);
321
+
322
+ const scene = new THREE.Scene();
323
+ const camera = new THREE.PerspectiveCamera(60, 1, 0.01, 5000);
324
+ camera.position.set(4, 2.4, 6);
325
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
326
+ renderer.setPixelRatio(Math.max(1, Math.min(2, ownerWindow.devicePixelRatio || 1)));
327
+ const flightHudCtx = flightHud.getContext('2d');
328
+ const state = {};
329
+ const cam = {
330
+ target: new THREE.Vector3(0, 0, 0),
331
+ dist: 7,
332
+ angle: Math.PI,
333
+ elev: 0.22,
334
+ };
335
+ const viewerConfig = config?.viewer || {};
336
+ const frames = Array.isArray(viewerConfig.frame) ? viewerConfig.frame : [];
337
+ const frameMatrices = new Map(frames.map((frame) => [frame.name, new THREE.Matrix4()]));
338
+ const configuredCameras = (Array.isArray(viewerConfig.camera) ? viewerConfig.camera : []).map((cameraConfig) => ({
339
+ name: trimMaybeString(cameraConfig.name),
340
+ frame: trimMaybeString(cameraConfig.frame),
341
+ mount: fluVector(THREE, cameraConfig.mount, [0, 0, 0]),
342
+ look: fluVector(THREE, cameraConfig.look, [1, 0, 0]).normalize(),
343
+ up: fluVector(THREE, cameraConfig.up, [0, 0, 1]).normalize(),
344
+ })).filter((cameraConfig) => cameraConfig.name && cameraConfig.frame);
345
+ const cameraModes = ['scene', ...configuredCameras.map((cameraConfig) => cameraConfig.name)];
346
+ const cameraScratch = {
347
+ mount: new THREE.Vector3(),
348
+ look: new THREE.Vector3(),
349
+ up: new THREE.Vector3(),
350
+ target: new THREE.Vector3(),
351
+ };
352
+ let cameraMode = cameraModes[0];
353
+ let flightHudVisible = Boolean(viewerConfig.hud);
354
+
355
+ if (assetBaseUrl && THREE.DefaultLoadingManager?.setURLModifier) {
356
+ THREE.DefaultLoadingManager.setURLModifier((url) => {
357
+ if (String(url).startsWith('/assets/')) {
358
+ return new URL(String(url).slice('/assets/'.length), assetBaseUrl).href;
359
+ }
360
+ return url;
361
+ });
362
+ }
363
+
364
+ function resize() {
365
+ const rect = container.getBoundingClientRect();
366
+ const width = Math.max(1, Math.floor(rect.width));
367
+ const height = Math.max(240, Math.floor(rect.height || width * 0.56));
368
+ renderer.setSize(width, height, false);
369
+ camera.aspect = width / height;
370
+ camera.updateProjectionMatrix();
371
+ const dpr = Math.max(1, Math.min(2, ownerWindow.devicePixelRatio || 1));
372
+ flightHud.width = Math.floor(width * dpr);
373
+ flightHud.height = Math.floor(height * dpr);
374
+ flightHud.style.width = `${width}px`;
375
+ flightHud.style.height = `${height}px`;
376
+ flightHudCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
377
+ }
378
+ resize();
379
+
380
+ function currentState() {
381
+ return Object.fromEntries(viewerSignals.entries());
382
+ }
383
+
384
+ function drawFlightHud(hud, hudState, roll, pitch, speed) {
385
+ const rect = canvas.getBoundingClientRect();
386
+ const width = Math.max(1, Math.floor(rect.width));
387
+ const height = Math.max(1, Math.floor(rect.height));
388
+ flightHudCtx.clearRect(0, 0, width, height);
389
+ if (!flightHudVisible || (!hudState.t && hudState.t !== 0)) {
390
+ return;
391
+ }
392
+ const cx = width / 2;
393
+ const cy = height / 2;
394
+ const hudColor = 'rgba(94, 255, 190, 0.92)';
395
+ const hudDim = 'rgba(94, 255, 190, 0.42)';
396
+ const textShadow = 'rgba(0, 0, 0, 0.75)';
397
+ const pitchPxPerRad = Math.min(width, height) * 0.42;
398
+
399
+ flightHudCtx.save();
400
+ flightHudCtx.translate(cx, cy);
401
+ flightHudCtx.rotate(-roll);
402
+ flightHudCtx.translate(0, pitch * pitchPxPerRad);
403
+ flightHudCtx.strokeStyle = hudColor;
404
+ flightHudCtx.lineWidth = 2;
405
+ flightHudCtx.beginPath();
406
+ flightHudCtx.moveTo(-160, 0);
407
+ flightHudCtx.lineTo(-35, 0);
408
+ flightHudCtx.moveTo(35, 0);
409
+ flightHudCtx.lineTo(160, 0);
410
+ flightHudCtx.stroke();
411
+
412
+ flightHudCtx.strokeStyle = hudDim;
413
+ flightHudCtx.lineWidth = 1.5;
414
+ flightHudCtx.font = '12px monospace';
415
+ flightHudCtx.textAlign = 'center';
416
+ flightHudCtx.textBaseline = 'middle';
417
+ for (let deg = -30; deg <= 30; deg += 10) {
418
+ if (deg === 0) {
419
+ continue;
420
+ }
421
+ const y = -deg * Math.PI / 180 * pitchPxPerRad;
422
+ const half = Math.abs(deg) % 20 === 0 ? 72 : 45;
423
+ flightHudCtx.beginPath();
424
+ flightHudCtx.moveTo(-half, y);
425
+ flightHudCtx.lineTo(-16, y);
426
+ flightHudCtx.moveTo(16, y);
427
+ flightHudCtx.lineTo(half, y);
428
+ flightHudCtx.stroke();
429
+ flightHudCtx.fillStyle = hudColor;
430
+ flightHudCtx.fillText(String(Math.abs(deg)), -half - 18, y);
431
+ flightHudCtx.fillText(String(Math.abs(deg)), half + 18, y);
432
+ }
433
+ flightHudCtx.restore();
434
+
435
+ flightHudCtx.save();
436
+ flightHudCtx.translate(cx, cy);
437
+ flightHudCtx.strokeStyle = hudColor;
438
+ flightHudCtx.lineWidth = 2;
439
+ flightHudCtx.beginPath();
440
+ flightHudCtx.moveTo(-18, 0);
441
+ flightHudCtx.lineTo(-6, 0);
442
+ flightHudCtx.lineTo(0, 8);
443
+ flightHudCtx.lineTo(6, 0);
444
+ flightHudCtx.lineTo(18, 0);
445
+ flightHudCtx.stroke();
446
+ flightHudCtx.beginPath();
447
+ flightHudCtx.arc(0, 0, 4, 0, Math.PI * 2);
448
+ flightHudCtx.stroke();
449
+ flightHudCtx.restore();
450
+
451
+ flightHudCtx.save();
452
+ flightHudCtx.translate(cx, 76);
453
+ flightHudCtx.rotate(-roll);
454
+ flightHudCtx.strokeStyle = hudColor;
455
+ flightHudCtx.lineWidth = 2;
456
+ flightHudCtx.beginPath();
457
+ flightHudCtx.arc(0, 0, 54, Math.PI * 1.1, Math.PI * 1.9);
458
+ flightHudCtx.stroke();
459
+ for (const deg of [-45, -30, -15, 0, 15, 30, 45]) {
460
+ const angle = (deg - 90) * Math.PI / 180;
461
+ const r1 = deg === 0 ? 43 : 48;
462
+ const r2 = 56;
463
+ flightHudCtx.beginPath();
464
+ flightHudCtx.moveTo(Math.cos(angle) * r1, Math.sin(angle) * r1);
465
+ flightHudCtx.lineTo(Math.cos(angle) * r2, Math.sin(angle) * r2);
466
+ flightHudCtx.stroke();
467
+ }
468
+ flightHudCtx.restore();
469
+
470
+ const hudText = (text, x, y, align = 'left') => {
471
+ flightHudCtx.font = '15px monospace';
472
+ flightHudCtx.textAlign = align;
473
+ flightHudCtx.textBaseline = 'middle';
474
+ flightHudCtx.fillStyle = textShadow;
475
+ flightHudCtx.fillText(text, x + 1, y + 1);
476
+ flightHudCtx.fillStyle = hudColor;
477
+ flightHudCtx.fillText(text, x, y);
478
+ };
479
+
480
+ if (hud.altitude) {
481
+ hudText(`ALT ${Number(hudState[hud.altitude] ?? 0).toFixed(1)} m`, width - 34, cy - 40, 'right');
482
+ }
483
+ if (Array.isArray(hud.speed) && hud.speed.length > 0) {
484
+ hudText(`SPD ${speed.toFixed(1)} m/s`, 34, cy - 40);
485
+ }
486
+ hudText(`ROLL ${(roll * 180 / Math.PI).toFixed(1)}°`, 34, cy + 44);
487
+ hudText(`PITCH ${(pitch * 180 / Math.PI).toFixed(1)}°`, width - 34, cy + 44, 'right');
488
+ if (hud.sticks) {
489
+ const stick = (name) => Number(hudState[name] ?? 0).toFixed(2);
490
+ const rows = [];
491
+ if (hud.sticks.roll) rows.push(`AIL ${stick(hud.sticks.roll)}`);
492
+ if (hud.sticks.pitch) rows.push(`ELE ${stick(hud.sticks.pitch)}`);
493
+ if (hud.sticks.yaw) rows.push(`RUD ${stick(hud.sticks.yaw)}`);
494
+ if (hud.sticks.throttle) rows.push(`THR ${stick(hud.sticks.throttle)}`);
495
+ if (rows.length > 0) {
496
+ hudText(rows.join(' '), cx, height - 44, 'center');
497
+ }
498
+ }
499
+ }
500
+
501
+ function applyConfiguredCamera(cameraConfig) {
502
+ const matrix = frameMatrices.get(cameraConfig.frame);
503
+ if (!matrix) {
504
+ return;
505
+ }
506
+ const mount = cameraScratch.mount.copy(cameraConfig.mount).applyMatrix4(matrix);
507
+ const look = cameraScratch.look.copy(cameraConfig.look).transformDirection(matrix);
508
+ const up = cameraScratch.up.copy(cameraConfig.up).transformDirection(matrix);
509
+ camera.position.copy(mount);
510
+ camera.up.copy(up);
511
+ camera.lookAt(cameraScratch.target.copy(mount).add(look));
512
+ }
513
+
514
+ const api = {
515
+ THREE,
516
+ GLTFLoader: THREE.GLTFLoader,
517
+ canvas,
518
+ camera,
519
+ cameraMode,
520
+ cam,
521
+ frames: frameMatrices,
522
+ get: (name) => viewerSignals.get(name),
523
+ motors: {},
524
+ pointer,
525
+ renderer,
526
+ scene,
527
+ state,
528
+ };
529
+
530
+ return {
531
+ api,
532
+ canvas,
533
+ ownerDocument,
534
+ ownerWindow,
535
+ cycleCamera() {
536
+ if (cameraModes.length <= 1) {
537
+ return cameraMode;
538
+ }
539
+ const next = (cameraModes.indexOf(cameraMode) + 1) % cameraModes.length;
540
+ cameraMode = cameraModes[next];
541
+ api.cameraMode = cameraMode;
542
+ return cameraMode;
543
+ },
544
+ toggleHud() {
545
+ if (!viewerConfig.hud) {
546
+ return flightHudVisible;
547
+ }
548
+ flightHudVisible = !flightHudVisible;
549
+ flightHud.style.display = flightHudVisible ? '' : 'none';
550
+ return flightHudVisible;
551
+ },
552
+ render() {
553
+ resize();
554
+ const hudState = currentState();
555
+ updateFrameMatrices(frames, frameMatrices, hudState);
556
+ const activeCamera = configuredCameras.find((cameraConfig) => cameraConfig.name === cameraMode);
557
+ if (activeCamera) {
558
+ applyConfiguredCamera(activeCamera);
559
+ } else {
560
+ camera.up.set(0, 1, 0);
561
+ }
562
+ const hud = viewerConfig.hud;
563
+ if (hud && flightHudVisible) {
564
+ const matrix = frameMatrices.get(hud.frame);
565
+ const attitude = matrix ? visualAttitudeFromMatrix(THREE, matrix) : { roll: 0, pitch: 0 };
566
+ const speedSignals = Array.isArray(hud.speed) ? hud.speed : [];
567
+ const speed = Math.sqrt(speedSignals.reduce((sum, name) => {
568
+ const value = Number(hudState[name] ?? 0);
569
+ return sum + value * value;
570
+ }, 0));
571
+ drawFlightHud(hud, hudState, attitude.roll, attitude.pitch, speed);
572
+ } else {
573
+ const rect = canvas.getBoundingClientRect();
574
+ flightHudCtx.clearRect(0, 0, Math.max(1, rect.width), Math.max(1, rect.height));
575
+ }
576
+ renderer.render(scene, camera);
577
+ },
578
+ };
579
+ }
580
+
581
+ function keyDisplayName(key) {
582
+ switch (key) {
583
+ case 'ArrowUp': return '↑';
584
+ case 'ArrowDown': return '↓';
585
+ case 'ArrowLeft': return '←';
586
+ case 'ArrowRight': return '→';
587
+ case 'Space': return 'Space';
588
+ default: return key.length === 1 ? key.toUpperCase() : key;
589
+ }
590
+ }
591
+
592
+ function fluVector(THREE, value, fallback) {
593
+ const vector = Array.isArray(value) && value.length === 3 && value.every(Number.isFinite)
594
+ ? value
595
+ : fallback;
596
+ return new THREE.Vector3(-vector[1], vector[2], vector[0]);
597
+ }
598
+
599
+ function signalCoord(state, ref) {
600
+ if (typeof ref === 'number') {
601
+ return ref;
602
+ }
603
+ const value = Number(state[ref]);
604
+ return Number.isFinite(value) ? value : 0;
605
+ }
606
+
607
+ function updateFrameMatrices(frames, frameMatrices, state) {
608
+ for (const frame of frames) {
609
+ const matrix = frameMatrices.get(frame.name);
610
+ if (!matrix) {
611
+ continue;
612
+ }
613
+ const position = frame.position ?? [];
614
+ const px = signalCoord(state, position[0] ?? 0);
615
+ const py = signalCoord(state, position[1] ?? 0);
616
+ const pz = signalCoord(state, position[2] ?? 0);
617
+ let q0 = 1;
618
+ let q1 = 0;
619
+ let q2 = 0;
620
+ let q3 = 0;
621
+ if (frame.quaternion) {
622
+ q0 = signalCoord(state, frame.quaternion[0]);
623
+ q1 = signalCoord(state, frame.quaternion[1]);
624
+ q2 = signalCoord(state, frame.quaternion[2]);
625
+ q3 = signalCoord(state, frame.quaternion[3]);
626
+ if (q0 === 0 && q1 === 0 && q2 === 0 && q3 === 0) {
627
+ q0 = 1;
628
+ }
629
+ } else if (frame.heading !== undefined && frame.heading !== null) {
630
+ const psi = signalCoord(state, frame.heading);
631
+ q0 = Math.cos(psi / 2);
632
+ q3 = Math.sin(psi / 2);
633
+ }
634
+ const r11 = 1 - 2 * (q2 * q2 + q3 * q3);
635
+ const r12 = 2 * (q1 * q2 - q0 * q3);
636
+ const r13 = 2 * (q1 * q3 + q0 * q2);
637
+ const r21 = 2 * (q1 * q2 + q0 * q3);
638
+ const r22 = 1 - 2 * (q1 * q1 + q3 * q3);
639
+ const r23 = 2 * (q2 * q3 - q0 * q1);
640
+ const r31 = 2 * (q1 * q3 - q0 * q2);
641
+ const r32 = 2 * (q2 * q3 + q0 * q1);
642
+ const r33 = 1 - 2 * (q1 * q1 + q2 * q2);
643
+ matrix.set(
644
+ r22, -r23, -r21, -py,
645
+ -r32, r33, r31, pz,
646
+ -r12, r13, r11, px,
647
+ 0, 0, 0, 1
648
+ );
649
+ }
650
+ }
651
+
652
+ function visualAttitudeFromMatrix(THREE, matrix) {
653
+ const forward = new THREE.Vector3(-1, 0, 0).transformDirection(matrix);
654
+ const right = new THREE.Vector3(0, 0, 1).transformDirection(matrix);
655
+ const up = new THREE.Vector3(0, 1, 0).transformDirection(matrix);
656
+ return {
657
+ roll: Math.atan2(right.y, up.y),
658
+ pitch: Math.asin(clamp(forward.y, [-1, 1])),
659
+ };
660
+ }
661
+
662
+ function compileSceneScript(scriptText, api) {
663
+ const ctx = {};
664
+ const fn = new Function('ctx', 'api', `${scriptText || ''}\nreturn ctx;`);
665
+ return fn(ctx, api) || ctx;
666
+ }
667
+
668
+ function buildStepperInputs(config, input, stepper, runtime) {
669
+ const routes = config?.signals?.stepper_inputs || {};
670
+ return sortedEntries(routes).map(([name, route]) => [
671
+ name,
672
+ routeValue(route, input.locals, stepper, runtime),
673
+ ]);
674
+ }
675
+
676
+ function buildViewerSignals(config, input, stepper, runtime) {
677
+ const result = new Map();
678
+ for (const [name, route] of sortedEntries(config?.signals?.viewer)) {
679
+ result.set(name, routeValue(route, input.locals, stepper, runtime));
680
+ }
681
+ return result;
682
+ }
683
+
684
+ export function scenarioUsesInputRuntime(config) {
685
+ return Boolean(config?.input);
686
+ }
687
+
688
+ export async function createInteractiveSimulation(options) {
689
+ const {
690
+ wasm,
691
+ THREE,
692
+ source,
693
+ modelName,
694
+ config,
695
+ sourceRootCacheUrl = '',
696
+ sourceRoots = '{}',
697
+ workspaceSources = '{}',
698
+ container,
699
+ scriptText = '',
700
+ assetBaseUrl = '',
701
+ onStatus = () => {},
702
+ onError = () => {},
703
+ } = options || {};
704
+ if (!wasm || typeof wasm.WasmStepper !== 'function') {
705
+ throw new Error('Interactive stepping is missing from this WASM package.');
706
+ }
707
+ await ensureParsedSourceRootCache(wasm, sourceRootCacheUrl);
708
+ if (hasJsonObjectPayload(sourceRoots)) {
709
+ if (typeof wasm.load_source_roots !== 'function') {
710
+ throw new Error('Source-root loading is missing from this WASM package.');
711
+ }
712
+ onStatus('loading source roots');
713
+ wasm.load_source_roots(sourceRoots);
714
+ }
715
+ if (hasJsonObjectPayload(workspaceSources)) {
716
+ if (typeof wasm.sync_workspace_sources !== 'function') {
717
+ throw new Error('Workspace-source syncing is missing from this WASM package.');
718
+ }
719
+ onStatus('syncing workspace sources');
720
+ wasm.sync_workspace_sources(workspaceSources);
721
+ }
722
+ const input = createInputRuntime(config || {});
723
+ onStatus('compiling stepper');
724
+ const stepper = new wasm.WasmStepper(source, modelName);
725
+ const viewerSignals = new Map();
726
+ const pointer = {
727
+ captured: false,
728
+ buttons: 0,
729
+ x: 0,
730
+ y: 0,
731
+ dx: 0,
732
+ dy: 0,
733
+ wheel: 0,
734
+ pointerType: '',
735
+ };
736
+ const viewer = createViewerRuntime({ THREE, container, viewerSignals, assetBaseUrl, pointer, config });
737
+ const scene = compileSceneScript(scriptText, viewer.api);
738
+ onStatus('initializing scene');
739
+ if (typeof scene.onInit === 'function') {
740
+ await Promise.resolve(scene.onInit(viewer.api));
741
+ }
742
+ onStatus('ready');
743
+
744
+ const simDt = Math.max(0.001, finiteNumber(config?.sim?.dt, 0.01));
745
+ let frameNum = 0;
746
+ let running = false;
747
+ let raf = null;
748
+ let lastTime = 0;
749
+ let accumulator = 0;
750
+ let updateCaptureUi = () => {};
751
+
752
+ function refreshViewerSignals() {
753
+ viewerSignals.clear();
754
+ const nextSignals = buildViewerSignals(
755
+ config,
756
+ input,
757
+ stepper,
758
+ input.runtimeFields(frameNum, stepper.time()),
759
+ );
760
+ for (const [name, value] of nextSignals) {
761
+ viewerSignals.set(name, value);
762
+ }
763
+ }
764
+
765
+ function stopAnimation() {
766
+ running = false;
767
+ if (raf !== null) {
768
+ ownerWindow.cancelAnimationFrame(raf);
769
+ raf = null;
770
+ }
771
+ }
772
+
773
+ function reportRuntimeError(error) {
774
+ stopAnimation();
775
+ const message = error?.message || error || 'Interactive simulation runtime error';
776
+ onStatus(`failed: ${message}`);
777
+ onError(error);
778
+ }
779
+
780
+ function routeFrame(dt) {
781
+ input.update(dt);
782
+ if (config?.reset?.on_signal && input.takeSignal(config.reset.on_signal)) {
783
+ if (config.reset.reset_locals) {
784
+ input.resetLocals();
785
+ }
786
+ if (config.reset.rebuild_stepper) {
787
+ stepper.reset();
788
+ }
789
+ }
790
+ if (input.takeSignal(trimMaybeString(config?.quit?.on_signal) || 'quit')) {
791
+ stopAnimation();
792
+ onStatus('stopped');
793
+ return false;
794
+ }
795
+ const runtime = input.runtimeFields(frameNum, stepper.time());
796
+ for (const [name, value] of buildStepperInputs(config, input, stepper, runtime)) {
797
+ stepper.set_input(name, finiteNumber(value, 0));
798
+ }
799
+ stepper.step(simDt);
800
+ refreshViewerSignals();
801
+ frameNum += 1;
802
+ return true;
803
+ }
804
+
805
+ function tick(now) {
806
+ try {
807
+ tickFrame(now);
808
+ } catch (error) {
809
+ reportRuntimeError(error);
810
+ }
811
+ }
812
+
813
+ function tickFrame(now) {
814
+ if (!running) {
815
+ return;
816
+ }
817
+ if (lastTime === 0) {
818
+ lastTime = now;
819
+ }
820
+ accumulator += Math.min(0.08, Math.max(0, (now - lastTime) / 1000));
821
+ lastTime = now;
822
+ let steps = 0;
823
+ while (accumulator >= simDt && steps < 8) {
824
+ if (!routeFrame(simDt)) {
825
+ return;
826
+ }
827
+ accumulator -= simDt;
828
+ steps += 1;
829
+ }
830
+ if (typeof scene.onFrame === 'function') {
831
+ scene.onFrame(viewer.api);
832
+ }
833
+ pointer.dx = 0;
834
+ pointer.dy = 0;
835
+ pointer.wheel = 0;
836
+ viewer.render();
837
+ onStatus(`live t=${stepper.time().toFixed(2)} s · ${input.runtimeFields(frameNum).input_mode}`);
838
+ raf = ownerWindow.requestAnimationFrame(tick);
839
+ }
840
+
841
+ let inputCaptureActive = false;
842
+ let lastCapturedKey = '';
843
+ let pointerLockExitReleasesCapture = true;
844
+ const eventCaptureOptions = { capture: true, passive: false };
845
+ const ownerDocument = viewer.ownerDocument;
846
+ const ownerWindow = viewer.ownerWindow;
847
+ const keyDown = (event) => input.keyDown(event);
848
+ const keyUp = (event) => input.keyUp(event);
849
+ const updatePointerFromEvent = (event) => {
850
+ const rect = viewer.canvas.getBoundingClientRect();
851
+ pointer.buttons = event.buttons || 0;
852
+ pointer.pointerType = trimMaybeString(event.pointerType);
853
+ pointer.x = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0;
854
+ pointer.y = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0;
855
+ pointer.dx += finiteNumber(event.movementX, 0);
856
+ pointer.dy += finiteNumber(event.movementY, 0);
857
+ };
858
+ const requestPointerCapture = () => {
859
+ if (ownerDocument.pointerLockElement === viewer.canvas || typeof viewer.canvas.requestPointerLock !== 'function') {
860
+ return;
861
+ }
862
+ try {
863
+ viewer.canvas.requestPointerLock();
864
+ } catch {
865
+ pointer.captured = false;
866
+ }
867
+ };
868
+ const releasePointerCapture = () => {
869
+ if (ownerDocument.pointerLockElement !== viewer.canvas || typeof ownerDocument.exitPointerLock !== 'function') {
870
+ return;
871
+ }
872
+ pointerLockExitReleasesCapture = false;
873
+ ownerDocument.exitPointerLock();
874
+ queueMicrotask(() => {
875
+ pointerLockExitReleasesCapture = true;
876
+ });
877
+ };
878
+ const setCaptureActive = (active, options = {}) => {
879
+ inputCaptureActive = Boolean(active);
880
+ if (!inputCaptureActive) {
881
+ input.releaseKeys();
882
+ pointer.buttons = 0;
883
+ if (!options.keepPointerLock) {
884
+ releasePointerCapture();
885
+ }
886
+ } else if (options.requestPointerLock) {
887
+ requestPointerCapture();
888
+ }
889
+ updateCaptureUi(inputCaptureActive);
890
+ };
891
+ const handleViewerKeyDown = (event) => {
892
+ if (event.repeat) {
893
+ return false;
894
+ }
895
+ const key = normalizedKeyboardKey(event);
896
+ if (key === 'c') {
897
+ lastCapturedKey = `camera ${viewer.cycleCamera()}`;
898
+ updateCaptureUi(true);
899
+ return true;
900
+ }
901
+ if (key === 'h') {
902
+ lastCapturedKey = `hud ${viewer.toggleHud() ? 'on' : 'off'}`;
903
+ updateCaptureUi(true);
904
+ return true;
905
+ }
906
+ return false;
907
+ };
908
+ const captureKeyDown = (event) => {
909
+ if (!inputCaptureActive) {
910
+ return;
911
+ }
912
+ event.preventDefault();
913
+ event.stopPropagation();
914
+ event.stopImmediatePropagation();
915
+ if (normalizedKeyboardKey(event) === 'Escape') {
916
+ setCaptureActive(false);
917
+ return;
918
+ }
919
+ lastCapturedKey = normalizedKeyboardKey(event);
920
+ if (handleViewerKeyDown(event)) {
921
+ return;
922
+ }
923
+ if (input.hasKeyboardBinding(event)) {
924
+ updateCaptureUi(true);
925
+ input.keyDown(event);
926
+ }
927
+ };
928
+ const captureKeyUp = (event) => {
929
+ if (!inputCaptureActive) {
930
+ return;
931
+ }
932
+ event.preventDefault();
933
+ event.stopPropagation();
934
+ event.stopImmediatePropagation();
935
+ if (input.hasKeyboardBinding(event)) {
936
+ input.keyUp(event);
937
+ }
938
+ };
939
+ const captureKeyPress = (event) => {
940
+ if (!inputCaptureActive) {
941
+ return;
942
+ }
943
+ event.preventDefault();
944
+ event.stopPropagation();
945
+ event.stopImmediatePropagation();
946
+ };
947
+ const capturePointerEvent = (event) => {
948
+ if (!inputCaptureActive) {
949
+ return;
950
+ }
951
+ if (event.target?.closest?.('.rumoca-interactive-controls')) {
952
+ return;
953
+ }
954
+ updatePointerFromEvent(event);
955
+ event.preventDefault();
956
+ event.stopPropagation();
957
+ event.stopImmediatePropagation();
958
+ };
959
+ const captureWheel = (event) => {
960
+ if (!inputCaptureActive) {
961
+ return;
962
+ }
963
+ pointer.wheel += finiteNumber(event.deltaY, 0);
964
+ event.preventDefault();
965
+ event.stopPropagation();
966
+ event.stopImmediatePropagation();
967
+ };
968
+ const handlePointerLockChange = () => {
969
+ pointer.captured = ownerDocument.pointerLockElement === viewer.canvas;
970
+ if (!pointer.captured && inputCaptureActive && pointerLockExitReleasesCapture) {
971
+ setCaptureActive(false, { keepPointerLock: true });
972
+ } else {
973
+ updateCaptureUi(inputCaptureActive);
974
+ }
975
+ };
976
+ const releaseCapture = () => {
977
+ setCaptureActive(false);
978
+ };
979
+ const focus = () => {
980
+ container.focus({ preventScroll: true });
981
+ };
982
+ const capturePointerDown = (event) => {
983
+ if (event.target?.closest?.('.rumoca-interactive-controls')) {
984
+ return;
985
+ }
986
+ if (container.contains(event.target)) {
987
+ focus();
988
+ if (inputCaptureActive) {
989
+ capturePointerEvent(event);
990
+ }
991
+ } else {
992
+ releaseCapture();
993
+ }
994
+ };
995
+ const controls = ownerDocument.createElement('div');
996
+ controls.className = 'rumoca-interactive-controls';
997
+ const captureToggle = ownerDocument.createElement('button');
998
+ captureToggle.type = 'button';
999
+ captureToggle.className = 'rumoca-interactive-capture-toggle';
1000
+ captureToggle.title = 'Capture keyboard and mouse input. Press Escape to release capture.';
1001
+ updateCaptureUi = (active) => {
1002
+ captureToggle.textContent = active
1003
+ ? `Release: Esc${lastCapturedKey ? ` · ${keyDisplayName(lastCapturedKey)}` : ''}${pointer.captured ? ' · mouse' : ''}`
1004
+ : 'Capture';
1005
+ captureToggle.setAttribute('aria-pressed', active ? 'true' : 'false');
1006
+ controls.classList.toggle('is-capturing', active);
1007
+ };
1008
+ captureToggle.addEventListener('pointerdown', (event) => {
1009
+ event.preventDefault();
1010
+ event.stopPropagation();
1011
+ setCaptureActive(!inputCaptureActive, { requestPointerLock: true });
1012
+ container.focus({ preventScroll: true });
1013
+ });
1014
+ controls.appendChild(captureToggle);
1015
+ updateCaptureUi(false);
1016
+ container.appendChild(controls);
1017
+ container.tabIndex = 0;
1018
+ viewer.canvas.tabIndex = -1;
1019
+ container.addEventListener('keydown', keyDown);
1020
+ container.addEventListener('keyup', keyUp);
1021
+ container.addEventListener('pointerdown', focus);
1022
+ viewer.canvas.addEventListener('pointerdown', focus);
1023
+ ownerWindow.addEventListener('keydown', captureKeyDown, true);
1024
+ ownerWindow.addEventListener('keyup', captureKeyUp, true);
1025
+ ownerWindow.addEventListener('keypress', captureKeyPress, true);
1026
+ ownerDocument.addEventListener('keydown', captureKeyDown, true);
1027
+ ownerDocument.addEventListener('keyup', captureKeyUp, true);
1028
+ ownerDocument.addEventListener('keypress', captureKeyPress, true);
1029
+ ownerDocument.addEventListener('pointerdown', capturePointerDown, true);
1030
+ ownerDocument.addEventListener('pointermove', capturePointerEvent, true);
1031
+ ownerDocument.addEventListener('pointerup', capturePointerEvent, true);
1032
+ ownerDocument.addEventListener('pointercancel', capturePointerEvent, true);
1033
+ ownerDocument.addEventListener('wheel', captureWheel, eventCaptureOptions);
1034
+ ownerDocument.addEventListener('pointerlockchange', handlePointerLockChange);
1035
+ ownerWindow.addEventListener('blur', releaseCapture);
1036
+ container.focus({ preventScroll: true });
1037
+ const resize = () => viewer.render();
1038
+ ownerWindow.addEventListener('resize', resize);
1039
+
1040
+ return {
1041
+ start() {
1042
+ if (running) {
1043
+ return;
1044
+ }
1045
+ try {
1046
+ refreshViewerSignals();
1047
+ if (typeof scene.onFrame === 'function') {
1048
+ scene.onFrame(viewer.api);
1049
+ }
1050
+ pointer.dx = 0;
1051
+ pointer.dy = 0;
1052
+ pointer.wheel = 0;
1053
+ viewer.render();
1054
+ running = true;
1055
+ lastTime = 0;
1056
+ raf = ownerWindow.requestAnimationFrame(tick);
1057
+ } catch (error) {
1058
+ reportRuntimeError(error);
1059
+ }
1060
+ },
1061
+ stop() {
1062
+ stopAnimation();
1063
+ },
1064
+ dispose() {
1065
+ this.stop();
1066
+ container.removeEventListener('keydown', keyDown);
1067
+ container.removeEventListener('keyup', keyUp);
1068
+ container.removeEventListener('pointerdown', focus);
1069
+ viewer.canvas.removeEventListener('pointerdown', focus);
1070
+ ownerWindow.removeEventListener('keydown', captureKeyDown, true);
1071
+ ownerWindow.removeEventListener('keyup', captureKeyUp, true);
1072
+ ownerWindow.removeEventListener('keypress', captureKeyPress, true);
1073
+ ownerDocument.removeEventListener('keydown', captureKeyDown, true);
1074
+ ownerDocument.removeEventListener('keyup', captureKeyUp, true);
1075
+ ownerDocument.removeEventListener('keypress', captureKeyPress, true);
1076
+ ownerDocument.removeEventListener('pointerdown', capturePointerDown, true);
1077
+ ownerDocument.removeEventListener('pointermove', capturePointerEvent, true);
1078
+ ownerDocument.removeEventListener('pointerup', capturePointerEvent, true);
1079
+ ownerDocument.removeEventListener('pointercancel', capturePointerEvent, true);
1080
+ ownerDocument.removeEventListener('wheel', captureWheel, eventCaptureOptions);
1081
+ ownerDocument.removeEventListener('pointerlockchange', handlePointerLockChange);
1082
+ ownerWindow.removeEventListener('blur', releaseCapture);
1083
+ ownerWindow.removeEventListener('resize', resize);
1084
+ releasePointerCapture();
1085
+ },
1086
+ reset() {
1087
+ input.resetLocals();
1088
+ input.releaseKeys();
1089
+ stepper.reset();
1090
+ frameNum = 0;
1091
+ accumulator = 0;
1092
+ lastTime = 0;
1093
+ refreshViewerSignals();
1094
+ viewer.render();
1095
+ onStatus('reset');
1096
+ },
1097
+ };
1098
+ }