@cognipilot/rumoca 0.9.5 → 0.9.6

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@cognipilot/rumoca",
3
3
  "type": "module",
4
4
  "description": "WebAssembly bindings for Rumoca compile and optional simulation surfaces",
5
- "version": "0.9.5",
5
+ "version": "0.9.6",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1835,7 +1835,7 @@ function __wbg_get_imports() {
1835
1835
  const ret = Reflect.get(arg0, arg1);
1836
1836
  return ret;
1837
1837
  }, arguments); },
1838
- __wbg_log_eb5eb4b225300419: function(arg0, arg1) {
1838
+ __wbg_log_de01f0de2d64abfc: function(arg0, arg1) {
1839
1839
  console.log(getStringFromWasm0(arg0, arg1));
1840
1840
  },
1841
1841
  __wbg_new_361308b2356cecd0: function() {
Binary file
Binary file
@@ -44,6 +44,104 @@ function clamp(value, limits) {
44
44
  : value;
45
45
  }
46
46
 
47
+ function normalizePacingMode(value) {
48
+ const text = trimMaybeString(value).toLowerCase().replace(/[-\s]+/g, '_');
49
+ return text === 'as_fast_as_possible' ? 'as_fast_as_possible' : 'realtime';
50
+ }
51
+
52
+ function pacingModeLabel(mode) {
53
+ return mode === 'as_fast_as_possible' ? 'fast' : 'realtime';
54
+ }
55
+
56
+ function speedRatioLabel(value) {
57
+ const ratio = finiteNumber(value, 0);
58
+ if (ratio >= 100) {
59
+ return `${ratio.toFixed(0)}x`;
60
+ }
61
+ if (ratio >= 10) {
62
+ return `${ratio.toFixed(1)}x`;
63
+ }
64
+ return `${ratio.toFixed(2)}x`;
65
+ }
66
+
67
+ function ensureInteractiveRuntimeStyles(ownerDocument) {
68
+ if (!ownerDocument || ownerDocument.getElementById('rumoca-interactive-runtime-styles')) {
69
+ return;
70
+ }
71
+ const style = ownerDocument.createElement('style');
72
+ style.id = 'rumoca-interactive-runtime-styles';
73
+ style.textContent = `
74
+ .rumoca-interactive-root {
75
+ position: relative;
76
+ overflow: hidden;
77
+ touch-action: none;
78
+ }
79
+ .rumoca-interactive-root:fullscreen {
80
+ width: 100vw;
81
+ height: 100vh;
82
+ background: #071825;
83
+ }
84
+ .rumoca-interactive-root:-webkit-full-screen {
85
+ width: 100vw;
86
+ height: 100vh;
87
+ background: #071825;
88
+ }
89
+ .rumoca-interactive-canvas {
90
+ display: block;
91
+ width: 100%;
92
+ height: 100%;
93
+ }
94
+ .rumoca-interactive-flight-hud {
95
+ position: absolute;
96
+ inset: 0;
97
+ z-index: 4;
98
+ pointer-events: none;
99
+ }
100
+ .rumoca-interactive-controls {
101
+ position: absolute;
102
+ left: 12px;
103
+ right: 12px;
104
+ bottom: 12px;
105
+ z-index: 5;
106
+ display: flex;
107
+ flex-wrap: wrap;
108
+ gap: 6px;
109
+ align-items: center;
110
+ pointer-events: auto;
111
+ }
112
+ .rumoca-interactive-controls button,
113
+ .rumoca-interactive-controls .rumoca-interactive-key-echo {
114
+ min-height: 28px;
115
+ padding: 4px 9px;
116
+ border: 1px solid rgba(255, 255, 255, 0.3);
117
+ border-radius: 6px;
118
+ background: rgba(10, 14, 18, 0.82);
119
+ color: #f4f7fb;
120
+ font: inherit;
121
+ font-size: 12px;
122
+ line-height: 18px;
123
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
124
+ }
125
+ .rumoca-interactive-controls button {
126
+ cursor: pointer;
127
+ }
128
+ .rumoca-interactive-controls.is-capturing .rumoca-interactive-capture-toggle,
129
+ .rumoca-interactive-controls .rumoca-interactive-pacing-toggle.is-fast,
130
+ .rumoca-interactive-controls .rumoca-interactive-fullscreen-toggle.is-fullscreen {
131
+ border-color: #37b7ff;
132
+ background: rgba(0, 103, 168, 0.88);
133
+ }
134
+ @media (max-width: 640px) {
135
+ .rumoca-interactive-controls button,
136
+ .rumoca-interactive-controls .rumoca-interactive-key-echo {
137
+ min-height: 42px;
138
+ font-size: 16px;
139
+ }
140
+ }
141
+ `;
142
+ (ownerDocument.head || ownerDocument.documentElement).appendChild(style);
143
+ }
144
+
47
145
  function localDefault(def) {
48
146
  if (!def || typeof def !== 'object') {
49
147
  return 0;
@@ -54,6 +152,36 @@ function localDefault(def) {
54
152
  return finiteNumber(def.default, 0);
55
153
  }
56
154
 
155
+ function inferredKeyboardDecayTargets(keyboardBindings) {
156
+ const targets = new Set();
157
+ for (const binding of Object.values(keyboardBindings || {})) {
158
+ if (trimMaybeString(binding?.action).toLowerCase() !== 'set') {
159
+ continue;
160
+ }
161
+ const target = trimMaybeString(binding.target);
162
+ if (target) {
163
+ targets.add(target);
164
+ }
165
+ }
166
+ return Array.from(targets).sort((a, b) => a.localeCompare(b));
167
+ }
168
+
169
+ function createKeyboardDecaySpec(decay, keyboardBindings) {
170
+ const raw = decay && typeof decay === 'object' ? decay : null;
171
+ const hasExplicitTargets = raw && Object.prototype.hasOwnProperty.call(raw, 'targets');
172
+ const targets = hasExplicitTargets
173
+ ? (Array.isArray(raw.targets) ? raw.targets.map(trimMaybeString).filter(Boolean) : [])
174
+ : inferredKeyboardDecayTargets(keyboardBindings);
175
+ if (targets.length === 0) {
176
+ return null;
177
+ }
178
+ return {
179
+ factor: finiteNumber(raw?.factor, 0.85),
180
+ ref_dt: raw?.ref_dt ?? raw?.refDt ?? 0.016,
181
+ targets,
182
+ };
183
+ }
184
+
57
185
  function sourceValue(source, locals, stepper, runtime) {
58
186
  const text = trimMaybeString(source);
59
187
  if (!text) {
@@ -137,7 +265,7 @@ function normalizedKeyboardKey(eventOrKey) {
137
265
  return typeof key === 'string' && key.length === 1 ? key.toLowerCase() : trimMaybeString(key);
138
266
  }
139
267
 
140
- function createInputRuntime(config) {
268
+ export function createInputRuntime(config) {
141
269
  const locals = new Map();
142
270
  const keyboardBindings = {};
143
271
  for (const [key, binding] of sortedEntries(config?.input?.keyboard?.keys)) {
@@ -152,8 +280,10 @@ function createInputRuntime(config) {
152
280
  const pressedKeys = new Set();
153
281
  const signals = new Set();
154
282
  const debounceUntil = new Map();
283
+ const pressedButtons = new Set();
155
284
  let connectedGamepad = null;
156
285
  let lastMode = 'keyboard';
286
+ const keyboardDecay = createKeyboardDecaySpec(config?.input?.keyboard?.decay, keyboardBindings);
157
287
 
158
288
  function resetLocals() {
159
289
  locals.clear();
@@ -197,14 +327,26 @@ function createInputRuntime(config) {
197
327
  }
198
328
  }
199
329
 
330
+ function applyHeldKeyboardAction(id, binding, nowMs) {
331
+ if (trimMaybeString(binding?.action).toLowerCase() === 'set') {
332
+ applyAction(id, binding, true, nowMs);
333
+ }
334
+ }
335
+
200
336
  function keyDown(event) {
201
337
  const key = normalizedKeyboardKey(event);
202
338
  const binding = keyboardBindings[key];
203
339
  if (!binding) {
204
340
  return false;
205
341
  }
342
+ const wasPressed = pressedKeys.has(key);
206
343
  pressedKeys.add(key);
207
- applyAction(`key:${key}`, binding, true, performance.now());
344
+ const id = `key:${key}`;
345
+ if (wasPressed) {
346
+ applyHeldKeyboardAction(id, binding, performance.now());
347
+ } else {
348
+ applyAction(id, binding, true, performance.now());
349
+ }
208
350
  event.preventDefault();
209
351
  return true;
210
352
  }
@@ -239,9 +381,10 @@ function createInputRuntime(config) {
239
381
  lastMode = 'keyboard';
240
382
  }
241
383
  const nowMs = performance.now();
384
+ applyKeyboardDecay(keyboardDecay, dt);
242
385
  for (const [key, binding] of sortedEntries(keyboardBindings)) {
243
386
  if (pressedKeys.has(key)) {
244
- applyAction(`key:${key}`, binding, true, nowMs);
387
+ applyHeldKeyboardAction(`key:${key}`, binding, nowMs);
245
388
  }
246
389
  }
247
390
  applyIntegrators(input.keyboard?.integrators, dt, null);
@@ -252,6 +395,31 @@ function createInputRuntime(config) {
252
395
  }
253
396
  }
254
397
 
398
+ function applyKeyboardDecay(decay, dt) {
399
+ if (!decay || typeof decay !== 'object') {
400
+ return;
401
+ }
402
+ const targets = Array.isArray(decay.targets)
403
+ ? decay.targets.map(trimMaybeString).filter(Boolean)
404
+ : [];
405
+ if (targets.length === 0) {
406
+ return;
407
+ }
408
+ const elapsed = finiteNumber(dt, 0);
409
+ if (elapsed <= 0) {
410
+ return;
411
+ }
412
+ const factor = clamp(finiteNumber(decay.factor, 1), [0, 1]);
413
+ const refDt = Math.max(finiteNumber(decay.ref_dt ?? decay.refDt, 0.016), Number.EPSILON);
414
+ const scale = Math.pow(factor, elapsed / refDt);
415
+ for (const target of targets) {
416
+ const current = locals.get(target);
417
+ if (typeof current === 'number' && Number.isFinite(current)) {
418
+ locals.set(target, current * scale);
419
+ }
420
+ }
421
+ }
422
+
255
423
  function applyIntegrators(integrators, dt, gamepad) {
256
424
  for (const [name, spec] of sortedEntries(integrators)) {
257
425
  const raw = gamepad ? sourceGamepadAxis(spec.source || name, gamepad) : sourceValue(spec.source, locals, { get: () => 0, time: () => 0 }, {});
@@ -274,7 +442,19 @@ function createInputRuntime(config) {
274
442
 
275
443
  function applyGamepadButtons(buttons, gamepad, nowMs) {
276
444
  for (const [name, spec] of sortedEntries(buttons)) {
277
- applyAction(`button:${name}`, spec, sourceGamepadButton(spec.source || name, gamepad), nowMs);
445
+ const id = `button:${name}`;
446
+ const active = sourceGamepadButton(spec.source || name, gamepad);
447
+ const action = trimMaybeString(spec?.action).toLowerCase();
448
+ if (action === 'set') {
449
+ applyAction(id, spec, active, nowMs);
450
+ } else if (active && !pressedButtons.has(id)) {
451
+ applyAction(id, spec, true, nowMs);
452
+ }
453
+ if (active) {
454
+ pressedButtons.add(id);
455
+ } else {
456
+ pressedButtons.delete(id);
457
+ }
278
458
  }
279
459
  }
280
460
 
@@ -294,6 +474,7 @@ function createInputRuntime(config) {
294
474
  },
295
475
  releaseKeys() {
296
476
  pressedKeys.clear();
477
+ pressedButtons.clear();
297
478
  },
298
479
  resetLocals,
299
480
  takeSignal,
@@ -329,7 +510,7 @@ function createViewerRuntime({ THREE, container, viewerSignals, assetBaseUrl, po
329
510
  const cam = {
330
511
  target: new THREE.Vector3(0, 0, 0),
331
512
  dist: 7,
332
- angle: Math.PI,
513
+ angle: -Math.PI / 2,
333
514
  elev: 0.22,
334
515
  };
335
516
  const viewerConfig = config?.viewer || {};
@@ -742,12 +923,16 @@ export async function createInteractiveSimulation(options) {
742
923
  onStatus('ready');
743
924
 
744
925
  const simDt = Math.max(0.001, finiteNumber(config?.sim?.dt, 0.01));
926
+ let pacingMode = normalizePacingMode(config?.sim?.mode);
745
927
  let frameNum = 0;
746
928
  let running = false;
747
929
  let raf = null;
748
930
  let lastTime = 0;
749
931
  let accumulator = 0;
932
+ let speedRatio = 0;
750
933
  let updateCaptureUi = () => {};
934
+ let updatePacingUi = () => {};
935
+ let updateFullscreenUi = () => {};
751
936
 
752
937
  function refreshViewerSignals() {
753
938
  viewerSignals.clear();
@@ -777,15 +962,64 @@ export async function createInteractiveSimulation(options) {
777
962
  onError(error);
778
963
  }
779
964
 
965
+ function statusLine() {
966
+ const inputMode = input.runtimeFields(frameNum, stepper.time()).input_mode;
967
+ return `live t=${stepper.time().toFixed(2)} s · ${pacingModeLabel(pacingMode)} · ${speedRatioLabel(speedRatio)} · ${inputMode}`;
968
+ }
969
+
970
+ function recordSpeed(simAdvanced, wallDt) {
971
+ if (wallDt <= 0) {
972
+ return;
973
+ }
974
+ const instant = Math.max(0, simAdvanced) / wallDt;
975
+ speedRatio = speedRatio === 0 ? instant : (speedRatio * 0.82 + instant * 0.18);
976
+ }
977
+
978
+ function resetSimulation(options = {}) {
979
+ const {
980
+ resetLocals = true,
981
+ resetStepper = true,
982
+ render = true,
983
+ statusText = 'reset',
984
+ } = options;
985
+ if (resetLocals) {
986
+ input.resetLocals();
987
+ }
988
+ input.releaseKeys();
989
+ if (resetStepper) {
990
+ stepper.reset();
991
+ }
992
+ frameNum = 0;
993
+ accumulator = 0;
994
+ lastTime = 0;
995
+ speedRatio = 0;
996
+ refreshViewerSignals();
997
+ if (render) {
998
+ viewer.render();
999
+ }
1000
+ updatePacingUi();
1001
+ onStatus(statusText);
1002
+ }
1003
+
1004
+ function togglePacingMode() {
1005
+ pacingMode = pacingMode === 'realtime' ? 'as_fast_as_possible' : 'realtime';
1006
+ accumulator = 0;
1007
+ lastTime = 0;
1008
+ speedRatio = 0;
1009
+ updatePacingUi();
1010
+ onStatus(statusLine());
1011
+ return pacingMode;
1012
+ }
1013
+
780
1014
  function routeFrame(dt) {
781
1015
  input.update(dt);
782
1016
  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
- }
1017
+ resetSimulation({
1018
+ resetLocals: Boolean(config.reset.reset_locals),
1019
+ resetStepper: Boolean(config.reset.rebuild_stepper),
1020
+ render: false,
1021
+ statusText: 'reset',
1022
+ });
789
1023
  }
790
1024
  if (input.takeSignal(trimMaybeString(config?.quit?.on_signal) || 'quit')) {
791
1025
  stopAnimation();
@@ -817,16 +1051,31 @@ export async function createInteractiveSimulation(options) {
817
1051
  if (lastTime === 0) {
818
1052
  lastTime = now;
819
1053
  }
820
- accumulator += Math.min(0.08, Math.max(0, (now - lastTime) / 1000));
1054
+ const wallDt = Math.min(0.08, Math.max(0, (now - lastTime) / 1000));
821
1055
  lastTime = now;
1056
+ let simAdvanced = 0;
822
1057
  let steps = 0;
823
- while (accumulator >= simDt && steps < 8) {
824
- if (!routeFrame(simDt)) {
825
- return;
1058
+ if (pacingMode === 'as_fast_as_possible') {
1059
+ const started = performance.now();
1060
+ do {
1061
+ if (!routeFrame(simDt)) {
1062
+ return;
1063
+ }
1064
+ simAdvanced += simDt;
1065
+ steps += 1;
1066
+ } while (steps < 250 && performance.now() - started < 12);
1067
+ } else {
1068
+ accumulator += wallDt;
1069
+ while (accumulator >= simDt && steps < 8) {
1070
+ if (!routeFrame(simDt)) {
1071
+ return;
1072
+ }
1073
+ accumulator -= simDt;
1074
+ simAdvanced += simDt;
1075
+ steps += 1;
826
1076
  }
827
- accumulator -= simDt;
828
- steps += 1;
829
1077
  }
1078
+ recordSpeed(simAdvanced, wallDt);
830
1079
  if (typeof scene.onFrame === 'function') {
831
1080
  scene.onFrame(viewer.api);
832
1081
  }
@@ -834,7 +1083,8 @@ export async function createInteractiveSimulation(options) {
834
1083
  pointer.dy = 0;
835
1084
  pointer.wheel = 0;
836
1085
  viewer.render();
837
- onStatus(`live t=${stepper.time().toFixed(2)} s · ${input.runtimeFields(frameNum).input_mode}`);
1086
+ updatePacingUi();
1087
+ onStatus(statusLine());
838
1088
  raf = ownerWindow.requestAnimationFrame(tick);
839
1089
  }
840
1090
 
@@ -844,7 +1094,15 @@ export async function createInteractiveSimulation(options) {
844
1094
  const eventCaptureOptions = { capture: true, passive: false };
845
1095
  const ownerDocument = viewer.ownerDocument;
846
1096
  const ownerWindow = viewer.ownerWindow;
847
- const keyDown = (event) => input.keyDown(event);
1097
+ container.classList.add('rumoca-interactive-root');
1098
+ ensureInteractiveRuntimeStyles(ownerDocument);
1099
+ const keyDown = (event) => {
1100
+ if (!event.repeat && handleViewerKeyDown(event)) {
1101
+ event.preventDefault();
1102
+ return true;
1103
+ }
1104
+ return input.keyDown(event);
1105
+ };
848
1106
  const keyUp = (event) => input.keyUp(event);
849
1107
  const updatePointerFromEvent = (event) => {
850
1108
  const rect = viewer.canvas.getBoundingClientRect();
@@ -875,6 +1133,37 @@ export async function createInteractiveSimulation(options) {
875
1133
  pointerLockExitReleasesCapture = true;
876
1134
  });
877
1135
  };
1136
+ const fullscreenElement = () => ownerDocument.fullscreenElement || ownerDocument.webkitFullscreenElement || null;
1137
+ const isFullscreenActive = () => {
1138
+ const activeElement = fullscreenElement();
1139
+ return activeElement === container || container.contains(activeElement);
1140
+ };
1141
+ const setFullscreenActive = async (active) => {
1142
+ try {
1143
+ if (active) {
1144
+ if (!isFullscreenActive()) {
1145
+ const request = container.requestFullscreen || container.webkitRequestFullscreen;
1146
+ if (typeof request === 'function') {
1147
+ await Promise.resolve(request.call(container));
1148
+ }
1149
+ }
1150
+ } else if (isFullscreenActive()) {
1151
+ const exit = ownerDocument.exitFullscreen || ownerDocument.webkitExitFullscreen;
1152
+ if (typeof exit === 'function') {
1153
+ await Promise.resolve(exit.call(ownerDocument));
1154
+ }
1155
+ }
1156
+ } finally {
1157
+ updateFullscreenUi();
1158
+ focus();
1159
+ viewer.render();
1160
+ }
1161
+ };
1162
+ const toggleFullscreen = () => {
1163
+ setFullscreenActive(!isFullscreenActive()).catch((error) => {
1164
+ onStatus(`fullscreen unavailable: ${error?.message || error}`);
1165
+ });
1166
+ };
878
1167
  const setCaptureActive = (active, options = {}) => {
879
1168
  inputCaptureActive = Boolean(active);
880
1169
  if (!inputCaptureActive) {
@@ -895,12 +1184,23 @@ export async function createInteractiveSimulation(options) {
895
1184
  const key = normalizedKeyboardKey(event);
896
1185
  if (key === 'c') {
897
1186
  lastCapturedKey = `camera ${viewer.cycleCamera()}`;
898
- updateCaptureUi(true);
1187
+ updateCaptureUi(inputCaptureActive);
899
1188
  return true;
900
1189
  }
901
1190
  if (key === 'h') {
902
1191
  lastCapturedKey = `hud ${viewer.toggleHud() ? 'on' : 'off'}`;
903
- updateCaptureUi(true);
1192
+ updateCaptureUi(inputCaptureActive);
1193
+ return true;
1194
+ }
1195
+ if (key === 't') {
1196
+ lastCapturedKey = `time ${pacingModeLabel(togglePacingMode())}`;
1197
+ updateCaptureUi(inputCaptureActive);
1198
+ return true;
1199
+ }
1200
+ if (key === 'f') {
1201
+ lastCapturedKey = 'fullscreen';
1202
+ toggleFullscreen();
1203
+ updateCaptureUi(inputCaptureActive);
904
1204
  return true;
905
1205
  }
906
1206
  return false;
@@ -998,6 +1298,16 @@ export async function createInteractiveSimulation(options) {
998
1298
  captureToggle.type = 'button';
999
1299
  captureToggle.className = 'rumoca-interactive-capture-toggle';
1000
1300
  captureToggle.title = 'Capture keyboard and mouse input. Press Escape to release capture.';
1301
+ const pacingToggle = ownerDocument.createElement('button');
1302
+ pacingToggle.type = 'button';
1303
+ pacingToggle.className = 'rumoca-interactive-pacing-toggle';
1304
+ pacingToggle.title = 'Toggle simulation pacing. Shortcut: T.';
1305
+ const speedReadout = ownerDocument.createElement('span');
1306
+ speedReadout.className = 'rumoca-interactive-key-echo rumoca-interactive-speed-readout';
1307
+ const fullscreenToggle = ownerDocument.createElement('button');
1308
+ fullscreenToggle.type = 'button';
1309
+ fullscreenToggle.className = 'rumoca-interactive-fullscreen-toggle';
1310
+ fullscreenToggle.title = 'Toggle fullscreen. Shortcut: F.';
1001
1311
  updateCaptureUi = (active) => {
1002
1312
  captureToggle.textContent = active
1003
1313
  ? `Release: Esc${lastCapturedKey ? ` · ${keyDisplayName(lastCapturedKey)}` : ''}${pointer.captured ? ' · mouse' : ''}`
@@ -1005,14 +1315,43 @@ export async function createInteractiveSimulation(options) {
1005
1315
  captureToggle.setAttribute('aria-pressed', active ? 'true' : 'false');
1006
1316
  controls.classList.toggle('is-capturing', active);
1007
1317
  };
1318
+ updatePacingUi = () => {
1319
+ const label = pacingModeLabel(pacingMode);
1320
+ pacingToggle.textContent = label === 'fast' ? 'Fast' : 'Realtime';
1321
+ pacingToggle.setAttribute('aria-pressed', pacingMode === 'as_fast_as_possible' ? 'true' : 'false');
1322
+ pacingToggle.classList.toggle('is-fast', pacingMode === 'as_fast_as_possible');
1323
+ speedReadout.textContent = `Speed ${speedRatioLabel(speedRatio)}`;
1324
+ };
1325
+ updateFullscreenUi = () => {
1326
+ const active = isFullscreenActive();
1327
+ fullscreenToggle.textContent = active ? 'Exit Fullscreen' : 'Fullscreen';
1328
+ fullscreenToggle.setAttribute('aria-pressed', active ? 'true' : 'false');
1329
+ fullscreenToggle.classList.toggle('is-fullscreen', active);
1330
+ };
1008
1331
  captureToggle.addEventListener('pointerdown', (event) => {
1009
1332
  event.preventDefault();
1010
1333
  event.stopPropagation();
1011
1334
  setCaptureActive(!inputCaptureActive, { requestPointerLock: true });
1012
1335
  container.focus({ preventScroll: true });
1013
1336
  });
1337
+ pacingToggle.addEventListener('pointerdown', (event) => {
1338
+ event.preventDefault();
1339
+ event.stopPropagation();
1340
+ togglePacingMode();
1341
+ container.focus({ preventScroll: true });
1342
+ });
1343
+ fullscreenToggle.addEventListener('pointerdown', (event) => {
1344
+ event.preventDefault();
1345
+ event.stopPropagation();
1346
+ toggleFullscreen();
1347
+ });
1014
1348
  controls.appendChild(captureToggle);
1349
+ controls.appendChild(pacingToggle);
1350
+ controls.appendChild(speedReadout);
1351
+ controls.appendChild(fullscreenToggle);
1015
1352
  updateCaptureUi(false);
1353
+ updatePacingUi();
1354
+ updateFullscreenUi();
1016
1355
  container.appendChild(controls);
1017
1356
  container.tabIndex = 0;
1018
1357
  viewer.canvas.tabIndex = -1;
@@ -1032,6 +1371,8 @@ export async function createInteractiveSimulation(options) {
1032
1371
  ownerDocument.addEventListener('pointercancel', capturePointerEvent, true);
1033
1372
  ownerDocument.addEventListener('wheel', captureWheel, eventCaptureOptions);
1034
1373
  ownerDocument.addEventListener('pointerlockchange', handlePointerLockChange);
1374
+ ownerDocument.addEventListener('fullscreenchange', updateFullscreenUi);
1375
+ ownerDocument.addEventListener('webkitfullscreenchange', updateFullscreenUi);
1035
1376
  ownerWindow.addEventListener('blur', releaseCapture);
1036
1377
  container.focus({ preventScroll: true });
1037
1378
  const resize = () => viewer.render();
@@ -1079,20 +1420,14 @@ export async function createInteractiveSimulation(options) {
1079
1420
  ownerDocument.removeEventListener('pointercancel', capturePointerEvent, true);
1080
1421
  ownerDocument.removeEventListener('wheel', captureWheel, eventCaptureOptions);
1081
1422
  ownerDocument.removeEventListener('pointerlockchange', handlePointerLockChange);
1423
+ ownerDocument.removeEventListener('fullscreenchange', updateFullscreenUi);
1424
+ ownerDocument.removeEventListener('webkitfullscreenchange', updateFullscreenUi);
1082
1425
  ownerWindow.removeEventListener('blur', releaseCapture);
1083
1426
  ownerWindow.removeEventListener('resize', resize);
1084
1427
  releasePointerCapture();
1085
1428
  },
1086
1429
  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');
1430
+ resetSimulation({ resetLocals: true, resetStepper: true });
1096
1431
  },
1097
1432
  };
1098
1433
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "packageBuiltTimeUtc": "2026-06-19T05:29:25Z"
2
+ "packageBuiltTimeUtc": "2026-06-20T01:13:46Z"
3
3
  }