+{/if}
+
+
diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte
index 67258067..0426788e 100644
--- a/src/components/menu/Toasts.svelte
+++ b/src/components/menu/Toasts.svelte
@@ -19,6 +19,8 @@
import { armExplorerSceneSave, explorerClose } from '../../stores/appStore'
import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, connectDrawerTab, toastsInDrawerOnly, showInfoToast, dismissToastById } from '../../stores/appStore'
import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave'
+ import { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte'
+ import { ingestVerdict, profileFor } from '$lib/sceneBudget'
import { cancelOutboundRequest } from '$lib/peerApproval'
// 27-B: the ONE sticky card for an uncaught error. This file already mirrors
// state stores into sticky toasts (restoreAvailable below); diagnostics.js stays a
@@ -147,18 +149,21 @@ const transientToasts = $derived($toastStore.filter((t: any) => !t?.sticky));
const hiddenCount = $derived(Math.max(0, transientToasts.length - MAX_TOASTS));
const visibleToasts = $derived([...transientToasts.slice(-MAX_TOASTS), ...stickyToasts]);
+// 26-B (audit M6): ONE traversal, then set lookups. This ran
+// `getObjectByProperty` — a full tree walk — TWICE per outstanding uuid, on every
+// scene poke: with 1,000 objects still to arrive over a 1,000-object scene that is
+// two million node visits per poke, and the receive path poked once per object. It
+// was the single most expensive consumer of the poke and a large part of the
+// reported freeze. Same verdict, O(objects + outstanding) instead of O(both).
$effect(() => {
- if($loading.length > 0)
- if($objectsGroup)
- // Remove loaded UUIDs from the loading array
- // once their corresponding objects are available
- $loading.forEach((uuid) => {
- $objectsGroup.getObjectByProperty('uuid', uuid)
- if ($objectsGroup.getObjectByProperty('uuid', uuid)) {
- $loading.splice($loading.indexOf(uuid, 0), 1);
- $loading = $loading // Trigger reactivity
- }
- })
+ const group = $objectsGroup;
+ const outstanding = $loading;
+ if (!group || !outstanding.length) return;
+ /** @type {Set} */
+ const present = new Set();
+ group.traverse((/** @type {any} */ o) => present.add(o.uuid));
+ const left = outstanding.filter((/** @type {string} */ uuid) => !present.has(uuid));
+ if (left.length !== outstanding.length) loading.set(left);
});
// 15-P2: "Receiving objects" visibility. The old machinery (showToast +
@@ -257,12 +262,46 @@ $effect(() => {
else dismissToastById('diagnostics-error');
});
+/** 26-G: the restore prompt's budget line reads the same verdict the ingest gate does. */
+function restoreLimit() {
+ return ingestVerdict(0, 1, profileFor(null)).limit;
+}
+function restoreOverBudget(objects: number) {
+ return ingestVerdict(0, Number(objects) || 0, profileFor(null)).gate;
+}
+
+// 26-C (roadmap 26 Stage 2): A SCENE BIGGER THAN THIS DEVICE'S BUDGET IS ARRIVING.
+// The objects are PARKED in the ingest queue, not applied, so this card is the only
+// thing between them and the scene — hence `noClose`: dismissing it with an X would
+// leave the transfer stalled with nothing left to resume it. The state store is the
+// seam (the restoreAvailable idiom), so commandsHandler never imports the UI.
+$effect(() => {
+ const gate = $ingestGate;
+ if (gate)
+ showInfoToast(
+ 'ingest-gate',
+ `This scene has ${gate.count} objects — that would take this device to ${gate.total}, above the ${gate.limit} recommended here.`,
+ [
+ { label: 'Load all', action: () => resolveIngestGate('all') },
+ { label: `Load the first ${gate.allowed}`, action: () => resolveIngestGate('some') },
+ { label: 'Cancel', action: () => resolveIngestGate('cancel') }
+ ],
+ undefined,
+ true
+ );
+ else dismissToastById('ingest-gate');
+});
+
$effect(() => {
const snap = $restoreAvailable;
if (snap)
showInfoToast(
'restore-session',
`Restore previous session? ${snap.objects} objects, saved ${new Date(snap.ts).toLocaleTimeString()}` +
+ // 26-G (roadmap 26 Stage 4, last bullet): say how the snapshot compares with
+ // this device's budget BEFORE restoring it. A phone that died restoring a
+ // 50MB scene comes back to this exact prompt, and the count is the reason.
+ (restoreOverBudget(snap.objects) ? ` — above the ${restoreLimit()} recommended for this device.` : '') +
// 27-D: `risky` means the last attempt to restore THIS snapshot never
// reached a clean flow tick. Auto-restore is already skipped for it; say
// why, so pressing Restore again is a choice rather than a surprise.
diff --git a/src/lib/ai/tools.js b/src/lib/ai/tools.js
index 3f5fc5ac..5178503d 100644
--- a/src/lib/ai/tools.js
+++ b/src/lib/ai/tools.js
@@ -1,5 +1,5 @@
import { get } from 'svelte/store';
-import { objectsGroup, lockedObjects } from '../../stores/sceneStore.js';
+import { objectsGroup, lockedObjects, pokeScene } from '../../stores/sceneStore.js';
import { peers } from '../../stores/appStore.js';
import { createGeometry, createLight, createGroup } from '$lib/geometries.svelte.js';
import { recordObjectPresence, recordTransform } from '$lib/history';
@@ -245,7 +245,7 @@ function applyAiTransform(object, t) {
scale: object.scale.toArray()
};
notifyExternalMove(object.uuid);
- objectsGroup.update((v) => v);
+ pokeScene();
broadcast({ type: 'move', uuid: object.uuid, pos: after.pos, rot: after.rot, scale: after.scale });
recordTransform({ uuid: object.uuid, before, after });
}
diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js
index f7b4114b..31bb6077 100644
--- a/src/lib/animatedImports.js
+++ b/src/lib/animatedImports.js
@@ -4,7 +4,7 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
import { runtimeNow } from './moduleSDK';
@@ -178,7 +178,7 @@ export async function applyObjectFile(data) {
if (data.pos) held.position.fromArray(data.pos);
if (data.rot) held.rotation.set(data.rot[0], data.rot[1], data.rot[2]);
if (data.scale) held.scale.fromArray(data.scale);
- objectsGroup.update((value) => value);
+ pokeScene();
if (data.anim) setAnimationState(data.uuid, data.anim, false);
return;
}
@@ -192,7 +192,7 @@ export async function applyObjectFile(data) {
if (data.rot) root.rotation.set(data.rot[0], data.rot[1], data.rot[2]);
if (data.scale) root.scale.fromArray(data.scale);
group.add(root);
- objectsGroup.update((value) => value);
+ pokeScene();
registerAnimatedImport(root, animations, bytes, data.kind === 'fbx' ? 'fbx' : 'gltf');
if (data.anim) setAnimationState(data.uuid, data.anim, false);
} catch (error) {
@@ -329,7 +329,7 @@ export async function animatedImportsRestore(entries, replicate = true) {
console.log('animated import restore failed', error);
}
}
- if (restored) objectsGroup.update((value) => value);
+ if (restored) pokeScene();
return restored;
}
@@ -383,7 +383,7 @@ registerHistoryKind('animimport', (entry, state) => {
return false;
}
existing.parent?.remove(existing);
- objectsGroup.update((value) => value);
+ pokeScene();
if (peer) peer.send({ type: 'delete', uuid: entry.uuid, peerId: peer.peer.id });
return true;
});
diff --git a/src/lib/audioDevices.js b/src/lib/audioDevices.js
index ee9cdb13..b61d56e0 100644
--- a/src/lib/audioDevices.js
+++ b/src/lib/audioDevices.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
// The device write path rides the existing 'props' history kind (objectActions.js
// owns the replay), so this module needs history only to RECORD — a static edge
@@ -322,7 +322,7 @@ export function setDeviceFor(uuid, patch, opts = {}) {
// a knob wants its sound NOW, not after the debounce — apply locally at once; the
// reconcile below is the backstop for everything that did not come through here
applyParams(object);
- objectsGroup.update((value) => value);
+ pokeScene();
return next ? structuredClone(next) : null;
}
@@ -346,7 +346,7 @@ export function previewDeviceParams(uuid, params, opts = {}) {
const peer = get(peers);
if (peer) peer.send({ type: 'objectParameters', parameter: 'device', uuid, device: next });
}
- if (opts.poke !== false) objectsGroup.update((value) => value);
+ if (opts.poke !== false) pokeScene();
return structuredClone(next);
}
@@ -364,7 +364,7 @@ export function applyRemoteDevice(data) {
if (data.device && typeof data.device === 'object') object.userData.device = normalizeDevice(data.device);
else delete object.userData.device;
applyParams(object);
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
}
@@ -404,7 +404,7 @@ export function addDevice(kind, opts = {}) {
// this the wire copy carries an identity matrix and every peer places the device at
// the origin (found by C1's flight: A held the speaker at [-10,10,14], B at [0,0,0])
object.updateMatrix();
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', object);
/** @type {any} */
const peer = get(peers);
diff --git a/src/lib/autosave.js b/src/lib/autosave.js
index d750552e..2b6f4848 100644
--- a/src/lib/autosave.js
+++ b/src/lib/autosave.js
@@ -3,7 +3,7 @@ import * as THREE from 'three';
import { get, writable } from 'svelte/store';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-import { objectsGroup, globalCamera, orbitControls } from '../stores/sceneStore';
+import { objectsGroup, globalCamera, orbitControls, pokeScene } from '../stores/sceneStore';
import { flowGraphs, restoreGraphs, SCENE_GRAPH } from '../stores/flowStore';
import { serializeGraphs } from './flowGraphs';
import { serializeNode, serializeEdge } from './nodesHandler';
@@ -563,7 +563,7 @@ function restoreMultiMaterial(entries) {
// for symmetry alone would be a worse trade than saying so here.
disposeTree(twin, { keep: keepSet(get(objectsGroup), twin) });
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -611,7 +611,7 @@ async function applyRestore(snapshot) {
group.add(child);
if (peer) peer.send({ type: 'object', element: child.toJSON() });
});
- objectsGroup.update((value) => value);
+ pokeScene();
}
// multi-material meshes come back from their toJSON, REPLACING the Group of
// single-material children the GLTF export left behind (same twin-replacement
diff --git a/src/lib/cameraObjects.js b/src/lib/cameraObjects.js
index 989f478b..4aec1ad3 100644
--- a/src/lib/cameraObjects.js
+++ b/src/lib/cameraObjects.js
@@ -1,6 +1,6 @@
import { get } from 'svelte/store';
import * as THREE from 'three';
-import { objectsGroup, globalCamera, orbitControls, globalRenderer, globalScene } from '../stores/sceneStore';
+import { objectsGroup, globalCamera, orbitControls, globalRenderer, globalScene, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry } from './history';
import { flyTo } from './objectActions';
@@ -85,7 +85,7 @@ export function setCameraFor(uuid, patch) {
const peer = get(peers);
if (peer) peer.send({ type: 'objectParameters', parameter: 'camera', uuid, camera: next });
// THREE trees are not reactive — poke so the list/viz/preview see it
- objectsGroup.update((value) => value);
+ pokeScene();
return next;
}
@@ -94,7 +94,7 @@ export function applyRemoteCamera(data) {
const object = get(objectsGroup)?.getObjectByProperty('uuid', data.uuid);
if (!object) return;
object.userData.camera = { ...DEFAULT_CAMERA, ...(data.camera ?? {}) };
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** Build (or update) a real THREE camera from a marker. Used by preview + Capture.
@@ -170,7 +170,7 @@ export function setCameraFromView(uuid) {
});
if (typeof view.fov === 'number' && cameraSpec(object).kind === 'perspective')
setCameraFor(uuid, { fov: Math.round(view.fov) });
- else objectsGroup.update((value) => value);
+ else pokeScene();
}
/**
diff --git a/src/lib/cameraPreview.js b/src/lib/cameraPreview.js
index dff95e65..9c5a976b 100644
--- a/src/lib/cameraPreview.js
+++ b/src/lib/cameraPreview.js
@@ -1,6 +1,6 @@
import { writable, derived, get } from 'svelte/store';
import * as THREE from 'three';
-import { objectsGroup, orbitControls } from '../stores/sceneStore';
+import { objectsGroup, orbitControls, pokeScene } from '../stores/sceneStore';
import { peers, showToast, specatorMode } from '../stores/appStore';
import { recordTransformSet } from './history';
import { findCameraObject, cameraSpec } from './cameraObjects';
@@ -129,7 +129,7 @@ function setMarkerHidden(object, hide) {
object.visible = markerWasVisible;
markerWasVisible = null;
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** Broadcast our preview state so peers can see (and join) it. @param {string|null} uuid */
diff --git a/src/lib/colliderEdit.js b/src/lib/colliderEdit.js
index bc83dd77..7b1dcd03 100644
--- a/src/lib/colliderEdit.js
+++ b/src/lib/colliderEdit.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, selectedObject, objectsGroup } from '../stores/sceneStore';
+import { globalScene, selectedObject, objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry } from './history';
import {
@@ -245,7 +245,7 @@ export function commitColliderEdit() {
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'objectParameters', parameter: 'physics', uuid, physics: next });
- objectsGroup.update((v) => v);
+ pokeScene();
selectedObject.update((v) => v);
import('./physics').then((m) => m.physicsShapeChanged(uuid)); // live rebuild mid-sim
showToast('Custom collider saved — ' + colliderPieces.length + ' convex piece' + (colliderPieces.length === 1 ? '' : 's'));
diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js
index 10e64de1..a4d8a51c 100644
--- a/src/lib/commandsHandler.svelte.js
+++ b/src/lib/commandsHandler.svelte.js
@@ -1,5 +1,5 @@
import * as THREE from 'three';
-import { globalScene, objectsGroup, showGrid, TControls, lockedObjects, selectedObject, globalCamera, peerHands } from '../stores/sceneStore.js';
+import { globalScene, objectsGroup, showGrid, TControls, lockedObjects, selectedObject, globalCamera, peerHands, pokeScene, beginSceneBatch, endSceneBatch } from '../stores/sceneStore.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createGeometry, createLight, createGroup } from '$lib/geometries.svelte'
@@ -20,7 +20,7 @@ import { stripEditOverlays } from '$lib/editOverlays'
import { runSceneClearHandlers } from '$lib/moduleSDK'
import { annotations } from '$lib/annotationsHandler'
import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions'
-import { get } from 'svelte/store'
+import { get, writable } from 'svelte/store'
import { addMessage, loading, loadingcount, showToast, fixLight, specatorMode } from '../stores/appStore';
import { dropWireErrors } from './wireErrors';
import { peers, userdata } from '../stores/appStore';
@@ -28,6 +28,10 @@ import { peers, userdata } from '../stores/appStore';
// the departing object was using, and never what the rest of the scene still holds.
import { disposeTree, keepSet } from '$lib/disposeTree';
import { safeStorage } from './safeStorage';
+// 26-A: the backlog is a reading the Statistics panel wants and sceneBudget cannot
+// reach — it REGISTERS rather than importing us, the registerDiagnosticsSection shape.
+import { registerMetricSource, ingestVerdict, profileFor } from './sceneBudget';
+import { globalRenderer } from '../stores/sceneStore.js';
//Access scene Store
let scene = $state();
@@ -67,7 +71,6 @@ globalCamera.subscribe(value => { camera = value });
const loader = new THREE.ObjectLoader();
-let uuids = [];
export function userData(data) {
// 27-A (audit H1): the roster applier called .forEach on whatever arrived. A malformed
@@ -246,7 +249,7 @@ export function sceneCommand(command) {
}
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -262,6 +265,9 @@ function sceneRoot() {
export function clearSceneLocal() {
controls?.detach();
+ // 26-B: anything still parked in the ingest queue belongs to the scene being wiped
+ dropIngestQueue();
+ clearLoadingBatch();
// 27-G: `clear()` drops the references and frees nothing, so a session that opens and
// clears several scenes pays for every one of them until the context dies.
const doomed = sceneObjects ? [...sceneObjects.children] : [];
@@ -278,7 +284,7 @@ export function clearSceneLocal() {
// authored clips were the one registry a wipe used to leak (dropAnimation had
// no call site at all before 17-E)
dropAllAnimations();
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** A peer wiped the shared scene @param {string} peerId */
@@ -326,6 +332,14 @@ export function handleDisconnected(peerId) {
dropPeerJoined(peerId);
voicePeerDisconnected(peerId);
physicsPeerDisconnected(peerId);
+ // 26-B (audit M2): the objects they were sending are never coming. Clearing the
+ // batch here is what stops "Receiving objects: 3/40" living forever on screen, and
+ // it drops the parked queue so a half-sent scene does not trickle in afterwards.
+ if (loadingSender === peerId) {
+ const left = /** @type {string[]} */ (get(loading)).length;
+ clearLoadingBatch();
+ if (left) showToast('The scene transfer stopped — ' + peerId + ' left.');
+ }
}
// Local age-out for roster entries that never grew a connection (a peer that
@@ -393,14 +407,71 @@ export function checkLocks(data) {
if (locked.length !== before) lockedObjects.set(locked);
}
-export async function createLoader(count, uuids) {
+/** 26-B (audit M2): who announced the batch we are receiving, so their teardown can
+ * clear it. LOCAL — nothing about this crosses the wire. */
+/** @type {string | null} */
+let loadingSender = null;
+/** @type {any} */
+let loadingStallTimer = null;
+/** The progress bar sticks at "3/40" forever when the sender leaves mid-send or a parse
+ * rejects. Nothing cleared it: the only writer was the Toasts effect, which removes a
+ * uuid when its object APPEARS, and an object that never arrives never appears. */
+const LOADING_STALL_MS = 60000;
+
+function armLoadingStall() {
+ clearTimeout(loadingStallTimer);
+ loadingStallTimer = setTimeout(() => {
+ const left = /** @type {string[]} */ (get(loading));
+ if (!left.length) return;
+ console.log('Receiving objects: giving up on ' + left.length + ' that never arrived');
+ clearLoadingBatch();
+ showToast(left.length + ' object' + (left.length === 1 ? '' : 's') + ' never arrived.');
+ }, LOADING_STALL_MS);
+}
+
+/** Close the batch: the bar goes away, the stall timer disarms. Idempotent. */
+export function clearLoadingBatch() {
+ clearTimeout(loadingStallTimer);
+ loadingStallTimer = null;
+ loadingSender = null;
+ loading.set([]);
+}
+
+/** Count a uuid as ARRIVED even though no object exists for it — a parse that rejected,
+ * or an object the sender dropped. Without this the bar waits out the full stall.
+ * @param {string[] | string} uuids */
+export function noteLoadFailed(uuids) {
+ const gone = new Set(Array.isArray(uuids) ? uuids : [uuids]);
+ const left = /** @type {string[]} */ (get(loading)).filter((u) => !gone.has(u));
+ loading.set(left);
+ if (!left.length) clearLoadingBatch();
+}
+
+/** @param {number} count @param {string[]} uuids @param {string} [senderId] */
+export async function createLoader(count, uuids, senderId) {
// console.log("create loader for " + count + " objects: " + uuids);
- loading.set(uuids);
+ loading.set(Array.isArray(uuids) ? uuids : []);
loadingcount.set(count);
- //Trigger reactivity for UI list of objects on remote
- loading.update((value) => value);
- //Trigger reactivity for UI list of objects on remote
- loadingcount.update((value) => value);
+ loadingSender = senderId ?? null;
+ // 26-C: THE ONE MOMENT the size is known and nothing has been applied. Past it a
+ // 4,000-object scene is simply happening to you.
+ const verdict = ingestVerdict(liveObjectCount(), count, profileFor(get(globalRenderer)));
+ if (verdict.gate) {
+ ingestHeld = true;
+ ingestGate.set({
+ count: verdict.incoming,
+ allowed: verdict.allowed,
+ total: verdict.total,
+ limit: verdict.limit,
+ sender: loadingSender
+ });
+ // the stall timer must NOT run while the question is open — the objects are
+ // parked, not missing, and clearing the bar under an open fork would be a lie
+ clearTimeout(loadingStallTimer);
+ loadingStallTimer = null;
+ return;
+ }
+ armLoadingStall();
}
export async function colorObject(uuid, color, near, far) {
@@ -457,7 +528,7 @@ export async function objectParameters(data) {
smoothWeldedNormals(mesh.geometry);
} else mesh.geometry.computeVertexNormals();
mesh.geometry.attributes.normal.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
}
} else if (data.parameter == 'physics') {
// P-A: userData.physics is the source of truth for the Inspector-set
@@ -466,7 +537,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.physics) mesh.userData.physics = data.physics;
else delete mesh.userData.physics;
- objectsGroup.update((value) => value); // collider viz re-syncs
+ pokeScene(); // collider viz re-syncs
physicsShapeChanged(data.uuid); // CL-A A2: live mid-sim rebuild
}
} else if (data.parameter == 'origin') {
@@ -476,7 +547,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.origin) mesh.userData.origin = data.origin;
else delete mesh.userData.origin;
- objectsGroup.update((value) => value);
+ pokeScene();
physicsShapeChanged(data.uuid); // the body/collider pose follows the pivot
}
} else if (data.parameter == 'particles') {
@@ -486,7 +557,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.particles) mesh.userData.particles = data.particles;
else delete mesh.userData.particles;
- objectsGroup.update((value) => value);
+ pokeScene();
}
} else if (data.parameter == 'device') {
// 23-A3: userData.device is a device object's whole configuration ({kind,
@@ -500,7 +571,7 @@ export async function objectParameters(data) {
if (mesh) {
if (data.camera) mesh.userData.camera = data.camera;
else delete mesh.userData.camera;
- objectsGroup.update((value) => value); // frustum viz + preview re-read
+ pokeScene(); // frustum viz + preview re-read
}
} else if (data.parameter == 'renderOrder') {
let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid);
@@ -520,7 +591,7 @@ export async function deleteObject(uuid) {
sceneObjects.remove(sceneObjects.getObjectByProperty('uuid', uuid));
disposeTree(object, { keep });
//Trigger reactivity for UI list of objects on remote
- objectsGroup.update((value) => value);
+ pokeScene();
}
@@ -541,6 +612,181 @@ export async function deleteObject(uuid) {
* @param {string} [groupuuid] @param {number[]} [pos] @param {number[]} [rot] @param {number[]} [scale]
*/
export async function createObject(object, uuid, override, groupuuid, pos, rot, scale) {
+ return enqueueIngest([object, uuid, override, groupuuid, pos, rot, scale]);
+}
+
+// ---------------------------------------------------------------------------
+// 26-B (roadmap 26 Stage 0) — TIME-SLICED INGEST.
+//
+// The dispatcher calls `createObject` once per incoming `object` message and never
+// awaits it, so a 1,000-object handshake used to start 1,000 overlapping parses in the
+// same task: `GLTFLoader.parse` is main-thread by design, so the tab had no frame to
+// give anyone until the last one finished. Ordering was also only accidental — two
+// parses that resolved out of order could attach a child before its group existed.
+//
+// The queue fixes both with one mechanism. Objects are applied STRICTLY IN THE ORDER
+// RECEIVED, and the drainer yields to the event loop every SLICE_MS of work, so input,
+// rendering and the poke flush all get a turn while a big scene lands. A batch is open
+// for the whole drain, which is what puts `pokeScene` into its one-per-frame mode.
+//
+// A macrotask (setTimeout 0) is the yield, not a microtask: a microtask chain never
+// returns to the browser, so it would slice the work without ever letting a frame run.
+// ---------------------------------------------------------------------------
+
+/** How long the drainer may hold the thread before yielding. 8ms leaves half a 60Hz
+ * frame for everything else. */
+const INGEST_SLICE_MS = 8;
+
+/** @type {{args: any[], resolve: (v?: any) => void, reject: (e: any) => void}[]} */
+let ingestQueue = [];
+let ingestDraining = false;
+
+// ---------------------------------------------------------------------------
+// 26-C (roadmap 26 Stage 2) — THE INGEST GATE.
+//
+// A scene arriving over the wire announces itself first (`{type:'loading', count,
+// uuids}`) and only then sends the objects, so there is exactly one moment where the
+// size is known and nothing has been applied yet. Past that moment a 4,000-object scene
+// is simply happening to you.
+//
+// The queue built in 26-B is already the parking mechanism: HOLDING it parks every
+// object that arrives, parsed or not, with no second code path and nothing to unwind.
+// The fork is three-way because a stream is divisible — half a room's scenery is a
+// usable scene, and the alternative to "load the first N" is all-or-nothing on somebody
+// else's content.
+//
+// LOCAL ONLY. Nothing here is sent: the peer is not told we declined, because that is a
+// fact about THIS device's budget and there is nothing for them to do about it. They
+// see us with fewer objects, which is what actually happened.
+// ---------------------------------------------------------------------------
+
+/** The open question, or null. Toasts.svelte MIRRORS this into one sticky card (the
+ * `restoreAvailable` idiom) rather than this module importing the UI. */
+/** @type {import('svelte/store').Writable<{count: number, allowed: number, total: number, limit: number, sender: string | null} | null>} */
+export const ingestGate = writable(null);
+
+let ingestHeld = false;
+/** How many more objects this drain may apply before dropping the rest. Infinity = no
+ * cap, which is every path that never met a gate. */
+let ingestCap = Infinity;
+
+/** How many objects the scene already holds — the walk the verdict is measured against. */
+function liveObjectCount() {
+ let n = 0;
+ sceneObjects?.traverse?.((/** @type {any} */ o) => {
+ if (o !== sceneObjects) n++;
+ });
+ return n;
+}
+
+/**
+ * Answer the fork. 'all' releases everything, 'some' applies up to the budget and drops
+ * the rest, 'cancel' drops the lot.
+ * @param {'all'|'some'|'cancel'} answer
+ */
+export function resolveIngestGate(answer) {
+ const open = get(ingestGate);
+ if (!open) return 0;
+ ingestGate.set(null);
+ ingestHeld = false;
+ if (answer === 'cancel') {
+ const dropped = dropIngestQueue();
+ clearLoadingBatch();
+ showToast('Cancelled — ' + open.count + ' objects were not loaded.');
+ return dropped;
+ }
+ ingestCap = answer === 'some' ? open.allowed : Infinity;
+ // the stall timer was parked while the question was open; the transfer resumes now
+ armLoadingStall();
+ if (!ingestDraining && ingestQueue.length) {
+ ingestDraining = true;
+ beginSceneBatch();
+ void drainIngest();
+ }
+ if (answer === 'some')
+ showToast('Loading the first ' + open.allowed + ' of ' + open.count + ' objects.');
+ return ingestQueue.length;
+}
+
+/** Is a fork open? Read by the suite. */
+export function ingestGateOpen() {
+ return ingestHeld;
+}
+
+/** @param {any[]} args */
+function enqueueIngest(args) {
+ return new Promise((resolve, reject) => {
+ ingestQueue.push({ args, resolve, reject });
+ if (!ingestDraining) {
+ ingestDraining = true;
+ beginSceneBatch();
+ void drainIngest();
+ }
+ });
+}
+
+async function drainIngest() {
+ try {
+ while (ingestQueue.length && !ingestHeld) {
+ const started = performance.now();
+ while (ingestQueue.length && performance.now() - started < INGEST_SLICE_MS) {
+ const job = ingestQueue.shift();
+ if (!job) break;
+ if (ingestCap <= 0) {
+ // over the budget the user agreed to: the object is DROPPED, and its
+ // uuid is counted as arrived so the progress bar does not wait out
+ // the full stall for something that is never coming
+ const uuid = job.args[1];
+ noteLoadFailed(Array.isArray(uuid) ? uuid : []);
+ job.resolve(undefined);
+ continue;
+ }
+ try {
+ // @ts-ignore - spread of a fixed-length arg tuple
+ job.resolve(await applyCreateObject(...job.args));
+ if (Number.isFinite(ingestCap)) ingestCap--;
+ } catch (error) {
+ // a parse that rejects is still an ARRIVAL as far as the progress bar
+ // is concerned, or the batch waits out the full 60s stall
+ console.log('Failed to create an incoming object: ' + error);
+ const uuid = job.args[1];
+ noteLoadFailed(Array.isArray(uuid) ? uuid : [job.args[0]?.element?.object?.uuid].filter(Boolean));
+ job.reject(error);
+ }
+ }
+ if (ingestQueue.length && !ingestHeld) await new Promise((r) => setTimeout(r, 0));
+ }
+ } finally {
+ ingestDraining = false;
+ endSceneBatch();
+ if (!ingestQueue.length) ingestCap = Infinity;
+ }
+}
+
+/** A peer wiped the scene, or we did: whatever is still parked is about to be wrong.
+ * (Roadmap 26 section 5 — "the ingest queue drops on clear".) */
+export function dropIngestQueue() {
+ ingestHeld = false;
+ ingestCap = Infinity;
+ ingestGate.set(null);
+ if (!ingestQueue.length) return 0;
+ const dropped = ingestQueue.length;
+ for (const job of ingestQueue) job.resolve(undefined);
+ ingestQueue = [];
+ return dropped;
+}
+
+/** How many objects are parked. Read by the suite and the 26-A meter. */
+export function ingestBacklog() {
+ return ingestQueue.length;
+}
+registerMetricSource('ingestBacklog', ingestBacklog);
+
+/**
+ * @param {any} object @param {string[]|null} uuid @param {boolean} [override]
+ * @param {string} [groupuuid] @param {number[]} [pos] @param {number[]} [rot] @param {number[]} [scale]
+ */
+async function applyCreateObject(object, uuid, override, groupuuid, pos, rot, scale) {
let parent;
if (uuid == null) {
let mesh = loader.parse(object.element);
@@ -613,7 +859,7 @@ export async function createObject(object, uuid, override, groupuuid, pos, rot,
});
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -627,30 +873,39 @@ export async function createObject(object, uuid, override, groupuuid, pos, rot,
* walk emits is byte-identical to what it always sent.
*/
export function sendObjects(peerId, element, opts = {}) {
- let conn; let groupid;
+ let groupid;
if (peerId === null) {
groupid = element.uuid;
- conn = peer;
- conn.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null,
+ peer.send({type: 'group', name: element.name, uuid: element.uuid, groupparent: null,
pos: element.position.toArray(),
rot: element.rotation.toArray(),
scale: element.scale.toArray(),
...(opts.override ? { override: true } : {})
});
}
- else
- conn = peer.connections[peerId];
-
- let objects = [];
- // Iterate over all objects in the scene
- let count = countObjects(element);
+ // 26-B (audit M1): the uuid list is built PER CALL. It used to be a module-level
+ // array that `countObjects` PUSHED onto and only the timer emptied, so two
+ // approvals 400ms apart both counted into it: the second joiner was told to expect
+ // the first joiner's objects too and its progress bar read "12/40" forever, while
+ // `count` itself was the RUNNING TOTAL rather than this send's.
+ const uuidList = [];
+ const count = countObjects(element, uuidList);
console.log("Sending " + count + " objects to " + peerId);
// Wait 500ms to ensure the connection is established before sending the objects
setTimeout(() => {
+ // …and RESOLVE THE CONNECTION HERE, not 500ms ago. `peer.connections[peerId]`
+ // is undefined while the dial is still in flight and closed when the joiner
+ // gave up in between; both used to throw INSIDE A TIMER, where nothing catches
+ // it — the handshake reply simply vanished with an uncaught TypeError.
+ const conn = peerId === null ? peer : peer?.connections?.[peerId];
+ if (!conn || (peerId !== null && !conn.open)) {
+ console.log('Not sending ' + count + ' objects to ' + peerId + ': the connection is gone');
+ return;
+ }
// Send amount of objects to be sent and their uuids
- conn.send({type: 'loading', count: count, uuids: uuids});
+ conn.send({type: 'loading', count: count, uuids: uuidList});
// park animated objects at their base pose so the receiver captures the
// TRUE animation base, not a mid-swing pose (88). The walk below reads
// every transform synchronously, so restore right after.
@@ -660,7 +915,6 @@ export function sendObjects(peerId, element, opts = {}) {
} finally {
restore();
}
- uuids = [];
}, 500);
}
@@ -833,7 +1087,8 @@ export function sendObject(conn, element, groupuuid, opts = {}) {
}
-function countObjects(element) {
+/** @param {any} element @param {string[]} sink the CALLER's uuid list (audit M1) */
+function countObjects(element, sink) {
let objects = [];
if (typeof element !== 'undefined') {
objects = element.children;
@@ -842,10 +1097,9 @@ function countObjects(element) {
}
objects.forEach(element => {
if (element.type == "Group" && !hasAnimatedImport(element.uuid)) {
- countObjects(element);
+ countObjects(element, sink);
}
- uuids.push(element.uuid)
+ sink.push(element.uuid)
})
- // console.log(uuids.length)
- return uuids.length;
+ return sink.length;
}
diff --git a/src/lib/drawMode.js b/src/lib/drawMode.js
index 488304f3..23910fec 100644
--- a/src/lib/drawMode.js
+++ b/src/lib/drawMode.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordObjectPresence } from './history';
@@ -199,7 +199,7 @@ export function endStroke() {
mesh.userData.shadow = false; // draw strokes don't cast (basic-material lines)
group.add(mesh);
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', mesh);
/** @type {any} */
const peer = get(peers);
diff --git a/src/lib/environment.js b/src/lib/environment.js
index 6f1ecf1b..e47ab28e 100644
--- a/src/lib/environment.js
+++ b/src/lib/environment.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, globalRenderer, objectsGroup, backgroundColor, TControls, passthroughActive } from '../stores/sceneStore';
+import { globalScene, globalRenderer, objectsGroup, backgroundColor, TControls, passthroughActive, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { sceneRadius } from './sceneBounds';
import { registerSystemGroup } from './moduleSDK';
@@ -403,7 +403,7 @@ export function convertToEnvironment(uuid) {
const controls = get(TControls);
if (controls?.object?.uuid === uuid) controls.detach();
object.parent?.remove(object);
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'delete', uuid, peerId: peer.peer.id });
@@ -424,7 +424,7 @@ export function convertFromEnvironment(id) {
if (def.groundColor && light.groundColor) light.groundColor.set(def.groundColor);
light.intensity = def.intensity ?? 1;
if (def.position) light.position.fromArray(def.position);
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) {
diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js
index 6fc17685..b88fda84 100644
--- a/src/lib/faceEdit.js
+++ b/src/lib/faceEdit.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, globalCamera, objectsGroup, TControls, lockedObjects, isVRMode } from '../stores/sceneStore';
+import { globalScene, globalCamera, objectsGroup, TControls, lockedObjects, isVRMode, pokeScene } from '../stores/sceneStore';
// 15-F: session-scoped undo — editSession imports ONLY history (an edge we
// already have), so this closes no cycle
import { noteEditEnter, noteEditExit, sealEditHistorySession } from './editSession';
@@ -1481,7 +1481,7 @@ export function applyMeshGeo(uuid, positions, groups, uvs, faceCounts, faceTris)
// module eval (it imports us — a dynamic import back would be a SECOND module
// instance under vite's ?t= HMR stamps, whose editingObject is always null).
vertexSessionRefresher?.(uuid);
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Average normals across position-welded vertices of a NON-INDEXED geometry
@@ -5433,7 +5433,7 @@ export function setShadingSmooth(smooth) {
uuid: faceEdited.uuid,
shading: faceEdited.userData.shading
});
- objectsGroup.update((v) => v);
+ pokeScene();
showToast(smooth ? 'Shading: smooth' : 'Shading: flat');
return true;
}
@@ -6316,7 +6316,7 @@ function applyGeometrySnapshot(positions, groups, uvs, faces) {
refreshFaceOverlay();
refreshEdgeHighlight(); // M4: baked in world space, same as the face overlay
refreshFaceWireframe(); // B2: the overlay wraps the NEW geometry
- objectsGroup.update((v) => v);
+ pokeScene();
}
/**
@@ -6486,7 +6486,7 @@ function liveGeometryUpdate() {
// grab it draws from the grab's own live endpoints (see refreshEdgeOverlay)
refreshEdgeOverlay();
refreshFaceWireframe(); // B2: track the gesture live
- objectsGroup.update((v) => v);
+ pokeScene();
const now = Date.now();
// The PREVIEW is the one thing that must stay small. A gesture streams this
// ~5×/s, so a mesh at the commit ceiling would be ~60 MB/s at every peer —
diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js
index 1b243849..21c53980 100644
--- a/src/lib/fileHandler.svelte.js
+++ b/src/lib/fileHandler.svelte.js
@@ -9,7 +9,7 @@ import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { get } from 'svelte/store';
import { scenePost } from '$lib/scenePost';
-import { objectsGroup, TControls, selectedObject, selectedObjects } from '../stores/sceneStore.js';
+import { objectsGroup, TControls, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore.js';
import { sendObjects } from './commandsHandler.svelte';
import { recordObjectPresence } from '$lib/history';
// 17-D2: the .mtl texture path reuses the app's own downscale-to-dataURL step.
@@ -330,7 +330,7 @@ function addAnimatedImport(result, buffer, name, kind) {
const root = result.scene;
root.name = name ?? 'Animated import';
sceneObjects.add(root);
- objectsGroup.update((value) => value);
+ pokeScene();
controls.attach(root);
registerAnimatedImport(root, result.animations, buffer, kind ?? 'gltf');
recordAnimatedImport(root);
@@ -350,7 +350,7 @@ function addImported(imported, name, position) {
if (position) imported.position.fromArray(position);
sceneObjects.add(imported);
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
controls.attach(imported);
recordObjectPresence('create', imported);
sendObjects(/** @type {any} */ (null), imported);
@@ -734,7 +734,7 @@ try {
sceneObjects.add(mesh)
});
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
//Send object to peers
peer.send({type: 'object', element: json, uuids: uuids})
} else if (file.name.split('.').pop() == 'json') {
@@ -753,7 +753,7 @@ try {
peer.send({type: 'object', element: child.toJSON()})
});
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// Free memory by emptying the array
objectsArray.length = 0;
console.log('Scene load complete');
diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js
index 030cbc89..ff003b00 100644
--- a/src/lib/geometries.svelte.js
+++ b/src/lib/geometries.svelte.js
@@ -14,7 +14,7 @@ function initRectAreaUniforms() {
RectAreaLightUniformsLib.init();
}
import { notifyExternalMove, noteObjectPose } from '$lib/flowRuntime';
-import { globalScene, objectsGroup, TControls, lockedObjects, selectedObject, selectedObjects } from '../stores/sceneStore.js';
+import { globalScene, objectsGroup, TControls, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore.js';
// 27-A: a transform off the wire is sanitised before it reaches the scene graph
import { sanitizeTransform } from './wireValidate';
import { noteWireError } from './wireErrors';
@@ -134,7 +134,7 @@ export function createGeometry(command, uuid) {
if (['Wedge', 'Stairs', 'Arch', 'Corner'].includes(geometry)) object.userData.colliderHint = 'hull';
sceneObjects.add(object);
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// console.log('createGeometry: ' + geometry);
if (!uuid) controls.attach(object);
if (!uuid) selectedObject.set(object);
@@ -199,7 +199,7 @@ export function createLight(command, uuid) {
if (uuid) light.uuid = uuid
sceneObjects.add(light);
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// console.log('createLight: ' + light);
if (!uuid) controls.attach(light);
if (!uuid) selectedObject.set(light);
@@ -234,7 +234,7 @@ export function createGroup(command, uuid, groupuuid, name, groupparent, pos, ro
group.scale.set(scale[0], scale[1], scale[2]);
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
return group.uuid
} else {
// R22 round 32 — A GROUP IS KEYED BY UUID TOO. This branch created a second
@@ -255,7 +255,7 @@ export function createGroup(command, uuid, groupuuid, name, groupparent, pos, ro
held.rotation.set(rot[0], rot[1], rot[2]);
held.scale.set(scale[0], scale[1], scale[2]);
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
return held.uuid;
}
@@ -275,7 +275,7 @@ export function createGroup(command, uuid, groupuuid, name, groupparent, pos, ro
}
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
// console.log('createGroup: ' + group);
if (!uuid) controls.attach(group);
if (!uuid) selectedObject.set(group);
@@ -303,7 +303,7 @@ export function changeName(uuid, name) {
if(object) {
object.name = name;
//Trigger reactivity for UI list of objects
- objectsGroup.update((value) => value);
+ pokeScene();
}
}
diff --git a/src/lib/geometryEdit.js b/src/lib/geometryEdit.js
index 684d9fb7..d99876cb 100644
--- a/src/lib/geometryEdit.js
+++ b/src/lib/geometryEdit.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordEntry, registerHistoryKind } from './history';
import { GEOMETRY_PARAMS, geometrySpec } from './geometryParams';
@@ -113,7 +113,7 @@ export function applyGeometry(uuid, patch, options = {}) {
// disabled after a rebuild that just threw those edits away.
delete object.userData.vertexEdited;
delete object.userData.faceEdited;
- objectsGroup.update((value) => value);
+ pokeScene();
if (record)
recordEntry({ kind: 'geometry', uuid, before, after: { gtype: current.gtype, params } });
if (replicate) {
@@ -137,7 +137,7 @@ export function applyRemoteGeometry(data) {
object.userData.geometryParams = { gtype: data.gtype, params: { ...data.params } };
delete object.userData.vertexEdited;
delete object.userData.faceEdited; // same lock, same reset as the local path
- objectsGroup.update((value) => value);
+ pokeScene();
}
// undo/redo replays the full param set — `state` is the recorded
diff --git a/src/lib/history.js b/src/lib/history.js
index de027235..6f0d3734 100644
--- a/src/lib/history.js
+++ b/src/lib/history.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, derived, get } from 'svelte/store';
-import { objectsGroup, TControls, selectedObject } from '../stores/sceneStore';
+import { objectsGroup, TControls, selectedObject, pokeScene } from '../stores/sceneStore';
import { peers, showToast, closeSelectionInspector } from '../stores/appStore';
import { notifyExternalMove } from '$lib/flowRuntime';
import { parkEditOverlays, stripEditOverlays } from '$lib/editOverlays';
@@ -258,7 +258,7 @@ function applyPresence(entry, state) {
? group.getObjectByProperty('uuid', entry.snapshot.parentUuid)
: null;
(parent ?? group).add(object);
- objectsGroup.update((value) => value);
+ pokeScene();
// receivers take the same ObjectLoader path as light/parent sync
if (peer)
peer.send({ type: 'object', element: entry.snapshot.element, groupuuid: entry.snapshot.parentUuid ?? undefined });
@@ -284,7 +284,7 @@ function applyPresence(entry, state) {
closeSelectionInspector();
}
existing.parent?.remove(existing);
- objectsGroup.update((value) => value);
+ pokeScene();
if (peer) peer.send({ type: 'delete', uuid: entry.uuid, peerId: peer.peer.id });
return true;
}
@@ -311,7 +311,7 @@ registerHistoryKind('transformSet', (entry, state) => {
peer.send({ type: 'move', uuid: item.uuid, pos: target.pos, rot: target.rot, scale: target.scale });
any = true;
});
- if (any) objectsGroup.update((value) => value);
+ if (any) pokeScene();
else showToast('Cannot undo/redo: the objects no longer exist');
return any;
});
@@ -330,7 +330,7 @@ function applyState(entry, state) {
object.rotation.set(state.rot[0], state.rot[1], state.rot[2]);
object.scale.fromArray(state.scale);
notifyExternalMove(entry.uuid); // undoing an animated object rewrites its base
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'move', uuid: entry.uuid, pos: state.pos, rot: state.rot, scale: state.scale });
diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js
index 837d29c1..dfd8cdfd 100644
--- a/src/lib/materialsHandler.js
+++ b/src/lib/materialsHandler.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry, registerHistoryKind } from '$lib/history';
@@ -100,7 +100,7 @@ registerHistoryKind('material', (entry, state) => {
if (object.material?.color) object.material.color.set(state.value);
broadcast({ type: 'color', uuid: entry.uuid, color: state.value });
}
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
});
@@ -149,7 +149,7 @@ export function applyMaterials(object, payload, replicate = false) {
object.geometry.addGroup(group.start, group.count, group.materialIndex);
}
object.material.needsUpdate ??= true;
- objectsGroup.update((value) => value);
+ pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'materials', uuid: object.uuid, payload });
}
@@ -219,7 +219,7 @@ export function setObjectMaterials(uuid, materials, groups) {
object.geometry.clearGroups();
for (const group of groups) object.geometry.addGroup(group.start, group.count, group.materialIndex);
}
- objectsGroup.update((value) => value);
+ pokeScene();
const after = materialsPayload(object);
recordEntry({
kind: 'material',
@@ -381,7 +381,7 @@ export function applyMap(object, dataURL, slot = 0) {
material.map = null;
delete material.userData.mapDataUrl;
material.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
return;
}
// set synchronously so the UI thumbnail appears immediately
@@ -402,7 +402,7 @@ export function applyMap(object, dataURL, slot = 0) {
material.map?.dispose();
material.map = texture;
material.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
});
}
@@ -531,7 +531,7 @@ export function switchMaterialType(uuid, type, replicate = true) {
}
object.material = fresh;
fresh.needsUpdate = true;
- objectsGroup.update((value) => value);
+ pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'material', uuid: uuid, material: type });
}
@@ -549,7 +549,7 @@ export function setObjectColor(uuid, hex, replicate = true) {
const before = '#' + material.color.getHexString();
material.color.set(hex);
material.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
if (replicate) {
recordMaterialChange(uuid, 'color', null, before, hex);
broadcast({ type: 'color', uuid: uuid, color: hex });
@@ -573,7 +573,7 @@ export function setMaterialParam(uuid, key, value, replicate = true) {
if (isColor) material[key].set(value);
else material[key] = value;
material.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'materialParam', uuid: uuid, key: key, value: value });
}
diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js
index 2808f0e6..e292cdd8 100644
--- a/src/lib/meshEdit.js
+++ b/src/lib/meshEdit.js
@@ -7,8 +7,7 @@ import {
TControls,
lockedObjects,
isVRMode,
- transformMode
-} from '../stores/sceneStore';
+ transformMode, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { registerHistoryKind, recordEntry } from './history';
// 15-F: session-scoped undo — editSession imports ONLY history (an edge we
@@ -1637,7 +1636,7 @@ export function applyVerts(uuid, indices, positionArray) {
overlay.geometry = editWireGeometry(object.geometry);
}
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
// ---- VR vertex editing (113): drive a handle from a controller, no gizmo ----
diff --git a/src/lib/moveSmoothing.js b/src/lib/moveSmoothing.js
index 3eb01f19..7601d08e 100644
--- a/src/lib/moveSmoothing.js
+++ b/src/lib/moveSmoothing.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
// 21-B: a thrown crate is SMOOTH on the peer watching it.
@@ -105,7 +105,7 @@ export function noteRemoteMove(uuid, object, before) {
object.position.copy(pending.to.pos);
object.quaternion.copy(pending.to.quat);
eases.delete(uuid);
- objectsGroup.update((value) => value);
+ pokeScene();
}, interval + 60)
);
return true;
@@ -133,7 +133,7 @@ export function tickMoveSmoothing() {
object.position.lerpVectors(ease.from.pos, ease.to.pos, t);
object.quaternion.slerpQuaternions(ease.from.quat, ease.to.quat, t);
}
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** the sim stopped, the peer left, the scene changed — land everything at once */
diff --git a/src/lib/multiTransform.js b/src/lib/multiTransform.js
index 568ed753..37836629 100644
--- a/src/lib/multiTransform.js
+++ b/src/lib/multiTransform.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get, writable } from 'svelte/store';
-import { globalScene, objectsGroup, TControls, selectedObjects, isVRMode } from '../stores/sceneStore';
+import { globalScene, objectsGroup, TControls, selectedObjects, isVRMode, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordTransformSet } from './history';
import { hasOrigin, originWorld, setOriginFromWorld } from './objectOrigin';
@@ -256,7 +256,7 @@ export function applyPivotTransform(mutate) {
if (customOrigin) customOrigin.copy(pivot.position);
if (transientPivot) transientPivot.copy(pivot.position);
publishPivotPose();
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
}
@@ -405,7 +405,7 @@ function onDraggingChanged(/** @type {any} */ event) {
pivotStartInverse = null;
// the transient snap anchor rides where the drag left the pivot (19-B)
if (transientPivot) transientPivot.copy(pivot.position);
- objectsGroup.update((value) => value);
+ pokeScene();
}
}
diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js
index 5aec23fe..202e82ba 100644
--- a/src/lib/objectActions.js
+++ b/src/lib/objectActions.js
@@ -16,8 +16,7 @@ import {
orbitControls,
isVRMode,
gizmoSuppressed,
- cameraClaim
-} from '../stores/sceneStore';
+ cameraClaim, pokeScene } from '../stores/sceneStore';
import { attachMultiPivot, releaseMultiPivot, hasCustomOrigin, pivotPose, setPivotOrigin } from './multiTransform';
import { focusTargetFace, faceEditObject, hideElementSelection, restoreElementSelection } from './faceEdit';
import { focusTargetVertex, editingObject, hideVertexSelection, restoreVertexSelection } from './meshEdit';
@@ -379,7 +378,7 @@ export function deleteObjectsByUuid(uuids) {
object.parent?.remove(object);
if (peer) peer.send({ type: 'delete', uuid, peerId: peer.peer.id });
}
- objectsGroup.update((value) => value);
+ pokeScene();
return uuids.length;
}
@@ -511,7 +510,7 @@ export function duplicateObject(uuid, options = {}) {
}
if (options.transient) markTransient(clone);
source.parent.add(clone);
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
@@ -604,7 +603,7 @@ export function applyRemoteDuplicate(sourceUuid, uuids, name, pos, transient) {
clone.position.fromArray(pos);
if (transient) markTransient(clone);
source.parent.add(clone);
- objectsGroup.update((value) => value);
+ pokeScene();
}
// name/visibility undo entries replay by setting the recorded value directly
@@ -664,7 +663,7 @@ registerHistoryKind('props', (entry, state) => {
if (peer)
peer.send({ type: 'objectParameters', parameter: 'origin', uuid: entry.uuid, origin: state.origin });
}
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
});
@@ -691,7 +690,7 @@ export function toggleObjectVisibility(uuid) {
after: { visible: !object.visible }
});
object.visible = !object.visible;
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
@@ -706,7 +705,7 @@ export function renameObject(uuid, name) {
if (object.name !== name)
recordEntry({ kind: 'props', uuid: uuid, before: { name: object.name }, after: { name: name } });
object.name = name;
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer) peer.send({ type: 'name', uuid: uuid, name: name });
@@ -755,7 +754,7 @@ export function moveObjectToGroup(uuid, target) {
const toParent = object.parent === group ? 'root' : object.parent?.uuid;
if (fromParent !== toParent)
recordEntry({ kind: 'group', uuid: uuid, before: { parent: fromParent }, after: { parent: toParent } });
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -825,7 +824,7 @@ export function groupSelection() {
}
for (const uuid of uuids) moveObjectToGroup(uuid, groupUuid);
endHistoryBatch('Group objects');
- objectsGroup.update((value) => value);
+ pokeScene();
applySelectionSet([groupUuid]);
return groupUuid;
}
@@ -1038,7 +1037,7 @@ export async function convertToMesh(uuids) {
});
endHistoryBatch('Convert to mesh');
- objectsGroup.update((value) => value);
+ pokeScene();
applySelectionSet([mesh.uuid]);
showToast(`Merged ${sources.length} meshes into "${mesh.name}"`);
return mesh.uuid;
@@ -1074,7 +1073,7 @@ export function alignToGround(uuid) {
};
recordTransform({ uuid: object.uuid, before: before, after: after });
resumeAnimation(object.uuid); // dropped spot becomes the new animation base
- objectsGroup.update((value) => value);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
@@ -1242,7 +1241,7 @@ export function isolateObjects(uuids) {
}
}
isolationSnapshot = snapshot;
- objectsGroup.update((v) => v);
+ pokeScene();
if (hidden) showToast('Isolated — press Esc to bring the scene back');
return hidden;
}
@@ -1258,6 +1257,6 @@ export function clearIsolation() {
if (object && visible && object.visible === false) object.visible = true;
}
isolationSnapshot = null;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
diff --git a/src/lib/objectListNav.js b/src/lib/objectListNav.js
index e5e9d13f..ab83a7e3 100644
--- a/src/lib/objectListNav.js
+++ b/src/lib/objectListNav.js
@@ -4,7 +4,7 @@
// can all use it.
/**
- * @typedef {{ uuid: string, depth: number, hasKids: boolean, parent: string | null, name: string }} ObjectRow
+ * @typedef {{ uuid: string, depth: number, hasKids: boolean, parent: string | null, name: string, object: any }} ObjectRow
*/
/**
@@ -25,7 +25,9 @@ export function visibleObjectRows(group, expanded, filter) {
if (!object || object.userData?.__localOnly) return;
if (filter && !filter.has(object.uuid)) return;
const kids = object.children ?? [];
- rows.push({ uuid: object.uuid, depth, hasKids: kids.length > 0, parent, name: object.name || object.type || '' });
+ // 26-B: the OBJECT rides along so the virtualised list can render a row without
+ // walking the tree again to find it (additive — every existing reader ignores it).
+ rows.push({ uuid: object.uuid, depth, hasKids: kids.length > 0, parent, name: object.name || object.type || '', object });
if (kids.length && expanded?.has(object.uuid)) for (const kid of kids) walk(kid, depth + 1, object.uuid);
};
for (const child of group?.children ?? []) walk(child, 0, null);
diff --git a/src/lib/objectOrigin.js b/src/lib/objectOrigin.js
index 3f50168d..3f8aa882 100644
--- a/src/lib/objectOrigin.js
+++ b/src/lib/objectOrigin.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordEntry } from './history';
@@ -94,7 +94,7 @@ export function setOriginFor(uuid, local) {
/** @type {any} */
const peer = get(peers);
peer?.send({ type: 'objectParameters', parameter: 'origin', uuid, origin: next });
- objectsGroup.update((v) => v);
+ pokeScene();
return next;
}
diff --git a/src/lib/objectPermissions.js b/src/lib/objectPermissions.js
index d115d35a..c815d2bf 100644
--- a/src/lib/objectPermissions.js
+++ b/src/lib/objectPermissions.js
@@ -10,7 +10,7 @@ import { get } from 'svelte/store';
import { rolesInfo } from './cloudHooks';
import { parkEditOverlays } from './editOverlays';
import { showToast, showLocalObjects, peers } from '../stores/appStore';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
/** broadcast message `type`s that CREATE a scene object (peerHandler send-gate) */
const CREATE_TYPES = new Set(['create', 'light', 'group', 'object', 'objectfile', 'duplicate']);
@@ -74,7 +74,7 @@ export function shareObject(object, groupUuid = null) {
} finally {
unpark();
}
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
diff --git a/src/lib/overloadGuard.js b/src/lib/overloadGuard.js
new file mode 100644
index 00000000..e226a3cf
--- /dev/null
+++ b/src/lib/overloadGuard.js
@@ -0,0 +1,244 @@
+import { writable, get } from 'svelte/store';
+import { objectsGroup, globalRenderer, pokeScene } from '../stores/sceneStore';
+import { BUDGETS, profileFor, registerFrameObserver, sceneMetrics, tierOf } from './sceneBudget';
+
+// 26-G (roadmap 26 section 4, Stages 3 and 4) — WHEN THE SCENE IS TOO HEAVY TO RUN.
+//
+// Stages 0-2 stop the app freezing on the way IN. This is what happens once a heavy
+// scene is already here and the device cannot keep up: a simulation that takes longer
+// to step than a frame lasts, or a render loop so slow the window stops answering.
+//
+// THE PRINCIPLE, the roadmap's: the main thread must never run an unbounded loop in
+// response to input it did not schedule. Every stop here is ONCE per streak, REVERSIBLE,
+// and SAYS SO — an automatic action the user cannot see and cannot undo is just a
+// different kind of broken.
+//
+// The context-loss half of Stage 4 already shipped in 27-G (`ContextLostOverlay`, the
+// canvas listeners, the recompile sweep). This does not rebuild it: a lost context is
+// shown by that overlay, and the paused overlay stands down whenever it is up.
+//
+// A LEAF over svelte/store, the scene store and sceneBudget. physics.js imports the
+// streak watch from here, which is why nothing here may reach the history family.
+
+/**
+ * A "N bad samples IN A ROW" detector that fires ONCE per streak. PURE given its inputs,
+ * so the rule is provable with no GPU and no physics world.
+ *
+ * Consecutive, not cumulative: one 300ms hitch while a texture uploads is not a scene
+ * that is too heavy, and a trigger that fired on it would stop somebody's simulation
+ * because they imported a picture.
+ * @param {{overMs: number, count: number}} opts
+ */
+export function createStreakWatch({ overMs, count }) {
+ let streak = 0;
+ let fired = false;
+ return {
+ /** @param {number} ms @returns {boolean} true exactly once, on the sample that completes a streak */
+ note(ms) {
+ if (!(ms > overMs)) {
+ streak = 0;
+ fired = false;
+ return false;
+ }
+ streak++;
+ if (streak >= count && !fired) {
+ fired = true;
+ return true;
+ }
+ return false;
+ },
+ reset() {
+ streak = 0;
+ fired = false;
+ },
+ streak: () => streak
+ };
+}
+
+// --- Stage 3: the physics budget -------------------------------------------------
+
+/** Step time past which a simulation is not keeping up: a 24ms step on a 16.7ms frame
+ * means every frame is late before rendering even starts. */
+export const PHYSICS_SLOW_MS = 24;
+/** …for this many steps in a row (half a second at 60Hz). */
+export const PHYSICS_SLOW_STEPS = 30;
+
+// --- Stage 4: the render freeze ---------------------------------------------------
+
+/** A frame that takes this long is the window visibly not answering. */
+export const FREEZE_FRAME_MS = 250;
+/** …for this many frames in a row, i.e. at least 2.5 seconds of a frozen tab. */
+export const FREEZE_FRAMES = 10;
+
+/** The pause, or null. `reason` says which trigger fired. LOCAL — a pause is about THIS
+ * device's GPU, so it never replicates. */
+/** @type {import('svelte/store').Writable<{reason: string, at: number} | null>} */
+export const renderPaused = writable(null);
+
+const freezeWatch = createStreakWatch({ overMs: FREEZE_FRAME_MS, count: FREEZE_FRAMES });
+/** After a resume the next few frames are expected to be slow (the first composer frame
+ * recompiles) — a grace window stops Resume from immediately re-pausing. */
+const RESUME_GRACE_MS = 3000;
+let graceUntil = 0;
+let wasHidden = false;
+
+/**
+ * Fed every frame by sceneBudget's loop. Three things are NOT a frozen scene and must
+ * never trip it: a backgrounded tab (the browser throttles rAF to ~1Hz on purpose), the
+ * first frame after the tab comes back (its delta spans the whole absence), and the
+ * seconds straight after a resume.
+ * @param {number} ms
+ */
+export function noteFrameForFreeze(ms) {
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
+ wasHidden = true;
+ freezeWatch.reset();
+ return false;
+ }
+ if (wasHidden) {
+ wasHidden = false;
+ freezeWatch.reset();
+ return false;
+ }
+ if (get(renderPaused) || Date.now() < graceUntil) return false;
+ // A SLOW MACHINE IS NOT AN OVERLOADED SCENE. A software-rendered page lives at
+ // ~2.5fps — 400ms frames, forever — and a real user on a weak GPU can too, drawing a
+ // scene of twelve boxes. Pausing that helps nothing: there is nothing heavy to set
+ // aside, Reduce would reduce nothing, and the person loses a window that was slow but
+ // ANSWERING. The first version of this trigger did exactly that and covered every
+ // non-GPU e2e suite with the overlay. So the streak only counts while the SCENE is
+ // heavy by its own measure; a light scene cannot build one at all.
+ if (!sceneIsHeavy()) {
+ freezeWatch.reset();
+ return false;
+ }
+ if (freezeWatch.note(ms)) {
+ pauseRendering('frozen');
+ return true;
+ }
+ return false;
+}
+
+/** The scene-size axes only — never frame time itself, which would make the rule
+ * circular. `unknown` (nothing sampled yet) is NOT heavy, so a freshly booted page can
+ * never be paused before the first reading. */
+const HEAVY_AXES = ['objects', 'triangles', 'calls'];
+
+/** Is the scene big enough that pausing it and setting part of it aside could help? */
+export function sceneIsHeavy() {
+ const metrics = get(sceneMetrics);
+ const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop';
+ return HEAVY_AXES.some((key) => {
+ const tier = tierOf(key, metrics?.[key], profile);
+ return tier === 'amber' || tier === 'red';
+ });
+}
+
+/** @param {string} reason */
+export function pauseRendering(reason) {
+ if (get(renderPaused)) return;
+ renderPaused.set({ reason, at: Date.now() });
+}
+
+export function resumeRendering() {
+ renderPaused.set(null);
+ freezeWatch.reset();
+ graceUntil = Date.now() + RESUME_GRACE_MS;
+}
+
+// --- Reduce: take the scene down to the budget, LOCALLY ----------------------------
+//
+// "Hiding the newest objects" — but NOT with `visible = false`. Autosave exports the
+// scene through GLTFExporter with no options, and `onlyVisible` DEFAULTS TO TRUE, so a
+// hidden object is silently DROPPED from the recovery snapshot. Reducing a scene would
+// then quietly delete its newest objects from the one copy meant to survive a crash —
+// and the overlay promises autosave keeps running.
+//
+// A render LAYER is invisible to every serializer (GLTFExporter never reads layers,
+// toJSON writes the mask but nothing reads it back as visibility), never replicates, and
+// is honoured by the camera's cull and the raycaster alike. So a reduced object is still
+// in the scene, in the save, on the wire and in the undo stack — it is simply not drawn
+// or picked HERE. The original masks live in a WeakMap, never on userData, so they cannot
+// leak into a file.
+
+/** The layer reduced objects are moved to. 31 is the last of three's 32 layers and
+ * nothing in this app enables it on a camera. */
+export const REDUCED_LAYER = 31;
+
+/** @type {Map>} per reduced ROOT uuid, each node's mask */
+const reduced = new Map();
+export const reducedObjects = writable(0);
+
+/** @param {any} node */
+function countNodes(node) {
+ let n = 0;
+ node.traverse((/** @type {any} */ o) => {
+ n++;
+ });
+ return n;
+}
+
+/**
+ * Stop drawing the newest top-level objects until what is still drawn is inside the
+ * object budget for this device. Returns how many were set aside.
+ * @param {'desktop'|'vr'} [profile]
+ */
+export function reduceScene(profile) {
+ const group = get(objectsGroup);
+ if (!group) return 0;
+ const which = profile ?? profileFor(get(globalRenderer));
+ const budget = BUDGETS.find((b) => b.key === 'objects');
+ const limit = budget ? (which === 'vr' ? budget.vr[1] : budget.desktop[1]) : Infinity;
+ let drawn = 0;
+ for (const child of group.children) if (!reduced.has(child.uuid)) drawn += countNodes(child);
+ let setAside = 0;
+ // NEWEST FIRST: children are in append order, so the end of the list is what arrived
+ // last — most likely whatever tipped the scene over
+ for (let i = group.children.length - 1; i >= 0 && drawn > limit; i--) {
+ const root = group.children[i];
+ if (reduced.has(root.uuid)) continue;
+ /** @type {WeakMap} */
+ const masks = new WeakMap();
+ root.traverse((/** @type {any} */ node) => {
+ masks.set(node, node.layers.mask);
+ node.layers.set(REDUCED_LAYER);
+ });
+ reduced.set(root.uuid, masks);
+ drawn -= countNodes(root);
+ setAside++;
+ }
+ reducedObjects.set(reduced.size);
+ if (setAside) pokeScene();
+ return setAside;
+}
+
+/** Draw everything `reduceScene` set aside again, exactly as it was. */
+export function restoreReduced() {
+ const group = get(objectsGroup);
+ let restored = 0;
+ for (const [uuid, masks] of reduced) {
+ const root = group?.getObjectByProperty?.('uuid', uuid);
+ if (root) {
+ root.traverse((/** @type {any} */ node) => {
+ const mask = masks.get(node);
+ // a node added under a reduced root since (a child attached later) had no
+ // saved mask — give it the default layer rather than leaving it stranded
+ node.layers.mask = mask ?? 1;
+ });
+ restored++;
+ }
+ }
+ reduced.clear();
+ reducedObjects.set(0);
+ if (restored) pokeScene();
+ return restored;
+}
+
+/** Is this object set aside? For the suite and any list that wants to say so. @param {string} uuid */
+export function isReduced(uuid) {
+ return reduced.has(uuid);
+}
+
+// The frame observer. Registered here, not imported by sceneBudget, so the budget
+// module stays a leaf that knows nothing about pausing.
+registerFrameObserver(noteFrameForFreeze);
diff --git a/src/lib/particleActions.js b/src/lib/particleActions.js
index aba452f1..ce11dedb 100644
--- a/src/lib/particleActions.js
+++ b/src/lib/particleActions.js
@@ -1,5 +1,5 @@
import { get } from 'svelte/store';
-import { objectsGroup, selectedObject } from '../stores/sceneStore';
+import { objectsGroup, selectedObject, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { recordEntry } from './history';
import { particlePreset, PARTICLE_DEFAULTS } from './particlePresets';
@@ -22,7 +22,7 @@ function objectOf(uuid) {
/** poke the stores so the Inspector/object list re-render */
function poke() {
- objectsGroup.update((v) => v);
+ pokeScene();
selectedObject.update((v) => v);
}
diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js
index f54b4783..db242582 100644
--- a/src/lib/peerHandler.svelte.js
+++ b/src/lib/peerHandler.svelte.js
@@ -27,6 +27,7 @@ import { canApply, getAuthProvider, dispatchCloudMessage, rolesInfo } from '$lib
// dispatcher can reject a malformed message before any applier sees it.
import { validateWireMessage } from '$lib/wireValidate';
import { noteWireError } from '$lib/wireErrors';
+import { noteWire } from '$lib/sceneBudget';
import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler';
import { applyPing } from '$lib/ping';
import { applyAssetFile, answerAssetRequest, applyAssetThumb, answerAssetThumbRequest, applyAssetStart, applyAssetChunk, applyAssetMissing } from '$lib/assetShare';
@@ -85,7 +86,7 @@ import { applySessionProposal, applySessionAnswer, deferUntilShareChoice, localS
import { applyRemoteGeometry } from '$lib/geometryEdit';
import { applyLightTarget } from '$lib/lightParams';
import { applyObjectFile } from '$lib/animatedImports';
-import { lockedObjects, selectedObject, peerHands, objectsGroup } from '../stores/sceneStore';
+import { lockedObjects, selectedObject, peerHands, objectsGroup, pokeScene } from '../stores/sceneStore';
import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast } from '../stores/appStore';
import { get } from 'svelte/store';
@@ -616,7 +617,7 @@ export class PeerConnection {
const made = get(objectsGroup)?.getObjectByProperty('uuid', data.uuid);
if (made) {
made.userData = { ...made.userData, ...data.userData };
- objectsGroup.update((value) => value);
+ pokeScene();
}
}
} else if(data.type == 'name') {
@@ -845,7 +846,9 @@ export class PeerConnection {
} else if(data.type == 'color') {
colorObject(data.uuid, data.color, data.near, data.far);
} else if(data.type == 'loading') {
- createLoader(data.count, data.uuids);
+ // 26-B (audit M2): WHO announced it, so their teardown can clear the batch. Local
+ // only — the message is unchanged, so an older peer is unaffected.
+ createLoader(data.count, data.uuids, conn.peer);
} else if(data.type == 'disconnected') {
if (data.peerId === conn.peer) {
// the peer says goodbye ITSELF (leaveSession / tab close): tear
@@ -1047,6 +1050,10 @@ export class PeerConnection {
noteWireError(conn.peer, 'shape', typeof data);
return;
}
+ // 26-A (roadmap 26 section 3, audit H7): WHICH STREAM IS CHATTY. Counted per
+ // type here and in `broadcast`; local only, never replicated, and the byte
+ // figure is a 1-in-16 sample so the measurement cannot become the cost.
+ noteWire('in', data);
// …then the shape its own type implies, so an applier cannot throw halfway
// through applying half a message. A type absent from the table is ALLOWED,
// which is what keeps a newer peer's messages working.
@@ -1515,6 +1522,7 @@ export class PeerConnection {
// conn can't throw mid-loop and starve the rest of the mesh (172).
/** @param {any} payload */
broadcast(payload) {
+ noteWire('out', payload);
// TWO REASONS TO WITHHOLD, and they are different arguments about the same peer.
//
// P2b, BANDWIDTH: pose streams (`camera`, `vrhands`) are bytes nobody in another
diff --git a/src/lib/physics.js b/src/lib/physics.js
index d0379f86..cc27ca41 100644
--- a/src/lib/physics.js
+++ b/src/lib/physics.js
@@ -1,7 +1,9 @@
import * as THREE from 'three';
+// 26-G: the streak watch is a pure leaf (stores + sceneBudget) — no edge into history.
+import { createStreakWatch, PHYSICS_SLOW_MS, PHYSICS_SLOW_STEPS } from './overloadGuard';
import { writable, get } from 'svelte/store';
import { flowGraphs, allNodes, allEdges, SCENE_GRAPH } from '../stores/flowStore';
-import { objectsGroup, lockedObjects, selectedObject, selectedObjects } from '../stores/sceneStore';
+import { objectsGroup, lockedObjects, selectedObject, selectedObjects, pokeScene } from '../stores/sceneStore';
import { peers, showToast, openSceneSection } from '../stores/appStore';
import { recordTransformSet, recordEntry } from './history';
import {
@@ -458,7 +460,7 @@ export function setPhysicsFor(uuid, patch) {
/** @type {any} */
const peer = get(peers);
peer?.send({ type: 'objectParameters', parameter: 'physics', uuid, physics: next });
- objectsGroup.update((v) => v); // collider viz re-syncs from the poke
+ pokeScene(); // collider viz re-syncs from the poke
physicsShapeChanged(uuid); // CL-A A2: live mid-sim collider rebuild
return next;
}
@@ -488,7 +490,7 @@ export function enablePhysicsOnSelection() {
showToast('Select an object first — then Enable physics makes it fall and collide');
return 0;
}
- objectsGroup.update((v) => v);
+ pokeScene();
selectedObject.update((v) => v);
showToast(count === 1 ? 'Physics enabled — dynamic, mass 1' : 'Physics enabled on ' + count + ' objects — dynamic, mass 1');
return count;
@@ -1236,7 +1238,7 @@ export function applyThrow(data) {
// external kinematic hold and EATS the throw
entry.lastWritten.pos.copy(object.position);
entry.lastWritten.quat.copy(object.quaternion);
- objectsGroup.update((value) => value);
+ pokeScene();
return true;
}
@@ -1251,7 +1253,15 @@ const MAX_SUBSTEPS = 8;
/** @param {number} now */
function step(now) {
try {
+ const started = performance.now();
stepInner(now);
+ // 26-G (roadmap 26 Stage 3): A SIMULATION THAT CANNOT KEEP UP. 27-C catches a step
+ // that THROWS; nothing caught one that simply takes longer than the frame it runs
+ // in, which turns every frame late before rendering starts and reads as the app
+ // freezing. Streak-based and ONCE per streak (a single slow step while a big body
+ // is built is not a scene too heavy to simulate), and the toast carries Resume so
+ // the stop is never a dead end.
+ if (slowStepWatch.note(performance.now() - started)) stopForSlowSteps();
} catch (error) {
console.warn('physics step failed, stopping the simulation', error);
// stopSimulation clears the post-tick hook itself, so this cannot re-enter.
@@ -1265,6 +1275,31 @@ function step(now) {
}
}
+const slowStepWatch = createStreakWatch({ overMs: PHYSICS_SLOW_MS, count: PHYSICS_SLOW_STEPS });
+
+/** ONE stop path for the slow-step streak, shared by the real step and the test hook so
+ * the two cannot drift apart. */
+function stopForSlowSteps() {
+ slowStepWatch.reset();
+ stopSimulation({ reason: 'too slow' });
+ showToast('Physics stopped — the simulation was too slow for this device (over ' + PHYSICS_SLOW_MS + 'ms a step). The scene is intact.', [
+ { label: 'Resume', action: () => { void toggleSimulation(); } }
+ ]);
+}
+
+/** TEST-ONLY: feed `n` step durations of `ms` through the SAME watch the real step uses,
+ * so the slow-step stop is provable without building a scene slow enough on the CI box. */
+export function noteSlowStepsForTest(/** @type {number} */ n, /** @type {number} */ ms) {
+ let fired = false;
+ for (let i = 0; i < n; i++) {
+ if (slowStepWatch.note(ms)) {
+ fired = true;
+ stopForSlowSteps();
+ }
+ }
+ return fired;
+}
+
/** TEST-ONLY: force the next step to throw, so the guard around it is provable. */
let throwOnNextStep = false;
export function throwOnNextStepForTest() {
@@ -1448,7 +1483,7 @@ function stepInner(now) {
}
});
pendingOob.forEach((entry) => handleOutOfBounds(entry, oobActionNow));
- objectsGroup.update((value) => value);
+ pokeScene();
}
/**
@@ -1600,7 +1635,7 @@ export function stopSimulation(opts = {}) {
simPaused.set(false);
if (peer) peer.send({ type: 'simulate', running: false, peerId: peer.peer.id });
if (items.length > 0) showToast('Simulation stopped — Ctrl+Z restores the initial layout');
- objectsGroup.update((value) => value);
+ pokeScene();
}
/** Reset: restore the initial layout and stop (no history entry — net no-op). */
diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js
index 236f79ea..001c13b6 100644
--- a/src/lib/playInteract.js
+++ b/src/lib/playInteract.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects } from '../stores/sceneStore';
+import { isLocked, isVRMode, playPointerFree, objectsGroup, globalScene, lockedObjects, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
import { sceneHits } from './scenePick';
import { topLevelObjectOf } from './objectActions';
@@ -375,7 +375,7 @@ export function tickPlayInteract(delta, camera) {
scale: grab.object.scale.toArray()
});
}
- objectsGroup.update((v) => v);
+ pokeScene();
return;
}
diff --git a/src/lib/prefabs.js b/src/lib/prefabs.js
index 7c318063..a88d0409 100644
--- a/src/lib/prefabs.js
+++ b/src/lib/prefabs.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordObjectPresence, beginHistoryBatch, endHistoryBatch } from './history';
import { patch as audioPatch, addCablesRemapped } from './audioPatch';
@@ -449,7 +449,7 @@ export function instantiatePrefab(prefab, position) {
if (cables.length) beginHistoryBatch();
try {
group.add(object);
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', object);
/** @type {any} */
const peer = get(peers);
diff --git a/src/lib/sceneBudget.js b/src/lib/sceneBudget.js
new file mode 100644
index 00000000..cc446420
--- /dev/null
+++ b/src/lib/sceneBudget.js
@@ -0,0 +1,498 @@
+import { writable, get } from 'svelte/store';
+import { objectsGroup, globalRenderer } from '../stores/sceneStore';
+import { coarsePointer } from './inputDevice';
+
+// 26-A (roadmap 26 sections 2 and 3) — WHAT THE SCENE COSTS, AND WHETHER THAT IS A LOT.
+//
+// THE FINDING: there was no scene-level budget anywhere. No object, triangle, draw-call
+// or texture ceiling, and `renderer.info` was read by exactly one thing — the VR stats
+// plate. So the answer to "how big can a scene be" was nobody's, the answer to "why did
+// it get slow" was a guess, and a diagnostics bundle carried no numbers at all.
+//
+// TIERS WITH ACTIONS, NOT WALLS. Green does nothing, amber shows the meter, red is what
+// the ingest fork (26-C) and the auto-stops (26-G) read. Nothing here refuses anything:
+// a budget that stops you working is a budget people turn off.
+//
+// TWO PROFILES, because the same scene is fine on a desktop and fatal on a headset: a
+// mobile GPU at 72-90Hz has a third of the frame time and a fraction of the memory, and
+// the tab is KILLED rather than slowed when it runs out.
+//
+// A LEAF: svelte/store, the scene store and `inputDevice` (itself import-free). That is
+// deliberate — peerHandler counts wire traffic through here, commandsHandler publishes
+// its ingest backlog, and both sit inside the documented import cycles. Anything that
+// cannot be reached without an edge REGISTERS instead (`registerMetricSource`).
+//
+// EVERYTHING HERE IS LOCAL. Not one number replicates, saves or undoes: a budget is a
+// fact about THIS machine's GPU and this tab's main thread, and two peers on different
+// hardware must be allowed to disagree about it.
+
+/**
+ * @typedef {'green'|'amber'|'red'|'unknown'} Tier
+ * @typedef {{key: string, label: string, unit: string, desktop: [number, number], vr: [number, number], why: string}} Budget
+ */
+
+/**
+ * The section-2 table as DATA, so the meter, the overlay and the gateway read ONE
+ * source (the `hudKinds` / `SAVE_AS_FORMATS` shape). Each pair is [green ceiling,
+ * amber ceiling]; above the second number is red.
+ * @type {Budget[]}
+ */
+export const BUDGETS = [
+ {
+ key: 'objects',
+ label: 'Objects',
+ unit: '',
+ desktop: [1000, 3000],
+ vr: [500, 1500],
+ why: 'every object is at least one draw call, one wire message per joiner, one row in the tree and one node in every traversal'
+ },
+ {
+ key: 'triangles',
+ label: 'Triangles / frame',
+ unit: '',
+ desktop: [1000000, 3000000],
+ vr: [300000, 600000],
+ why: 'vertex and fill cost, at 60Hz on a desktop against 72-90Hz on a headset'
+ },
+ {
+ key: 'calls',
+ label: 'Draw calls / frame',
+ unit: '',
+ desktop: [1000, 2000],
+ vr: [300, 500],
+ why: 'there is no instancing or batching in core, so every call is CPU time'
+ },
+ {
+ key: 'textures',
+ label: 'Textures',
+ unit: '',
+ desktop: [300, 600],
+ vr: [150, 300],
+ why: 'a proxy for GPU bytes: the tab is killed on mobile and the context is lost on desktop'
+ },
+ {
+ key: 'geometries',
+ label: 'Geometries',
+ unit: '',
+ desktop: [1500, 4000],
+ vr: [700, 2000],
+ why: 'buffers held on the GPU; a leak shows here first (a delete that never disposed)'
+ },
+ {
+ key: 'frameP95',
+ label: 'Frame time p95',
+ unit: 'ms',
+ desktop: [20, 33],
+ vr: [11, 13.9],
+ why: 'FPS averages a stutter away; p95 is the frame you actually feel'
+ },
+ {
+ key: 'longTasks',
+ label: 'Long tasks / min',
+ unit: '',
+ desktop: [2, 12],
+ vr: [1, 6],
+ why: 'the direct measure of "the window froze" — a task over 50ms blocks input'
+ }
+];
+
+/** @type {Map} */
+const byKey = new Map(BUDGETS.map((b) => [b.key, b]));
+
+/**
+ * Which profile this device is judged against. `renderer.xr.isPresenting` is the true
+ * answer while a headset is on; a coarse pointer is the standing one for a phone.
+ * @param {any} [renderer]
+ * @returns {'desktop'|'vr'}
+ */
+export function profileFor(renderer) {
+ try {
+ if (renderer?.xr?.isPresenting) return 'vr';
+ } catch {
+ /* a disposed renderer */
+ }
+ return coarsePointer() ? 'vr' : 'desktop';
+}
+
+/**
+ * The tier one reading falls in. PURE — this is the part that has to be right, and it
+ * is testable with no browser and no GPU.
+ * @param {string} key @param {number | null | undefined} value @param {'desktop'|'vr'} profile
+ * @returns {Tier}
+ */
+export function tierOf(key, value, profile) {
+ const budget = byKey.get(key);
+ if (!budget || value == null || !Number.isFinite(value)) return 'unknown';
+ const [green, amber] = profile === 'vr' ? budget.vr : budget.desktop;
+ if (value <= green) return 'green';
+ if (value <= amber) return 'amber';
+ return 'red';
+}
+
+const ORDER = { unknown: 0, green: 1, amber: 2, red: 3 };
+
+/**
+ * The meter's single dot: the worst tier across everything we can read. An UNKNOWN
+ * never darkens the dot — "we have not measured it" is not "it is fine", but it is
+ * certainly not a warning either.
+ * @param {Record} metrics @param {'desktop'|'vr'} profile @returns {Tier}
+ */
+export function worstTier(metrics, profile) {
+ /** @type {Tier} */
+ let worst = 'unknown';
+ for (const budget of BUDGETS) {
+ const tier = tierOf(budget.key, metrics?.[budget.key], profile);
+ if (ORDER[tier] > ORDER[worst]) worst = tier;
+ }
+ return worst;
+}
+
+/**
+ * Every budget with its current reading and tier — what the overlay renders and what a
+ * diagnostics bundle carries.
+ * @param {Record} metrics @param {'desktop'|'vr'} profile
+ */
+export function budgetRows(metrics, profile) {
+ return BUDGETS.map((budget) => {
+ const value = metrics?.[budget.key];
+ const [green, amber] = profile === 'vr' ? budget.vr : budget.desktop;
+ return { ...budget, value: value ?? null, green, amber, tier: tierOf(budget.key, value, profile) };
+ });
+}
+
+/**
+ * 26-C (roadmap 26 Stage 2) — SHOULD THIS MANY MORE OBJECTS BE LET IN?
+ *
+ * The one question the ingest gate and the file-open ask both need, and it is PURE, so
+ * it is answerable with no scene, no wire and no browser.
+ *
+ * `allowed` is how many of `incoming` fit before the scene crosses into red — the
+ * number the "load the first N" fork offers. It is measured against the AMBER ceiling
+ * because that is where red begins; offering to fill the scene exactly to the edge of
+ * red is the most that can be let in without asking again.
+ *
+ * @param {number} current objects already in the scene
+ * @param {number} incoming objects announced
+ * @param {'desktop'|'vr'} profile
+ * @returns {{tier: Tier, total: number, current: number, incoming: number, limit: number, allowed: number, gate: boolean}}
+ */
+export function ingestVerdict(current, incoming, profile) {
+ const now = Math.max(0, Number(current) || 0);
+ const more = Math.max(0, Number(incoming) || 0);
+ const total = now + more;
+ const budget = byKey.get('objects');
+ const limit = budget ? (profile === 'vr' ? budget.vr[1] : budget.desktop[1]) : Infinity;
+ const tier = tierOf('objects', total, profile);
+ return {
+ tier,
+ total,
+ current: now,
+ incoming: more,
+ limit,
+ allowed: Math.max(0, Math.min(more, limit - now)),
+ // nothing to ask about when the arrival is empty, and nothing to ask about
+ // below red — amber warns, red asks (the tiers-with-actions rule)
+ gate: more > 0 && tier === 'red'
+ };
+}
+
+// --- frame times ------------------------------------------------------------------
+// A RING, not an average. p95 is the whole point: a scene that renders 58 of every 60
+// frames in 8ms and two in 300ms reads as 60fps and feels broken.
+
+const FRAME_RING = 240;
+/** @type {number[]} */
+const frames = [];
+
+/** @param {number} ms */
+export function noteFrame(ms) {
+ if (!Number.isFinite(ms) || ms <= 0) return;
+ frames.push(ms);
+ if (frames.length > FRAME_RING) frames.shift();
+}
+
+/** @param {number[]} sorted @param {number} q */
+function percentile(sorted, q) {
+ if (!sorted.length) return null;
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
+ return sorted[index];
+}
+
+/** p50 / p95 / p99 over the ring. PURE given the ring. */
+export function frameStats() {
+ const sorted = [...frames].sort((a, b) => a - b);
+ return {
+ n: sorted.length,
+ p50: percentile(sorted, 0.5),
+ p95: percentile(sorted, 0.95),
+ p99: percentile(sorted, 0.99)
+ };
+}
+
+// --- long tasks -------------------------------------------------------------------
+// `PerformanceObserver('longtask')` is the browser telling us, in its own words, that
+// the main thread was blocked past 50ms. Nothing else in this app can say that.
+
+/** @type {{at: number, ms: number}[]} */
+let longTasks = [];
+/** @type {any} */
+let longTaskObserver = null;
+
+/** @param {number} ms */
+export function noteLongTask(ms) {
+ const now = Date.now();
+ longTasks.push({ at: now, ms });
+ // a rolling minute, which is what the budget is stated in
+ longTasks = longTasks.filter((t) => now - t.at < 60000);
+}
+
+/** Count in the last minute plus the worst one. */
+export function longTaskStats() {
+ const now = Date.now();
+ const recent = longTasks.filter((t) => now - t.at < 60000);
+ return { perMinute: recent.length, longest: recent.reduce((m, t) => Math.max(m, t.ms), 0) };
+}
+
+export function startLongTasks() {
+ if (longTaskObserver || typeof PerformanceObserver === 'undefined') return false;
+ try {
+ longTaskObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) noteLongTask(entry.duration);
+ });
+ longTaskObserver.observe({ entryTypes: ['longtask'] });
+ return true;
+ } catch {
+ // Safari and Firefox do not implement it. The rest of the panel still works,
+ // and the row says "not available" rather than lying with a zero.
+ longTaskObserver = null;
+ return false;
+ }
+}
+
+export function stopLongTasks() {
+ try {
+ longTaskObserver?.disconnect();
+ } catch {
+ /* already gone */
+ }
+ longTaskObserver = null;
+}
+
+// --- wire traffic per type (audit H7's measurement) ---------------------------------
+// WHICH STREAM IS CHATTY is the question, and the answer is a COUNT — exact, and free.
+// BYTES are sampled: `JSON.stringify` on every message would itself become the cost
+// being measured, so one in SAMPLE_EVERY is measured and scaled, and the UI says "≈".
+
+const SAMPLE_EVERY = 16;
+/** @type {Map} */
+const wire = new Map();
+let wireSince = Date.now();
+let wireTick = 0;
+
+/** @param {'in'|'out'} dir @param {any} payload */
+export function noteWire(dir, payload) {
+ const type = typeof payload?.type === 'string' ? payload.type : 'unknown';
+ let row = wire.get(type);
+ if (!row) wire.set(type, (row = { in: 0, out: 0, bytes: 0, sampled: 0 }));
+ row[dir]++;
+ if (++wireTick % SAMPLE_EVERY === 0) {
+ try {
+ row.bytes += JSON.stringify(payload).length;
+ row.sampled++;
+ } catch {
+ // a payload holding an ArrayBuffer (the raw-bytes channels) — count the
+ // message, skip the estimate rather than pretend
+ }
+ }
+}
+
+/** Per-type rows, busiest first, with a per-second rate over the window since the
+ * last reset. `bytes` is an ESTIMATE and is labelled as one wherever it is shown. */
+export function wireStats() {
+ const seconds = Math.max(1, (Date.now() - wireSince) / 1000);
+ const rows = [...wire.entries()]
+ .map(([type, row]) => ({
+ type,
+ in: row.in,
+ out: row.out,
+ perSecond: (row.in + row.out) / seconds,
+ bytes: row.sampled ? Math.round((row.bytes / row.sampled) * (row.in + row.out)) : null
+ }))
+ .sort((a, b) => b.in + b.out - (a.in + a.out));
+ return { seconds, rows };
+}
+
+export function resetWireStats() {
+ wire.clear();
+ wireSince = Date.now();
+ wireTick = 0;
+}
+
+// --- extra sources, registered rather than imported ---------------------------------
+
+/** @type {Map any>} */
+const sources = new Map();
+
+/**
+ * Contribute a reading without this module importing you. The `registerDiagnosticsSection`
+ * seam, one domain over — commandsHandler publishes its ingest backlog this way, and
+ * physics can publish its body count without sceneBudget reaching into the cycle family.
+ * @param {string} key @param {() => any} read @returns {() => void} unregister
+ */
+export function registerMetricSource(key, read) {
+ sources.set(key, read);
+ return () => sources.delete(key);
+}
+
+// --- the sampler --------------------------------------------------------------------
+
+/** The last sample. Written ~2x/s, never per frame — the panel is DOM. */
+/** @type {import('svelte/store').Writable>} */
+export const sceneMetrics = writable({ at: 0, profile: 'desktop' });
+
+/** The desktop Statistics overlay's open state. LOCAL. */
+export const statsOpen = writable(false);
+
+/** How often the reading is recomputed. Anything faster is unreadable and the walk is
+ * O(objects); anything slower misses the hitch you opened the panel to find. */
+const SAMPLE_MS = 500;
+
+let running = false;
+/** @type {any} */
+let rafId = null;
+let lastFrameAt = 0;
+let lastSampleAt = 0;
+
+function walkScene() {
+ const group = get(objectsGroup);
+ let objects = 0;
+ let meshes = 0;
+ let hidden = 0;
+ group?.traverse?.((/** @type {any} */ o) => {
+ if (o === group) return;
+ objects++;
+ if (o.isMesh) meshes++;
+ if (o.visible === false) hidden++;
+ });
+ return { objects, meshes, hidden };
+}
+
+function sample() {
+ /** @type {any} */
+ const renderer = get(globalRenderer);
+ const info = renderer?.info;
+ const profile = profileFor(renderer);
+ const scene = walkScene();
+ const fps = frameStats();
+ const tasks = longTaskStats();
+ /** @type {any} */
+ const perf = typeof performance !== 'undefined' ? performance : null;
+ const heap = perf?.memory?.usedJSHeapSize ?? null;
+ /** @type {Record} */
+ const extra = {};
+ for (const [key, read] of sources) {
+ try {
+ extra[key] = read();
+ } catch {
+ extra[key] = null;
+ }
+ }
+ const metrics = {
+ at: Date.now(),
+ profile,
+ objects: scene.objects,
+ meshes: scene.meshes,
+ hidden: scene.hidden,
+ triangles: info?.render?.triangles ?? null,
+ calls: info?.render?.calls ?? null,
+ geometries: info?.memory?.geometries ?? null,
+ textures: info?.memory?.textures ?? null,
+ frameP50: fps.p50,
+ frameP95: fps.p95,
+ frameP99: fps.p99,
+ frameSamples: fps.n,
+ fps: fps.p50 ? Math.round(1000 / fps.p50) : null,
+ longTasks: tasks.perMinute,
+ longestTask: Math.round(tasks.longest),
+ longTasksAvailable: !!longTaskObserver,
+ heap,
+ ...extra
+ };
+ sceneMetrics.set(metrics);
+}
+
+/** @type {Set<(ms: number) => void>} */
+const frameObservers = new Set();
+
+/**
+ * Hear every frame's duration. 26-G's freeze detector is the reader; it registers rather
+ * than being imported so this module keeps knowing nothing about pausing. An observer
+ * that throws is isolated — one bad observer must not end the sampler for everyone.
+ * @param {(ms: number) => void} fn @returns {() => void} unregister
+ */
+export function registerFrameObserver(fn) {
+ frameObservers.add(fn);
+ return () => frameObservers.delete(fn);
+}
+
+function loop() {
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
+ if (lastFrameAt) {
+ const ms = now - lastFrameAt;
+ noteFrame(ms);
+ for (const fn of frameObservers) {
+ try {
+ fn(ms);
+ } catch {
+ /* isolated — see registerFrameObserver */
+ }
+ }
+ }
+ lastFrameAt = now;
+ if (now - lastSampleAt >= SAMPLE_MS) {
+ lastSampleAt = now;
+ sample();
+ }
+ if (running) rafId = requestAnimationFrame(loop);
+}
+
+/**
+ * Start sampling. The rAF loop is OUR OWN rather than threlte's task graph, on purpose:
+ * frame time measured from the browser's own callback cadence is exactly the quantity
+ * "did the window freeze" is asking about, and it keeps this a leaf that Scene.svelte
+ * does not have to know exists.
+ */
+export function startSceneMetrics() {
+ if (running || typeof requestAnimationFrame === 'undefined') return;
+ running = true;
+ lastFrameAt = 0;
+ lastSampleAt = 0;
+ startLongTasks();
+ rafId = requestAnimationFrame(loop);
+}
+
+export function stopSceneMetrics() {
+ running = false;
+ if (rafId != null) cancelAnimationFrame(rafId);
+ rafId = null;
+ stopLongTasks();
+}
+
+/** Force a reading now — the overlay opening, and the suite. */
+export function sampleSceneMetrics() {
+ sample();
+ return get(sceneMetrics);
+}
+
+/** One line per budget, for the diagnostics bundle (audit H4). */
+export function budgetSummary() {
+ const metrics = get(sceneMetrics);
+ const profile = metrics.profile === 'vr' ? 'vr' : 'desktop';
+ return {
+ profile,
+ tier: worstTier(metrics, profile),
+ metrics,
+ budgets: budgetRows(metrics, profile).map((r) => ({ key: r.key, value: r.value, tier: r.tier })),
+ wire: wireStats().rows.slice(0, 12)
+ };
+}
diff --git a/src/lib/sessions.js b/src/lib/sessions.js
index dc237f59..4dfc1f4c 100644
--- a/src/lib/sessions.js
+++ b/src/lib/sessions.js
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls } from '../stores/sceneStore';
+import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene } from '../stores/sceneStore';
import { restoreGraphs, clearGraphs, SCENE_GRAPH, allNodes } from '../stores/flowStore';
import { serializeGraphs, copyGraphFrom } from './flowGraphs';
import { serializeNode, serializeEdge, sendNodes } from './nodesHandler';
@@ -1206,7 +1206,7 @@ export function importObjects(payload, indices) {
if (peer) peer.send({ type: 'object', element: object.toJSON() });
added++;
}
- objectsGroup.update((value) => value);
+ pokeScene();
carryObjectDocuments(payload, uuidMap);
showToast('Imported ' + added + ' object' + (added === 1 ? '' : 's') + ' from the session');
return added;
@@ -1321,7 +1321,7 @@ export async function applySession(payload, opts = {}) {
group.add(object); // keep original uuids — every peer converges on them
if (replicate && peer) peer.send({ type: 'object', element });
}
- objectsGroup.update((value) => value);
+ pokeScene();
// animated imports come back from their original bytes (mixers rebuilt, peers
// reparse the same file) and authored tracks from the payload
await animatedImportsRestore(payload.animated ?? [], replicate);
@@ -1426,8 +1426,71 @@ export async function requestLoadSession(id) {
* @returns {Promise} true when the load APPLIED NOW, false when it became a
* proposal (or there was nothing to load)
*/
+
+/** Objects in a SERIALIZED payload, counting nested children — the same unit the
+ * budget is stated in (`objectsGroup` tree nodes), not the top-level array length.
+ * @param {any} payload */
+export function countPayloadObjects(payload) {
+ let n = 0;
+ /** @param {any} node */
+ const walk = (node) => {
+ if (!node) return;
+ n++;
+ for (const kid of node.children ?? []) walk(kid);
+ };
+ for (const element of payload?.objects ?? []) {
+ // a serialized element is `{object: {...}, geometries, materials}` (toJSON) or the
+ // bare node; both shapes appear in saved payloads
+ walk(element?.object ?? element);
+ }
+ return n;
+}
+
+/**
+ * Ask when a file would take this device past its object budget. True = go ahead.
+ * @param {any} payload
+ */
+async function confirmSceneSize(payload) {
+ try {
+ const [{ ingestVerdict, profileFor }, { showChoice }] = await Promise.all([
+ import('./sceneBudget'),
+ import('./confirmDialog')
+ ]);
+ const group = get(objectsGroup);
+ // the file REPLACES the scene, so the comparison is the file against the budget
+ // and not the file plus what is already here
+ const verdict = ingestVerdict(0, countPayloadObjects(payload), profileFor(get(globalRenderer)));
+ if (!verdict.gate) return true;
+ const answer = await showChoice({
+ title: 'This scene is large',
+ message:
+ '"' + (payload?.name ?? 'This scene') + '" has ' + verdict.incoming +
+ ' objects — above the ' + verdict.limit +
+ ' recommended for this device. It may be slow, and on a phone or headset the tab can be closed by the browser.',
+ choices: [{ value: 'open', label: 'Open anyway' }],
+ cancelLabel: 'Cancel'
+ });
+ return answer === 'open';
+ } catch {
+ // the ask is a courtesy; never let it stop a load it could not evaluate
+ return true;
+ }
+}
+
+/** @param {any} payload @returns {Promise} see the block comment above */
export async function requestLoadPayload(payload) {
if (!payload) return false;
+ // 26-C (roadmap 26 Stage 2, last bullet): SAY HOW BIG IT IS BEFORE REPLACING THE
+ // SCENE. This is the file half of the ingest gate, and it sits HERE rather than in
+ // `applySession` on purpose: travel, a peer's proposal, an autosave restore and the
+ // rejoin path all go through applySession, and a replicated hop must never stop at a
+ // dialog nobody is standing at (the travel-node rule). This function is the one
+ // entry point a PERSON reaches by opening a file or pressing Load.
+ //
+ // TWO ways out, not the wire's three. "Load the first N objects of this file" makes
+ // a scene nobody saved, which the user would then re-save over their own file
+ // silently truncated — a stream is divisible, a document is not.
+ if (!(await confirmSceneSize(payload))) return false;
/** @type {any} */
const peer = get(peers);
let connected = Object.keys(peer?.connections ?? {});
@@ -1713,7 +1776,7 @@ function sweepGateWork() {
if (controls?.object?.uuid === uuid) controls.detach();
object.parent?.remove(object);
}
- objectsGroup.update((value) => value);
+ pokeScene();
clearGraphs(); // H1: a cleared scene empties every graph document
}
diff --git a/src/lib/shaderGraph.js b/src/lib/shaderGraph.js
index 59c63e17..206e6866 100644
--- a/src/lib/shaderGraph.js
+++ b/src/lib/shaderGraph.js
@@ -14,7 +14,7 @@
// tracks the scene's light set, which ShaderFrog silently does not).
import { writable, get } from 'svelte/store';
-import { objectsGroup, globalScene, globalCamera, globalRenderer } from '../stores/sceneStore.js';
+import { objectsGroup, globalScene, globalCamera, globalRenderer, pokeScene } from '../stores/sceneStore.js';
import { compileShaderGraphToIR } from './shaderCompile.js';
import { compileShaderGraph, INJECT_SHADER_BACKEND, forgetShaderContext } from './shaderBackends.js';
import {
@@ -454,7 +454,7 @@ function applyMaterial(object, material) {
// Inspector's `material` derived and its shader-driven notice both read through
// `objectsGroup`, and without the poke they keep showing the pre-shader state. Safe
// from the reconcile's own subscriber because a compile always runs off a timer.
- objectsGroup.update((v) => v);
+ pokeScene();
}
// ---- texture uniforms ------------------------------------------------------------
@@ -519,7 +519,7 @@ export function detachFrom(object) {
if (mine && mine !== base && typeof mine.dispose === 'function') mine.dispose();
// and poke, for the same reason the install does — otherwise the Inspector keeps
// offering Detach for an object that is no longer shader-driven
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Is this object currently shader-driven? @param {string} uuid */
diff --git a/src/lib/splineTool.js b/src/lib/splineTool.js
index af4d3ff6..eea73f24 100644
--- a/src/lib/splineTool.js
+++ b/src/lib/splineTool.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore';
+import { globalScene, objectsGroup, selectedObject, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordObjectPresence, recordEntry, registerHistoryKind } from './history';
import { drawMode, drawTool, drawColor, drawSize } from './drawMode';
@@ -245,7 +245,7 @@ export function finishSpline() {
const mesh = createSplineMesh(spline, center);
if (!mesh) return null;
group.add(mesh);
- objectsGroup.update((value) => value);
+ pokeScene();
recordObjectPresence('create', mesh);
/** @type {any} */
const peer = get(peers);
@@ -291,7 +291,7 @@ export function applySplineEdit(uuid, spline) {
object.geometry = geometry;
object.userData.spline = data;
if (object.material && !Array.isArray(object.material)) object.material.color?.set?.(data.color);
- objectsGroup.update((value) => value);
+ pokeScene();
selectedObject.update((value) => value); // keep the Spline inspector rows live
fireRefresh(uuid);
return true;
diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js
index 85f9366b..0b3c37c9 100644
--- a/src/lib/terrainSculpt.js
+++ b/src/lib/terrainSculpt.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup, lockedObjects, globalScene, TControls, gizmoSuppressed } from '../stores/sceneStore';
+import { objectsGroup, lockedObjects, globalScene, TControls, gizmoSuppressed, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { commitMeshGeoSnapshot } from './faceEdit';
import { MAX_SNAPSHOT, previewReplicable } from './meshBudget';
@@ -428,7 +428,7 @@ export function strokeMove(uuid, x, z, dt = 0.016, y = 0) {
});
}
}
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Stroke end: flush the pending preview + ONE snapshot commit + undo entry. */
diff --git a/src/lib/transientObjects.js b/src/lib/transientObjects.js
index 1c7a7145..70958a21 100644
--- a/src/lib/transientObjects.js
+++ b/src/lib/transientObjects.js
@@ -28,7 +28,7 @@
// reach it, and any of those edges through objectActions/history would be a cycle.
import { get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers } from '../stores/appStore';
/** @param {any} object */
@@ -84,7 +84,7 @@ export function removeTransientObjects() {
if (!doomed.length) return [];
const uuids = doomed.map((object) => object.uuid);
uuids.forEach((uuid) => removeTransientObject(uuid, false));
- objectsGroup.update((value) => value);
+ pokeScene();
return uuids;
}
@@ -102,7 +102,7 @@ export function removeTransientObject(uuid, poke = true) {
const peer = get(peers);
object.parent?.remove(object);
if (peer) peer.send({ type: 'delete', uuid, peerId: peer.peer.id });
- if (poke) objectsGroup.update((value) => value);
+ if (poke) pokeScene();
return true;
}
diff --git a/src/lib/uvEditor.js b/src/lib/uvEditor.js
index 2866411e..fe93ed42 100644
--- a/src/lib/uvEditor.js
+++ b/src/lib/uvEditor.js
@@ -1,7 +1,7 @@
// @ts-ignore - no bundled three type declarations (project-wide)
import * as THREE from 'three';
import { writable, get } from 'svelte/store';
-import { objectsGroup } from '../stores/sceneStore';
+import { objectsGroup, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { recordEntry } from './history';
// UV3 painting commits through the EXISTING replicated texture path, so
@@ -385,7 +385,7 @@ export function transformUvCluster(object, indices, options = {}) {
uv.setXY(i, pivot.cu + du * cos - dv * sin, pivot.cv + du * sin + dv * cos);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -470,7 +470,7 @@ export function applyUvSnapshot(object, snapshot, options = {}) {
uv.setXY(s.i, u + du, v + dv);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -514,7 +514,7 @@ export function snapUvToPixels(object, indices, w, h) {
uv.setXY(i, Math.round(uv.getX(i) * w) / w, Math.round(uv.getY(i) * h) / h);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -593,7 +593,7 @@ export function fitUvToSquare(object, indices, margin = 0.02) {
uv.setXY(i, u, v);
}
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -910,7 +910,7 @@ export function moveUvCluster(object, indices, du, dv) {
}
for (const i of indices) uv.setXY(i, uv.getX(i) + du, uv.getY(i) + dv);
uv.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
return true;
}
@@ -1079,7 +1079,7 @@ function install(material, entry) {
material.needsUpdate = true;
}
entry.texture.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** A live texture identifies its seed by uuid+version, so a blank canvas (seed
@@ -1211,7 +1211,7 @@ export function paintMove(u, v, color, size, w) {
const point = pressured && typeof w === 'number' ? [u, v, Math.max(0, Math.min(1, Math.round(w * 1000) / 1000))] : [u, v];
paintStroke.points.push(point);
if (previous) strokeSegment(entry, previous, point, color, size, paintStroke.pmode);
- objectsGroup.update((value) => value);
+ pokeScene();
const now = performance.now();
if (now - lastPaintSend < PAINT_THROTTLE) return true;
lastPaintSend = now;
@@ -1295,7 +1295,7 @@ export function cancelPaintStroke() {
if (material.map === entry.texture) {
material.map = entry.previousMap ?? null;
material.needsUpdate = true;
- objectsGroup.update((v) => v);
+ pokeScene();
}
// the canvas now disagrees with the material — drop it so the next stroke
// re-seeds from whatever is actually on the model
@@ -1325,7 +1325,7 @@ export async function applyUvPaint(data) {
for (let i = 1; i < seg.length; i++)
strokeSegment(entry, seg[i - 1], seg[i], data.color ?? '#000000', data.size ?? 16, pmode);
liveUvStrokes.set(data.id, { ts: Date.now() });
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Receive side: a peer finished a stroke. @param {any} data */
diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js
index 6bbbabd3..d826b3c3 100644
--- a/src/lib/vrControls.js
+++ b/src/lib/vrControls.js
@@ -39,8 +39,7 @@ import {
vrToolMode,
vrTargetHz,
vrSleeveEnabled,
- peerHandStyle
-} from '../stores/sceneStore';
+ peerHandStyle, pokeScene } from '../stores/sceneStore';
import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu';
import { paletteColorAt, barValueAt } from './vrPalette';
import { recordMaterialChange, setMaterialParam } from './materialsHandler';
@@ -1295,7 +1294,7 @@ function nudgeTransform(object, kind, axis, sign) {
else object.scale[axis] = Math.max(0.01, object.scale[axis] + sign * step);
recordTransform({ uuid: object.uuid, before, after: transformStateOf(object) });
broadcastMove(object, true);
- objectsGroup.update((v) => v);
+ pokeScene();
}
/** Props panel actions ('props:' prefix in executeVRMenuAction) @param {string} action */
@@ -2440,7 +2439,7 @@ function updateGrab() {
}
grab.prevPos.copy(position);
grab.prevQuat.copy(quaternion);
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(object);
return;
}
@@ -2469,7 +2468,7 @@ function updateGrab() {
}
grab.prevPos.copy(position);
grab.prevQuat.copy(quaternion);
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(object);
}
@@ -2481,7 +2480,7 @@ function updateScaleGrab() {
factor = Math.max(Math.round(factorRaw / step) * step, step);
}
scaleGrab.object.scale.copy(scaleGrab.startScale).multiplyScalar(factor);
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(scaleGrab.object);
}
@@ -2511,7 +2510,7 @@ function spawnPrimitive(command) {
} else {
object.position.set(spawn.x, object.position.y, spawn.z);
}
- objectsGroup.update((value) => value);
+ pokeScene();
broadcastMove(object, true);
}
@@ -2759,7 +2758,7 @@ export function executeVRMenuAction(name) {
object.rotation.set(0, 0, 0);
recordTransform({ uuid: object.uuid, before, after: transformStateOf(object) });
broadcastMove(object, true);
- objectsGroup.update((v) => v);
+ pokeScene();
hapticPulse(0.3, 40);
return;
}
diff --git a/src/lib/vrSleeve.js b/src/lib/vrSleeve.js
index c9c404f6..ef2d87a7 100644
--- a/src/lib/vrSleeve.js
+++ b/src/lib/vrSleeve.js
@@ -8,8 +8,7 @@ import {
vrMenuHand,
vrMenuOpen,
vrSnapMode,
- vrSleeveEnabled
-} from '../stores/sceneStore';
+ vrSleeveEnabled, pokeScene } from '../stores/sceneStore';
import { peers, showToast } from '../stores/appStore';
import { parkEditOverlays } from './editOverlays';
import { snapEnabled, snapSettings, dropToSurface } from './snapping';
@@ -304,7 +303,7 @@ function applyPlacement(object, pose, scale) {
object.position.z = Math.round(object.position.z / step) * step;
}
object.updateMatrix();
- objectsGroup.update((v) => v);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
@@ -515,7 +514,7 @@ export function sleeveGripDrop(object, before) {
if (before?.pos) object.position.fromArray(before.pos);
if (before?.rot) object.rotation.set(before.rot[0], before.rot[1], before.rot[2]);
if (before?.scale) object.scale.fromArray(before.scale);
- objectsGroup.update((v) => v);
+ pokeScene();
/** @type {any} */
const peer = get(peers);
if (peer)
diff --git a/src/stores/appStore.js b/src/stores/appStore.js
index 32709890..c24c802d 100644
--- a/src/stores/appStore.js
+++ b/src/stores/appStore.js
@@ -724,8 +724,15 @@ export function dismissToastById(id) {
toastStore.update((list) => list.filter((entry) => !(entry && entry.id === id)));
}
+/** 26-B: the uuids of an inbound object batch still outstanding. Typed, because it is
+ * now WRITTEN as an array (the old code spliced in place and re-assigned), and
+ * `writable([])` alone infers `never[]`. */
+/** @type {import('svelte/store').Writable} */
export const loading = writable([]);
-export const loadingcount = writable([]);
+/** How many the batch announced. It was initialised to `[]` and only ever held a
+ * number; `[] > 0` is false and `[] - n` is `-n`, so 0 reads identically. */
+/** @type {import('svelte/store').Writable} */
+export const loadingcount = writable(0);
export const loadingFile = writable([]);
export const messages = writable([]);
diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js
index 3f571e1f..4ffec3b0 100644
--- a/src/stores/sceneStore.js
+++ b/src/stores/sceneStore.js
@@ -225,3 +225,100 @@ export const vrGrabStyle = writable(
);
/** handedness currently holding a grab ('left'|'right'|null) — gates that hand's stick */
export const vrGrabbedHand = writable(null);
+
+// ---------------------------------------------------------------------------
+// 26-B (hardening audit M6) — THE ONE PLACE A SCENE MUTATION IS ANNOUNCED.
+//
+// THE FINDING: `objectsGroup.update((v) => v)` sat at 117 call sites and eighteen
+// subscribers hang off it, several of which TRAVERSE the whole tree (the Controls
+// status-line walk, `refreshFilter`, `shadowDefaults.sweep`, the collider / camera /
+// light helper sweeps, the shader reconcile, `sceneAssets.schedule`). A 1,000-object
+// handshake therefore ran 1,000 pokes x ~8 traversals x 1,000 nodes — about 8M node
+// visits, synchronously, on the receive path — which IS the reported "the window
+// freezes while a big scene arrives". The same shape on `/clear` + restore and on any
+// bulk import.
+//
+// The mutation itself is unchanged: `pokeScene()` still ends in the same identity
+// update and every subscriber still sees the same value. What changes is HOW MANY
+// times: at most one flush per microtask normally, and at most one per frame while an
+// INGEST BATCH is open. N pokes inside one task become one.
+//
+// WHY A MICROTASK AND NOT rAF as the default: a microtask lands before the browser
+// paints and before any `await` continuation, so nothing that reads a subscriber's
+// output after yielding can observe a stale tree — and it still runs in a backgrounded
+// tab, which rAF does not. The batch mode uses a TIMER for the same reason: a hidden
+// tab throttles it to ~1Hz instead of stopping, so an ingest that starts and then loses
+// focus still converges.
+//
+// This lives in the STORE and not in a new leaf on purpose: all 37 files that poke
+// already import from here, so the seam costs no import edge anywhere — which matters,
+// because the pokers include peerHandler, flowRuntime, autosave and history, i.e. every
+// module inside the documented import cycles.
+// ---------------------------------------------------------------------------
+
+/** Bumped on every flush. A subscriber that caches an expensive traversal can key it
+ * off this instead of re-walking; it is also what the 26-A meter samples. LOCAL — it
+ * never replicates, saves or undoes. */
+export const sceneRevision = writable(0);
+
+/** One poke per this many ms while an ingest batch is open (~one frame at 60Hz). */
+const POKE_BATCH_MS = 16;
+
+let pokePending = false;
+/** @type {any} */
+let pokeTimer = null;
+let batchDepth = 0;
+
+function flushScenePoke() {
+ pokePending = false;
+ if (pokeTimer !== null) {
+ clearTimeout(pokeTimer);
+ pokeTimer = null;
+ }
+ sceneRevision.update((n) => n + 1);
+ objectsGroup.update((value) => value);
+}
+
+/**
+ * Announce that the THREE tree under `objectsGroup` changed. Coalesced — see above.
+ * Every former `objectsGroup.update((v) => v)` call site calls this instead.
+ */
+export function pokeScene() {
+ if (batchDepth > 0) {
+ // batch mode: a timer already armed means this poke is already covered
+ if (pokeTimer !== null) return;
+ pokePending = true;
+ pokeTimer = setTimeout(flushScenePoke, POKE_BATCH_MS);
+ return;
+ }
+ if (pokePending) return;
+ pokePending = true;
+ queueMicrotask(flushScenePoke);
+}
+
+/**
+ * Open an ingest batch: while one is open, pokes flush at most once per frame instead
+ * of once per microtask. Refcounted, so nested batches (an import inside a handshake)
+ * compose. ALWAYS pair with `endSceneBatch` in a `finally`.
+ */
+export function beginSceneBatch() {
+ batchDepth++;
+}
+
+/** Close an ingest batch and flush immediately, so the last object of a batch is on
+ * screen without waiting out a frame. */
+export function endSceneBatch() {
+ batchDepth = Math.max(0, batchDepth - 1);
+ if (batchDepth === 0 && pokePending) flushScenePoke();
+}
+
+/** Flush any pending poke right now. For the paths that must not yield first (a
+ * serializer about to read the tree) and for the suite. */
+export function flushScenePokes() {
+ if (pokePending) flushScenePoke();
+}
+
+/** Is an ingest batch open? Read by the suite and by the 26-A meter. */
+export function sceneBatchOpen() {
+ return batchDepth > 0;
+}
diff --git a/tests/e2e/ingest-gate.test.cjs b/tests/e2e/ingest-gate.test.cjs
new file mode 100644
index 00000000..693e9e6c
--- /dev/null
+++ b/tests/e2e/ingest-gate.test.cjs
@@ -0,0 +1,196 @@
+// 26-C — Stage 2: the ingest gate (roadmap 26 section 4).
+//
+// A scene arriving over the wire announces itself FIRST (`{type:'loading', count,
+// uuids}`) and only then sends the objects, so there is exactly one moment where the
+// size is known and nothing has been applied yet. Past that moment a 4,000-object scene
+// is simply happening to you — which is what the freeze reports describe.
+//
+// What is asserted, in the order it matters:
+// 1. the verdict, which is pure and decides everything downstream;
+// 2. an over-budget announcement PARKS the objects instead of applying them, and the
+// progress bar does not quietly give up while the question is open;
+// 3. each of the three answers does what it says — including "the first N", which is
+// the only one with arithmetic in it;
+// 4. a scene FILE asks too, with two ways out rather than three, and Cancel really
+// leaves the scene alone.
+const h = require('./helpers.cjs');
+
+const objectCount = (page) =>
+ page.evaluate(() => {
+ let n = 0;
+ const g = window.__stores.objectsGroup;
+ let group;
+ const s = g.subscribe((/** @type {any} */ v) => (group = v));
+ s();
+ group?.traverse?.((/** @type {any} */ o) => { if (o !== group) n++; });
+ return n;
+ });
+
+/** N object messages, exactly as the wire delivers them, without draining them. */
+const feed = (page, n, prefix) =>
+ page.evaluate(
+ ({ n, prefix }) => {
+ const { THREE, commandsHandler } = window.__stores;
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const mat = new THREE.MeshStandardMaterial();
+ const uuids = [];
+ for (let i = 0; i < n; i++) {
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.name = prefix + i;
+ uuids.push(mesh.uuid);
+ commandsHandler.createObject({ element: mesh.toJSON() }, null);
+ }
+ return uuids;
+ },
+ { n, prefix }
+ );
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. the verdict ------------------------------------------------------
+ const verdicts = await A.page.evaluate(() => {
+ const { ingestVerdict } = window.__stores.sceneBudget;
+ return {
+ // desktop objects: green <= 1000, amber <= 3000, red above
+ small: ingestVerdict(0, 50, 'desktop'),
+ amber: ingestVerdict(0, 2000, 'desktop'),
+ red: ingestVerdict(0, 4200, 'desktop'),
+ // the CURRENT scene counts: 2,900 here plus 500 more crosses it
+ topUp: ingestVerdict(2900, 500, 'desktop'),
+ // …and a headset crosses far sooner on the same numbers
+ vr: ingestVerdict(0, 2000, 'vr'),
+ empty: ingestVerdict(0, 0, 'desktop'),
+ alreadyOver: ingestVerdict(5000, 100, 'desktop'),
+ negative: ingestVerdict(-5, -5, 'desktop')
+ };
+ });
+ h.check(verdicts.small.gate === false && verdicts.amber.gate === false, 'green and AMBER do not ask — amber warns, red asks');
+ h.check(verdicts.red.gate === true && verdicts.red.allowed === 3000, `red asks, and offers the first ${verdicts.red.allowed}`);
+ h.check(
+ verdicts.topUp.gate === true && verdicts.topUp.allowed === 100,
+ `what is ALREADY here counts: 2900 + 500 asks, and only ${verdicts.topUp.allowed} fit`
+ );
+ h.check(verdicts.vr.gate === true, 'the same 2,000 objects ask on a headset and not on a desktop');
+ h.check(verdicts.empty.gate === false, 'an empty arrival never asks');
+ h.check(verdicts.alreadyOver.allowed === 0, 'a scene already past the budget offers zero, not a negative number');
+ h.check(verdicts.negative.total === 0 && verdicts.negative.gate === false, 'nonsense input answers 0, never NaN');
+
+ // ---- 2. an over-budget arrival PARKS ------------------------------------
+ const before = await objectCount(A.page);
+ const armed = await A.page.evaluate((before) => {
+ const { commandsHandler } = window.__stores;
+ // announce more than the desktop budget can take
+ commandsHandler.createLoader(4200, ['a', 'b', 'c'], 'peer-sending');
+ return { open: commandsHandler.ingestGateOpen(), before };
+ }, before);
+ h.check(armed.open, 'an over-budget announcement opens the gate');
+ await feed(A.page, 30, 'parked-');
+ await A.page.waitForTimeout(700);
+ const parked = await A.page.evaluate(() => ({
+ backlog: window.__stores.commandsHandler.ingestBacklog(),
+ gate: (() => { let v; const s = window.__stores.commandsHandler.ingestGate.subscribe((/** @type {any} */ x) => (v = x)); s(); return v; })()
+ }));
+ h.check(parked.backlog >= 29, `the objects are PARKED, not applied (${parked.backlog} in the queue)`);
+ h.check((await objectCount(A.page)) === before, 'the scene is untouched while the question is open');
+ h.check(parked.gate?.count === 4200 && parked.gate?.limit === 3000, `the card is told the real numbers (${parked.gate?.count} of ${parked.gate?.limit})`);
+ const card = await A.page.locator('.tp-toast', { hasText: 'This scene has 4200 objects' });
+ h.check((await card.count()) > 0, 'the fork is on screen');
+ h.check(
+ (await A.page.getByRole('button', { name: /Load the first/ }).count()) > 0,
+ '…offering "Load the first N" beside Load all and Cancel'
+ );
+
+ // ---- 3a. Cancel --------------------------------------------------------
+ await A.page.getByRole('button', { name: 'Cancel', exact: true }).first().click();
+ await A.page.waitForTimeout(500);
+ const cancelled = await A.page.evaluate(() => ({
+ backlog: window.__stores.commandsHandler.ingestBacklog(),
+ open: window.__stores.commandsHandler.ingestGateOpen(),
+ loading: (() => { let v; const s = window.__stores.loading.subscribe((/** @type {any} */ x) => (v = x)); s(); return v.length; })()
+ }));
+ h.check(cancelled.backlog === 0 && !cancelled.open, 'Cancel drops the parked queue and closes the gate');
+ h.check((await objectCount(A.page)) === before, '…and not one of them reached the scene');
+ h.check(cancelled.loading === 0, '…and the progress bar is cleared rather than left stuck');
+
+ // ---- 3b. "Load the first N" --------------------------------------------
+ const capBase = await objectCount(A.page);
+ await A.page.evaluate(() => window.__stores.commandsHandler.createLoader(4200, [], 'peer-sending'));
+ await feed(A.page, 40, 'capped-');
+ await A.page.waitForTimeout(400);
+ // force a small allowance so the arithmetic is observable in a headless scene
+ await A.page.evaluate(() => {
+ window.__stores.commandsHandler.ingestGate.update((/** @type {any} */ g) => ({ ...g, allowed: 12 }));
+ });
+ await A.page.waitForTimeout(200);
+ await A.page.getByRole('button', { name: /Load the first 12/ }).first().click();
+ await h.eventually(
+ () => A.page.evaluate(() => window.__stores.commandsHandler.ingestBacklog()),
+ (n) => n === 0,
+ 'the queue drains after the answer'
+ );
+ const capped = (await objectCount(A.page)) - capBase;
+ h.check(capped === 12, `exactly the allowance was applied and the rest dropped (${capped} of 40)`);
+ h.check(
+ (await A.page.evaluate(() => { let v; const s = window.__stores.loading.subscribe((/** @type {any} */ x) => (v = x)); s(); return v.length; })) === 0,
+ 'the dropped objects count as arrived, so the bar does not wait out the stall'
+ );
+
+ // ---- 3c. Load all -------------------------------------------------------
+ await A.page.evaluate(() => {
+ const { objectsGroup, pokeScene } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ group.clear();
+ pokeScene();
+ });
+ await A.page.waitForTimeout(400);
+ const allBase = await objectCount(A.page);
+ await A.page.evaluate(() => window.__stores.commandsHandler.createLoader(4200, [], 'peer-sending'));
+ await feed(A.page, 25, 'all-');
+ await A.page.waitForTimeout(300);
+ await A.page.getByRole('button', { name: 'Load all', exact: true }).first().click();
+ await h.eventually(
+ () => objectCount(A.page),
+ (n) => n - allBase === 25,
+ 'Load all applies every parked object'
+ );
+ h.check(!(await A.page.evaluate(() => window.__stores.commandsHandler.ingestGateOpen())), 'and the gate closes behind it');
+
+ // ---- 4. a scene FILE asks too ------------------------------------------
+ const payload = await A.page.evaluate(() => {
+ const { THREE, sessions } = window.__stores;
+ const objects = [];
+ for (let i = 0; i < 3500; i++) {
+ const m = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshBasicMaterial());
+ m.name = 'file-' + i;
+ objects.push({ object: { uuid: m.uuid, name: m.name, type: 'Mesh', children: [] } });
+ }
+ return { count: sessions.countPayloadObjects({ objects }), nested: sessions.countPayloadObjects({ objects: [{ object: { children: [{ children: [{}] }] } }] }) };
+ });
+ h.check(payload.count === 3500, `a payload's objects are counted (${payload.count})`);
+ h.check(payload.nested === 3, `…including nested children, the unit the budget is stated in (${payload.nested})`);
+
+ const sceneBefore = await objectCount(A.page);
+ await A.page.evaluate(() => {
+ const objects = [];
+ for (let i = 0; i < 3500; i++)
+ objects.push({ object: { uuid: 'file-uuid-' + i, name: 'file-' + i, type: 'Mesh', children: [] } });
+ // requestLoadPayload is what a file open and the Sessions manager's Load both
+ // reach; the travel node and a peer proposal deliberately do NOT
+ window.__tpLoad = window.__stores.sessions.requestLoadPayload({ name: 'Huge', objects });
+ });
+ await A.page.waitForSelector('dialog', { timeout: 8000 });
+ const ask = await A.page.evaluate(() => document.querySelector('dialog')?.textContent ?? '');
+ h.check(/3500 objects/.test(ask), `the ask names the count (${ask.slice(0, 90)})`);
+ h.check(/3000 recommended/.test(ask), '…against the budget for this device');
+ h.check(!/first \d/.test(ask), 'a FILE gets two ways out, not three — half a document is not a scene');
+ await A.page.getByRole('button', { name: /Cancel/i }).first().click();
+ const answered = await A.page.evaluate(() => window.__tpLoad);
+ h.check(answered === false, 'Cancel refuses the load');
+ await A.page.waitForTimeout(400);
+ h.check((await objectCount(A.page)) === sceneBefore, '…and the current scene is untouched — it was not cleared first');
+
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/overload-guard.test.cjs b/tests/e2e/overload-guard.test.cjs
new file mode 100644
index 00000000..dd24d41f
--- /dev/null
+++ b/tests/e2e/overload-guard.test.cjs
@@ -0,0 +1,301 @@
+// 26-G — Stages 3 and 4: the runtime auto-stops and the paused overlay
+// (roadmap 26 section 4).
+//
+// Stages 0-2 stop the app freezing on the way IN. This is what happens once a heavy
+// scene is already here and the device cannot keep up.
+//
+// What is asserted, in the order it matters:
+// 1. the streak rule — consecutive, once per streak — because a single hitch must never
+// stop anybody's simulation;
+// 2. a simulation too slow to keep up is stopped ONCE, says so, and offers Resume;
+// 3. a frozen render loop pauses drawing, but a backgrounded tab never does;
+// 4. Reduce sets the newest objects aside ON THIS DEVICE ONLY, and — the hazard this
+// design exists for — they STAY in the autosave export;
+// 5. the restore prompt says how the snapshot compares with this device's budget.
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. the streak rule --------------------------------------------------
+ const streak = await A.page.evaluate(() => {
+ const { createStreakWatch } = window.__stores.overloadGuard;
+ const w = createStreakWatch({ overMs: 24, count: 5 });
+ const fires = [];
+ // four slow, one fast: the streak BREAKS and nothing fires
+ for (const ms of [30, 30, 30, 30, 10]) fires.push(w.note(ms));
+ const brokenStreak = fires.every((f) => !f);
+ // five slow in a row fires exactly once, on the fifth
+ const run = [30, 30, 30, 30, 30, 30, 30].map((ms) => w.note(ms));
+ const firedOnFifth = run[4] === true && run.filter(Boolean).length === 1;
+ // a sample exactly ON the threshold is not over it
+ const w2 = createStreakWatch({ overMs: 24, count: 2 });
+ const onThreshold = [24, 24, 24].map((ms) => w2.note(ms)).some(Boolean);
+ // after a fast sample the watch re-arms and can fire again
+ w.note(10);
+ const again = [30, 30, 30, 30, 30].map((ms) => w.note(ms)).filter(Boolean).length === 1;
+ return { brokenStreak, firedOnFifth, onThreshold, again };
+ });
+ h.check(streak.brokenStreak, 'a streak broken by one fast sample fires nothing — a single hitch is not a heavy scene');
+ h.check(streak.firedOnFifth, 'N slow samples in a row fire exactly once, and not again while the streak continues');
+ h.check(!streak.onThreshold, 'a sample exactly on the threshold is not over it');
+ h.check(streak.again, 'a fast sample re-arms the watch');
+
+ // ---- 2. physics: a simulation too slow to keep up -------------------------
+ await A.page.evaluate(async () => {
+ const { commandsHandler } = window.__stores;
+ commandsHandler.sceneCommand('/create box');
+ await new Promise((r) => setTimeout(r, 400));
+ });
+ const started = await A.page.evaluate(async () => {
+ const { physics } = window.__stores;
+ // prewarm rapier (lazy wasm), then run
+ await physics.toggleSimulation();
+ await new Promise((r) => setTimeout(r, 1500));
+ let sim; const s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s();
+ return sim;
+ });
+ h.check(started === true, 'the simulation is running (premise)');
+ await A.page.evaluate(() => window.__stores.toastStore.set([]));
+ const slow = await A.page.evaluate(async () => {
+ const { physics, overloadGuard } = window.__stores;
+ // one short of the streak must NOT stop it
+ const early = physics.noteSlowStepsForTest(overloadGuard.PHYSICS_SLOW_STEPS - 1, 40);
+ let sim; let s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s();
+ const stillRunning = sim;
+ // a fast step breaks that streak, then a full one stops the run
+ physics.noteSlowStepsForTest(1, 5);
+ const fired = physics.noteSlowStepsForTest(overloadGuard.PHYSICS_SLOW_STEPS, 40);
+ s = physics.simulating.subscribe((/** @type {any} */ v) => (sim = v)); s();
+ return { early, stillRunning, fired, stopped: sim === false };
+ });
+ h.check(!slow.early && slow.stillRunning, 'one step short of the streak leaves the simulation running');
+ h.check(slow.fired && slow.stopped, 'a full streak of slow steps STOPS the simulation');
+ await h.eventually(
+ () => A.page.locator('.tp-toast', { hasText: 'too slow for this device' }).count(),
+ (n) => n > 0,
+ '…and says so, naming the reason'
+ );
+ const resume = A.page.locator('.tp-toast', { hasText: 'too slow for this device' }).getByRole('button', { name: 'Resume' });
+ h.check((await resume.count()) > 0, '…with a Resume button, so the stop is never a dead end');
+ await resume.first().click();
+ await h.eventually(
+ () => A.page.evaluate(() => { let v; const s = window.__stores.physics.simulating.subscribe((/** @type {any} */ x) => (v = x)); s(); return v; }),
+ (v) => v === true,
+ 'Resume starts the simulation again'
+ );
+ await A.page.evaluate(() => window.__stores.physics.toggleSimulation());
+
+ // ---- 3. the render freeze -------------------------------------------------
+ h.check((await A.page.locator('#render-paused').count()) === 0, 'the paused overlay starts hidden (premise)');
+
+ // THE REGRESSION THIS RULE WAS REWRITTEN FOR: a SLOW MACHINE drawing a LIGHT scene. A
+ // software-rendered page lives at ~2.5fps — 400ms frames, forever — and the first
+ // version of this trigger paused it, covering every non-GPU e2e suite with the overlay
+ // ("#render-paused intercepts pointer events", 23 times in one battery). Pausing a
+ // light scene helps nothing: there is nothing heavy to set aside.
+ const light = await A.page.evaluate(() => {
+ const g = window.__stores.overloadGuard;
+ const b = window.__stores.sceneBudget;
+ g.resumeRendering();
+ const realNow = Date.now;
+ Date.now = () => realNow() + 10000;
+ b.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 12, triangles: 144, calls: 12 });
+ const heavy = g.sceneIsHeavy();
+ let paused = false;
+ for (let i = 0; i < 40; i++) paused = g.noteFrameForFreeze(400) || paused;
+ Date.now = realNow;
+ return { heavy, paused };
+ });
+ h.check(!light.heavy, 'twelve boxes are not a heavy scene (premise)');
+ h.check(!light.paused, 'forty 400ms frames on a LIGHT scene never pause — a slow machine is not an overloaded scene');
+
+ const freeze = await A.page.evaluate(() => {
+ const g = window.__stores.overloadGuard;
+ // a HEAVY reading: past the desktop object budget, so pausing could actually help
+ window.__stores.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 4200, triangles: 900000, calls: 2600 });
+ g.resumeRendering();
+ // resumeRendering starts a grace window; step past it for the test
+ const realNow = Date.now;
+ Date.now = () => realNow() + 10000;
+ const short = [];
+ for (let i = 0; i < g.FREEZE_FRAMES - 1; i++) short.push(g.noteFrameForFreeze(400));
+ const notYet = !short.some(Boolean);
+ g.noteFrameForFreeze(16); // breaks it
+ let paused = false;
+ for (let i = 0; i < g.FREEZE_FRAMES; i++) paused = g.noteFrameForFreeze(400) || paused;
+ Date.now = realNow;
+ let state; const s = g.renderPaused.subscribe((/** @type {any} */ v) => (state = v)); s();
+ return { notYet, paused, reason: state?.reason };
+ });
+ h.check(freeze.notYet, 'nine frozen frames do not pause — the rule is ten in a row');
+ h.check(freeze.paused && freeze.reason === 'frozen', `ten frames over 250ms PAUSE drawing (reason: ${freeze.reason})`);
+ await A.page.waitForSelector('#render-paused', { timeout: 5000 });
+ h.check(true, 'the "Rendering paused" overlay appears');
+ const card = await A.page.locator('#render-paused').textContent();
+ h.check(/Nothing is lost/.test(String(card)) && /autosave keeps running/.test(String(card)), 'it says nothing is lost and autosave carries on');
+
+ // the render loop really stops: renderer.info.render.frame stops advancing
+ const frozenFrames = await A.page.evaluate(async () => {
+ let renderer; const s = window.__stores.globalRenderer.subscribe((/** @type {any} */ r) => (renderer = r)); s();
+ const a = renderer.info.render.frame;
+ await new Promise((r) => setTimeout(r, 600));
+ return renderer.info.render.frame - a;
+ });
+ h.check(frozenFrames === 0, `no frame is drawn while paused (${frozenFrames} frames in 600ms)`);
+
+ await A.page.locator('#render-paused-resume').click();
+ await A.page.waitForTimeout(700);
+ const liveFrames = await A.page.evaluate(async () => {
+ let renderer; const s = window.__stores.globalRenderer.subscribe((/** @type {any} */ r) => (renderer = r)); s();
+ const a = renderer.info.render.frame;
+ await new Promise((r) => setTimeout(r, 600));
+ return renderer.info.render.frame - a;
+ });
+ h.check((await A.page.locator('#render-paused').count()) === 0, 'Resume closes the overlay');
+ h.check(liveFrames > 5, `…and drawing starts again (${liveFrames} frames in 600ms)`);
+
+ // a BACKGROUNDED tab throttles rAF to ~1Hz on purpose — that must never pause
+ const hidden = await A.page.evaluate(() => {
+ const g = window.__stores.overloadGuard;
+ // HEAVY, or this check passes vacuously — a light scene never pauses anyway
+ window.__stores.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 4200, triangles: 900000, calls: 2600 });
+ g.resumeRendering();
+ const realNow = Date.now;
+ Date.now = () => realNow() + 10000;
+ const desc = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState');
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
+ let paused = false;
+ for (let i = 0; i < 30; i++) paused = g.noteFrameForFreeze(1000) || paused;
+ // …and the FIRST frame back spans the whole absence
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
+ const firstBack = g.noteFrameForFreeze(60000);
+ delete document.visibilityState;
+ if (desc) Object.defineProperty(Document.prototype, 'visibilityState', desc);
+ Date.now = realNow;
+ return { paused, firstBack };
+ });
+ h.check(!hidden.paused, 'thirty 1-second frames in a HIDDEN tab never pause — that is the browser throttling, not a heavy scene');
+ h.check(!hidden.firstBack, 'the first frame after coming back is ignored — its delta is the whole absence');
+
+ // ---- 4. Reduce: newest set aside, LOCALLY, and still in the autosave ------
+ const reduce = await A.page.evaluate(async () => {
+ const { THREE, objectsGroup, pokeScene, overloadGuard, autosave } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ group.clear();
+ // 3,200 top-level objects: 200 past the desktop object budget (3,000). EMPTY GROUPS,
+ // not meshes: the budget counts tree NODES, so a Group counts exactly like a mesh,
+ // and it costs this memory-starved box no geometry and no GPU upload (a run with
+ // 3,200 real meshes died here with "Resulting promise was garbage collected").
+ for (let i = 0; i < 3200; i++) {
+ const m = new THREE.Group();
+ m.name = 'r' + i;
+ group.add(m);
+ }
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 300));
+ const n = overloadGuard.reduceScene('desktop');
+ const newest = group.children[group.children.length - 1];
+ const oldest = group.children[0];
+ // the camera draws layer 0; a reduced object is on the reduced layer
+ const cam = new THREE.PerspectiveCamera();
+ return {
+ n,
+ newestReduced: overloadGuard.isReduced(newest.uuid) && !cam.layers.test(newest.layers),
+ oldestDrawn: !overloadGuard.isReduced(oldest.uuid) && cam.layers.test(oldest.layers),
+ stillVisibleFlag: newest.visible === true,
+ stillInScene: group.children.length,
+ hasExport: typeof autosave.exportScene === 'function' || typeof autosave.snapshotScene === 'function'
+ };
+ });
+ h.check(reduce.n === 200, `Reduce sets aside exactly the overflow, newest first (${reduce.n})`);
+ h.check(reduce.newestReduced, 'the NEWEST object is set aside and no longer drawn');
+ h.check(reduce.oldestDrawn, 'the oldest is untouched');
+ h.check(reduce.stillInScene === 3200, `nothing was removed from the scene (${reduce.stillInScene})`);
+ h.check(
+ reduce.stillVisibleFlag,
+ '`visible` is NOT touched — GLTFExporter drops invisible objects from autosave, and this must not'
+ );
+
+ // the hazard, proven: a GLTF export (autosave's serializer, no options) still carries
+ // a reduced object, where a `visible = false` hide would have dropped it
+ const exported = await A.page.evaluate(async () => {
+ const { THREE, GLTFExporterModule, objectsGroup, overloadGuard } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ const probe = new THREE.Group();
+ const reducedOne = group.children[group.children.length - 1].clone();
+ reducedOne.name = 'probe-reduced';
+ reducedOne.layers.set(overloadGuard.REDUCED_LAYER);
+ const hiddenOne = group.children[0].clone();
+ hiddenOne.name = 'probe-hidden';
+ hiddenOne.visible = false;
+ probe.add(reducedOne, hiddenOne);
+ const json = await new Promise((resolve) =>
+ new GLTFExporterModule.GLTFExporter().parse(probe, resolve, () => resolve(null))
+ );
+ const names = (json?.nodes ?? []).map((/** @type {any} */ n) => n.name);
+ return { reduced: names.includes('probe-reduced'), hidden: names.includes('probe-hidden') };
+ });
+ h.check(exported.reduced, 'a REDUCED object is still in a default GLTF export — autosave keeps it');
+ h.check(!exported.hidden, '…while a `visible = false` one is dropped: the counterfactual, measured in the same export');
+
+ const restored = await A.page.evaluate(() => {
+ const { objectsGroup, overloadGuard } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ const n = overloadGuard.restoreReduced();
+ const newest = group.children[group.children.length - 1];
+ return { n, back: !overloadGuard.isReduced(newest.uuid) && newest.layers.mask === 1 };
+ });
+ h.check(restored.n === 200 && restored.back, `restoring puts every set-aside object back on its original layer (${restored.n})`);
+
+ // the overlay's own Reduce button, end to end
+ await A.page.evaluate(() => {
+ const g = window.__stores.overloadGuard;
+ g.pauseRendering('frozen');
+ window.__stores.toastStore.set([]);
+ });
+ await A.page.waitForSelector('#render-paused-reduce', { timeout: 5000 });
+ await A.page.locator('#render-paused-reduce').click();
+ await A.page.waitForTimeout(400);
+ h.check((await A.page.locator('#render-paused').count()) === 0, 'Reduce resumes drawing');
+ h.check(
+ (await A.page.locator('.tp-toast', { hasText: 'nothing was deleted' }).count()) > 0,
+ '…and says what it did, and that nothing was deleted'
+ );
+ h.check(
+ (await A.page.locator('.tp-toast').getByRole('button', { name: 'Show them again' }).count()) > 0,
+ '…with a way to undo it'
+ );
+ // leave a LIGHT scene behind: 3,200 objects is heavy by definition, and the real frame
+ // loop would be entitled to pause over it on a saturated box while section 5 runs
+ await A.page.evaluate(() => {
+ const { objectsGroup, pokeScene, overloadGuard } = window.__stores;
+ overloadGuard.restoreReduced();
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ group.clear();
+ pokeScene();
+ overloadGuard.resumeRendering();
+ });
+
+ // ---- 5. the restore prompt names the budget ------------------------------
+ await A.page.evaluate(() => {
+ window.__stores.toastStore.set([]);
+ window.__stores.autosave.restoreAvailable.set({ objects: 4200, ts: Date.now() });
+ });
+ await h.eventually(
+ () => A.page.locator('.tp-toast', { hasText: 'Restore previous session?' }).textContent().catch(() => ''),
+ (t) => /4200 objects/.test(String(t)) && /above the 3000 recommended/.test(String(t)),
+ 'the restore prompt says the snapshot is above this device\'s budget'
+ );
+ await A.page.evaluate(() => window.__stores.autosave.restoreAvailable.set({ objects: 40, ts: Date.now() }));
+ await h.eventually(
+ () => A.page.locator('.tp-toast', { hasText: 'Restore previous session?' }).textContent().catch(() => ''),
+ (t) => /40 objects/.test(String(t)) && !/recommended/.test(String(t)),
+ '…and says nothing about the budget for a small one'
+ );
+ await A.page.evaluate(() => window.__stores.autosave.restoreAvailable.set(null));
+
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/scene-budget.test.cjs b/tests/e2e/scene-budget.test.cjs
new file mode 100644
index 00000000..2e0f7a91
--- /dev/null
+++ b/tests/e2e/scene-budget.test.cjs
@@ -0,0 +1,188 @@
+// 26-A — the scene budget, the meter and the desktop Statistics panel
+// (roadmap 26 sections 2 and 3).
+//
+// THE FINDING: there was no scene-level budget at all, and `renderer.info` had exactly
+// ONE reader in the whole app — the VR stats plate. On a desktop, where every heavy
+// scene is built, there was no way to see draw calls, triangles, GPU object counts or a
+// single frame-time number, and a diagnostics bundle carried none of them.
+//
+// What is asserted, in the order it matters:
+// 1. the tier arithmetic, which is the part that has to be right and needs no browser;
+// 2. the sampler actually reads the live renderer and the live scene;
+// 3. the meter's dot changes tier when the scene crosses a budget — driven by REAL
+// objects, not by writing the store;
+// 4. the panel opens from the burger menu (the real entry point) and renders the rows;
+// 5. the wire counters count per type, and the numbers reach the diagnostics bundle.
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ // GPU args: section 2 asserts a frame-time percentile over real frames, and a
+ // SwiftShader page runs at ~2.5fps where "p95" is noise (the e2e skill's rule).
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. the pure part ---------------------------------------------------
+ const pure = await A.page.evaluate(() => {
+ const b = window.__stores.sceneBudget;
+ return {
+ budgets: b.BUDGETS.length,
+ // objects: desktop 1000 / 3000, vr 500 / 1500
+ green: b.tierOf('objects', 900, 'desktop'),
+ amber: b.tierOf('objects', 2000, 'desktop'),
+ red: b.tierOf('objects', 4000, 'desktop'),
+ // the SAME count is judged harder on a headset — that is the whole reason
+ // there are two columns
+ vrAmber: b.tierOf('objects', 900, 'vr'),
+ vrRed: b.tierOf('objects', 2000, 'vr'),
+ boundaryGreen: b.tierOf('objects', 1000, 'desktop'),
+ boundaryAmber: b.tierOf('objects', 3000, 'desktop'),
+ unknownKey: b.tierOf('not-a-budget', 5, 'desktop'),
+ unknownValue: b.tierOf('objects', null, 'desktop'),
+ nan: b.tierOf('objects', NaN, 'desktop'),
+ // the meter takes the WORST, and an unmeasured reading never darkens it
+ worstOfGreen: b.worstTier({ objects: 10, triangles: 10, calls: 1 }, 'desktop'),
+ worstOfMixed: b.worstTier({ objects: 10, triangles: 10, calls: 5000 }, 'desktop'),
+ worstOfNothing: b.worstTier({}, 'desktop'),
+ rows: b.budgetRows({ objects: 4000 }, 'desktop').find((r) => r.key === 'objects')
+ };
+ });
+ h.check(pure.budgets >= 7, `the budget table is data (${pure.budgets} rows)`);
+ h.check(pure.green === 'green' && pure.amber === 'amber' && pure.red === 'red', 'the three tiers read as written');
+ h.check(pure.vrAmber === 'amber' && pure.vrRed === 'red', '…and the VR column judges the same count harder');
+ h.check(
+ pure.boundaryGreen === 'green' && pure.boundaryAmber === 'amber',
+ 'a reading EXACTLY on a ceiling stays in the lower tier'
+ );
+ h.check(
+ pure.unknownKey === 'unknown' && pure.unknownValue === 'unknown' && pure.nan === 'unknown',
+ 'an unknown budget, a missing reading and a NaN are all "unknown", never a tier'
+ );
+ h.check(pure.worstOfGreen === 'green' && pure.worstOfMixed === 'red', 'the meter takes the worst reading');
+ h.check(pure.worstOfNothing === 'unknown', '…and nothing measured is not a warning');
+ h.check(pure.rows?.tier === 'red' && pure.rows?.green === 1000, 'budgetRows carries the reading, the ceilings and the tier');
+
+ // ---- 2. the sampler reads the LIVE renderer and scene --------------------
+ const live = await A.page.evaluate(async () => {
+ const { sceneBudget, THREE, objectsGroup, pokeScene } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ group.clear();
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const mat = new THREE.MeshStandardMaterial();
+ for (let i = 0; i < 24; i++) group.add(new THREE.Mesh(geo, mat));
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 900));
+ return sceneBudget.sampleSceneMetrics();
+ });
+ h.check(live.objects === 24, `the sampler walks the live scene (${live.objects} objects)`);
+ h.check(live.meshes === 24, `…and counts meshes (${live.meshes})`);
+ h.check(
+ typeof live.triangles === 'number' && live.triangles > 0,
+ `renderer.info reaches the desktop at last (${live.triangles} triangles, ${live.calls} draw calls)`
+ );
+ h.check(typeof live.geometries === 'number', `GPU object counts are read (${live.geometries} geometries, ${live.textures} textures)`);
+ h.check(
+ live.frameSamples > 10 && live.frameP95 != null && live.frameP95 >= live.frameP50,
+ `frame percentiles come from real frames (${live.frameSamples} samples, p50 ${live.frameP50}, p95 ${live.frameP95})`
+ );
+ h.check(live.profile === 'desktop', `a desktop context is judged against the desktop budget (${live.profile})`);
+ h.check(typeof live.ingestBacklog === 'number', 'a registered source (the ingest backlog) reaches the sample');
+
+ // ---- 3. the meter's dot moves with the scene ----------------------------
+ await A.page.waitForTimeout(700);
+ const greenDot = await A.page.getAttribute('#object-budget-dot', 'data-tier');
+ h.check(greenDot === 'green', `24 objects reads green in the status line (${greenDot})`);
+
+ await A.page.evaluate(async () => {
+ const { THREE, objectsGroup, pokeScene } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const mat = new THREE.MeshStandardMaterial();
+ // past the desktop AMBER ceiling for objects (3000)
+ for (let i = 0; i < 3200; i++) group.add(new THREE.Mesh(geo, mat));
+ pokeScene();
+ });
+ await h.eventually(
+ () => A.page.getAttribute('#object-budget-dot', 'data-tier'),
+ (t) => t === 'red',
+ 'the status-line dot goes RED when the object budget is exceeded'
+ );
+ const title = await A.page.getAttribute('#object-count', 'title');
+ h.check(
+ /over on/.test(String(title)) && /object/i.test(String(title)),
+ `…and the tooltip names WHAT is over budget (${title})`
+ );
+
+ // ---- 4. the panel, through its real entry point --------------------------
+ await A.page.evaluate(() => {
+ const { THREE, objectsGroup, pokeScene } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ group.clear();
+ group.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()));
+ pokeScene();
+ });
+ h.check((await A.page.locator('#stats-window').count()) === 0, 'the Statistics window starts closed (premise)');
+ // 94: the logo IS the menu button
+ await A.page.locator('#logo-menu').click();
+ await A.page.waitForTimeout(400);
+ const menuRow = A.page.locator('#open-stats');
+ if ((await menuRow.count()) === 0) {
+ // the burger opener differs across layouts; fall back to the store, and SAY SO
+ h.check(false, 'the burger menu offers a Statistics row (#open-stats not reachable — check the opener)');
+ await A.page.evaluate(() => window.__stores.sceneBudget.statsOpen.set(true));
+ } else {
+ await menuRow.click();
+ h.check(true, 'the burger menu offers a Statistics row and it opens the window');
+ }
+ await A.page.waitForSelector('#stats-window', { timeout: 8000 });
+ h.check(true, 'the Statistics window is open');
+ const panel = await A.page.evaluate(() => {
+ const rows = [...document.querySelectorAll('#stats-budgets tr[data-budget]')];
+ return {
+ rows: rows.length,
+ keys: rows.map((r) => r.getAttribute('data-budget')),
+ tiers: rows.map((r) => r.getAttribute('data-tier')),
+ frame: document.querySelector('#stats-frame')?.textContent ?? '',
+ overall: document.querySelector('#stats-overall')?.getAttribute('data-tier')
+ };
+ });
+ h.check(panel.rows >= 7, `every budget gets a row (${panel.rows})`);
+ h.check(panel.keys.includes('triangles') && panel.keys.includes('calls'), 'including the two renderer.info readings the desktop never had');
+ h.check(panel.tiers.every((t) => ['green', 'amber', 'red', 'unknown'].includes(String(t))), 'each row carries a tier');
+ h.check(/p50/.test(panel.frame) && /ms/.test(panel.frame), 'the frame block shows the percentiles');
+ h.check(['green', 'amber', 'red', 'unknown'].includes(String(panel.overall)), `the header carries the overall tier (${panel.overall})`);
+
+ // ---- 5. wire counters + the diagnostics bundle ---------------------------
+ const wire = await A.page.evaluate(() => {
+ const b = window.__stores.sceneBudget;
+ b.resetWireStats();
+ for (let i = 0; i < 40; i++) b.noteWire('out', { type: 'camera', pos: [i, 0, 0] });
+ for (let i = 0; i < 5; i++) b.noteWire('in', { type: 'move', uuid: 'x' });
+ b.noteWire('in', null); // a malformed message still counts, as 'unknown'
+ const stats = b.wireStats();
+ return {
+ busiest: stats.rows[0],
+ second: stats.rows[1],
+ types: stats.rows.map((r) => r.type),
+ seconds: stats.seconds
+ };
+ });
+ h.check(wire.busiest?.type === 'camera' && wire.busiest?.out === 40, `the busiest type is named and counted (${wire.busiest?.type} x${wire.busiest?.out})`);
+ h.check(wire.second?.type === 'move' && wire.second?.in === 5, 'and the next one, by direction');
+ h.check(wire.types.includes('unknown'), 'a message with no type counts as "unknown" rather than being dropped');
+ h.check(typeof wire.busiest?.bytes === 'number', `bytes are estimated from a sample (≈${wire.busiest?.bytes})`);
+
+ const bundle = await A.page.evaluate(() => {
+ const text = window.__stores.diagnostics.bundleText();
+ return { hasSection: /scene-budget/.test(text), hasTriangles: /triangles/.test(text) };
+ });
+ h.check(bundle.hasSection, 'the diagnostics bundle carries a scene-budget section');
+ h.check(bundle.hasTriangles, '…with the numbers in it — a report can carry them now');
+
+ // the panel closes from its own button
+ await A.page.locator('#stats-close').click();
+ await A.page.waitForTimeout(250);
+ h.check((await A.page.locator('#stats-window').count()) === 0, 'the window closes from its own X');
+
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await h.finish(browser);
+});
diff --git a/tests/e2e/scene-poke.test.cjs b/tests/e2e/scene-poke.test.cjs
new file mode 100644
index 00000000..56803c4b
--- /dev/null
+++ b/tests/e2e/scene-poke.test.cjs
@@ -0,0 +1,330 @@
+// 26-B — Stage 0: poke coalescing, time-sliced ingest, list virtualisation.
+// (hardening audit M6, M1, M2; roadmap 26 section 4 "Stage 0".)
+//
+// THE FINDING: `objectsGroup.update((v) => v)` sat at 117 call sites with eighteen
+// subscribers hanging off it, several of which traverse the whole tree. A 1,000-object
+// handshake therefore ran ~8M node visits synchronously on the receive path, the object
+// list re-rendered every row on each one, and the "Receiving objects" bar walked the
+// tree TWICE per outstanding uuid per poke. That is the reported freeze.
+//
+// What is asserted, in the order it matters:
+// 1. N pokes in one task produce ONE store notification (the mechanism), and a batch
+// drops that to one per frame — with the counterfactual measured in the SAME run;
+// 2. incoming objects are applied IN ORDER and the drainer YIELDS, so a big scene
+// lands without holding the thread;
+// 3. the object list virtualises above the threshold and stays byte-identical below;
+// 4. audit M1: a send builds its OWN uuid list and resolves its connection late;
+// 5. audit M2: the progress bar is cleared by a sender leaving, by a parse failure
+// and by a clear — none of which could clear it before.
+const h = require('./helpers.cjs');
+
+h.run(async () => {
+ // GPU args: section 2 counts rAF frames, and a SwiftShader page runs at ~2.5fps,
+ // where a one-poke-per-frame claim cannot be told from doing nothing (the e2e
+ // skill's rate-assertion rule).
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- 1. the coalescer ---------------------------------------------------
+ const seam = await A.page.evaluate(() => {
+ const s = window.__stores;
+ return {
+ poke: typeof s.pokeScene === 'function',
+ begin: typeof s.beginSceneBatch === 'function',
+ end: typeof s.endSceneBatch === 'function',
+ rev: !!s.sceneRevision
+ };
+ });
+ h.check(seam.poke && seam.begin && seam.end && seam.rev, 'the pokeScene seam is on the debug hook (premise)');
+
+ const coalesced = await A.page.evaluate(async () => {
+ const { objectsGroup, pokeScene } = window.__stores;
+ let hits = 0;
+ const stop = objectsGroup.subscribe(() => hits++);
+ hits = 0; // the subscribe itself fires once
+ for (let i = 0; i < 500; i++) pokeScene();
+ const duringTask = hits;
+ await new Promise((r) => setTimeout(r, 50));
+ const afterFlush = hits;
+ stop();
+ return { duringTask, afterFlush };
+ });
+ h.check(coalesced.duringTask === 0, `500 pokes notify nothing inside the task (${coalesced.duringTask})`);
+ h.check(coalesced.afterFlush === 1, `…and exactly ONE notification lands after it (${coalesced.afterFlush})`);
+
+ // THE COUNTERFACTUAL, measured in the same page: the raw identity update this
+ // replaced notifies once per call. 500 vs 1 is the whole of Stage 0.
+ const raw = await A.page.evaluate(async () => {
+ const { objectsGroup } = window.__stores;
+ let hits = 0;
+ const stop = objectsGroup.subscribe(() => hits++);
+ hits = 0;
+ for (let i = 0; i < 500; i++) objectsGroup.update((v) => v);
+ stop();
+ return hits;
+ });
+ h.check(raw === 500, `the old bare update notifies once per call (${raw}) — the counterfactual`);
+
+ // ---- 2. batch mode: one poke per frame, not per microtask ----------------
+ const batched = await A.page.evaluate(async () => {
+ const { objectsGroup, pokeScene, beginSceneBatch, endSceneBatch, sceneBatchOpen } = window.__stores;
+ let hits = 0;
+ const stop = objectsGroup.subscribe(() => hits++);
+ hits = 0;
+ beginSceneBatch();
+ const open = sceneBatchOpen();
+ // 40 pokes spread over ~200ms of REAL time: microtask coalescing would give 40
+ // (one per task), the frame rule gives about 200/16
+ let frames = 0;
+ const tick = () => { frames++; requestAnimationFrame(tick); };
+ requestAnimationFrame(tick);
+ for (let i = 0; i < 40; i++) {
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 5));
+ }
+ const duringBatch = hits;
+ endSceneBatch();
+ await new Promise((r) => setTimeout(r, 30));
+ stop();
+ return { open, duringBatch, after: hits, frames, closed: !sceneBatchOpen() };
+ });
+ h.check(batched.open, 'beginSceneBatch opens the batch (premise)');
+ h.check(batched.closed, 'endSceneBatch closes it');
+ h.check(batched.frames > 6, `the page really rendered frames in the window (${batched.frames} — GPU premise)`);
+ h.check(
+ batched.duringBatch > 0 && batched.duringBatch < 25,
+ `40 pokes over ~200ms flush ~one per frame, not one per task (${batched.duringBatch})`
+ );
+ h.check(batched.after >= batched.duringBatch, 'closing the batch flushes what is pending');
+
+ // ---- 3. time-sliced ingest ----------------------------------------------
+ const ingest = await A.page.evaluate(() => {
+ const c = window.__stores.commandsHandler;
+ return { backlog: typeof c.ingestBacklog === 'function', drop: typeof c.dropIngestQueue === 'function' };
+ });
+ h.check(ingest.backlog && ingest.drop, 'the ingest queue is on the debug hook (premise)');
+
+ // Feed real objects through the RECEIVE entry point (`createObject` with a toJSON
+ // element, exactly what the `object` message carries) and measure the LONGEST the
+ // main thread was held — which is what a user feels, and what "the window freezes"
+ // names. THE COUNTERFACTUAL is the shape this replaced, measured in the same page on
+ // the same payloads: parse, add and poke each object in ONE uninterrupted task.
+ // 400 is deliberately just UNDER the virtualisation threshold, so this section
+ // measures the coalescer and the queue alone and not the windowed list.
+ const N = 400;
+ const sliced = await A.page.evaluate(async (N) => {
+ const { THREE, objectsGroup, pokeScene, commandsHandler } = window.__stores;
+ let group; { const s = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); s(); }
+ const payloads = [];
+ const names = [];
+ for (let i = 0; i < N; i++) {
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 18), new THREE.MeshStandardMaterial());
+ mesh.name = 'ingest-' + String(i).padStart(3, '0');
+ names.push(mesh.name);
+ payloads.push({ element: mesh.toJSON() });
+ }
+ /** longest gap between two consecutive rAF callbacks = the worst hitch */
+ const watch = () => {
+ const state = { max: 0, frames: 0, done: false, last: performance.now() };
+ const tick = () => {
+ const now = performance.now();
+ state.max = Math.max(state.max, now - state.last);
+ state.last = now;
+ state.frames++;
+ if (!state.done) requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ return state;
+ };
+ const frame = () => new Promise((r) => requestAnimationFrame(() => r(undefined)));
+ const reset = async () => {
+ group.clear();
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 250));
+ };
+
+ // a) THE OLD SHAPE: everything in one task, one bare poke per object
+ await reset();
+ const loader = new THREE.ObjectLoader();
+ let w = watch();
+ await frame();
+ const rawStart = performance.now();
+ for (const p of payloads) {
+ group.add(loader.parse(p.element));
+ objectsGroup.update((v) => v);
+ }
+ const rawMs = performance.now() - rawStart;
+ await frame();
+ w.done = true;
+ const inline = { max: w.max, frames: w.frames };
+
+ // b) …and through the queue
+ await reset();
+ w = watch();
+ await frame();
+ const started = performance.now();
+ const all = payloads.map((p) => commandsHandler.createObject(p, null));
+ const backlogSeen = commandsHandler.ingestBacklog();
+ await Promise.all(all);
+ const ms = performance.now() - started;
+ await frame();
+ w.done = true;
+ const queued = { max: w.max, frames: w.frames };
+ const landed = group.children.map((/** @type {any} */ o) => o.name).filter((/** @type {string} */ n) => n.startsWith('ingest-'));
+ await reset();
+
+ return {
+ ms, rawMs, backlogSeen,
+ maxGap: queued.max, frames: queued.frames,
+ rawMaxGap: inline.max, rawFrames: inline.frames,
+ ordered: landed.join(',') === names.join(','),
+ count: landed.length
+ };
+ }, N);
+ h.check(sliced.backlogSeen > 1, `the queue really parks work rather than parsing inline (${sliced.backlogSeen} parked)`);
+ h.check(sliced.count === N, `all ${N} objects landed (${sliced.count})`);
+ h.check(sliced.ordered, 'they landed in the order they were received — the queue preserves it');
+ h.check(
+ sliced.rawMaxGap > 150,
+ `the old shape holds the thread for ${Math.round(sliced.rawMaxGap)}ms on ${N} objects (premise: this is the freeze)`
+ );
+ h.check(
+ sliced.maxGap < sliced.rawMaxGap * 0.6,
+ `the queued ingest's worst hitch is far shorter (${Math.round(sliced.maxGap)}ms vs ${Math.round(sliced.rawMaxGap)}ms) — the counterfactual`
+ );
+ h.check(
+ sliced.frames > sliced.rawFrames,
+ `the page rendered more frames during the queued ingest (${sliced.frames}) than during the old one (${sliced.rawFrames})`
+ );
+
+ // dropping the queue: a clear must not let a half-sent scene trickle in after it
+ const dropped = await A.page.evaluate(async () => {
+ const { THREE, commandsHandler } = window.__stores;
+ const payloads = [];
+ for (let i = 0; i < 40; i++) {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial());
+ mesh.name = 'dropme-' + i;
+ payloads.push({ element: mesh.toJSON() });
+ }
+ const all = payloads.map((p) => commandsHandler.createObject(p, null));
+ const n = commandsHandler.dropIngestQueue();
+ await Promise.all(all);
+ await new Promise((r) => setTimeout(r, 200));
+ const group = window.__stores.objectsGroup;
+ let live = 0;
+ const stop = group.subscribe((/** @type {any} */ g) => {
+ live = g.children.filter((/** @type {any} */ o) => String(o.name).startsWith('dropme-')).length;
+ });
+ stop();
+ return { n, live };
+ });
+ h.check(dropped.n > 0, `dropIngestQueue reports what it dropped (${dropped.n})`);
+ h.check(dropped.live < 40, `the dropped objects never reached the scene (${dropped.live} of 40 landed)`);
+
+ // ---- 4. audit M1: a send owns its uuid list, and resolves its conn late ---
+ const m1 = await A.page.evaluate(() => {
+ // the send path bails instead of throwing when the conn is gone — the old code
+ // read `conn.send` inside a timer, where an uncaught TypeError kills the reply
+ // with no trace at all
+ try {
+ window.__stores.commandsHandler.sendObjects('nobody-is-here');
+ return { threw: false };
+ } catch (e) {
+ return { threw: true, message: String(e) };
+ }
+ });
+ h.check(!m1.threw, `sendObjects to an absent peer does not throw (${m1.message ?? ''})`);
+ await A.page.waitForTimeout(900);
+ h.check(
+ h.pageErrors(A).length === 0,
+ `…and nothing is thrown 500ms later inside the timer either (${JSON.stringify(h.pageErrors(A))})`
+ );
+
+ // ---- 5. audit M2: the progress bar can be cleared -------------------------
+ const m2 = await A.page.evaluate(async () => {
+ const s = window.__stores;
+ const read = () => { let v; const stop = s.loading.subscribe((/** @type {any} */ x) => (v = x)); stop(); return v; };
+ s.commandsHandler.createLoader(3, ['ghost-a', 'ghost-b', 'ghost-c'], 'peer-who-left');
+ const armed = read().length;
+ // a parse that never produces an object still counts as an arrival
+ s.commandsHandler.noteLoadFailed(['ghost-a']);
+ const afterFail = read().length;
+ // the sender leaving clears the rest
+ s.commandsHandler.handleDisconnected('peer-who-left');
+ const afterLeave = read().length;
+ // and a clear drops any batch outright
+ s.commandsHandler.createLoader(2, ['x', 'y'], 'someone');
+ const armedAgain = read().length;
+ s.commandsHandler.clearSceneLocal();
+ return { armed, afterFail, afterLeave, armedAgain, afterClear: read().length };
+ });
+ h.check(m2.armed === 3, `a loading batch arms with its uuids (${m2.armed})`);
+ h.check(m2.afterFail === 2, `a failed parse counts as an arrival (${m2.afterFail})`);
+ h.check(m2.afterLeave === 0, 'the sender disconnecting clears the batch — it used to stick forever');
+ h.check(m2.armedAgain === 2 && m2.afterClear === 0, 'a scene clear drops the batch too');
+
+ // ---- 6. list virtualisation ---------------------------------------------
+ // Below the threshold the recursive tree renders as it always did.
+ const small = await A.page.evaluate(async () => {
+ const { THREE, objectsGroup, pokeScene, objectListNav, expandedObjects } = window.__stores;
+ let group; const stop = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); stop();
+ group.clear();
+ for (let i = 0; i < 20; i++) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial());
+ m.name = 'small-' + i;
+ group.add(m);
+ }
+ pokeScene();
+ await new Promise((r) => setTimeout(r, 300));
+ let exp; const s2 = expandedObjects.subscribe((/** @type {any} */ v) => (exp = v)); s2();
+ return objectListNav.visibleObjectRows(group, exp, null).length;
+ });
+ h.check(small === 20, `20 objects flatten to 20 rows (${small})`);
+ // the list is open by default in this app; assert on what it actually rendered
+ await A.page.waitForTimeout(400);
+ const smallRows = await A.page.locator('#object-tree [role="treeitem"]').count();
+ h.check(smallRows === 20, `…and all 20 rows are in the DOM below the threshold (${smallRows})`);
+
+ const big = await A.page.evaluate(async () => {
+ const { THREE, objectsGroup, pokeScene } = window.__stores;
+ let group; const stop = objectsGroup.subscribe((/** @type {any} */ g) => (group = g)); stop();
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const mat = new THREE.MeshStandardMaterial();
+ for (let i = 0; i < 700; i++) {
+ const m = new THREE.Mesh(geo, mat);
+ m.name = 'big-' + i;
+ group.add(m);
+ }
+ pokeScene();
+ return group.children.length;
+ });
+ h.check(big === 720, `the scene holds ${big} objects — past the 500-row threshold (premise)`);
+ await A.page.waitForTimeout(900);
+ const bigRows = await A.page.locator('#object-tree [role="treeitem"]').count();
+ h.check(
+ bigRows > 0 && bigRows < 200,
+ `the list draws a WINDOW, not 720 rows (${bigRows} in the DOM) — the virtualisation`
+ );
+ const mode = await A.page.getAttribute('[data-object-rows]', 'data-object-rows');
+ h.check(mode === 'window', `the list says which mode it is in (${mode})`);
+ // …and the scroll height still covers all of them, so the scrollbar tells the truth
+ const spacers = await A.page.evaluate(() => {
+ const host = document.querySelector('[data-object-rows]');
+ const kids = host ? [...host.children] : [];
+ // the two spacers carry their height INLINE, which is readable whether or not the
+ // panel is laid out at this instant
+ const px = kids
+ .map((el) => /height:\s*([\d.]+)px/.exec(el.getAttribute('style') ?? '')?.[1])
+ .filter(Boolean)
+ .map(Number);
+ return { spacerPx: px.reduce((a, b) => a + b, 0), spacers: px.length, kids: kids.length };
+ });
+ h.check(spacers.spacers === 2, `the window has its two spacers (${spacers.spacers} of ${spacers.kids} children)`);
+ h.check(
+ spacers.spacerPx > 700 * 12,
+ `the spacers stand in for every off-screen row, so the scrollbar tells the truth (${Math.round(spacers.spacerPx)}px)`
+ );
+
+ h.check(h.pageErrors(A).length === 0, `no page errors (${JSON.stringify(h.pageErrors(A))})`);
+ await h.finish(browser);
+});