diff --git a/check-baseline.json b/check-baseline.json index 222d02fc..aaeba533 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 357, + "errors": 352, "warnings": 47, - "measured": "2026-09-12" + "measured": "2026-09-16" } diff --git a/src/App.svelte b/src/App.svelte index b6cb5b7c..a52b9cec 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -25,6 +25,10 @@ import { previewWindows } from './lib/fileWindows' import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' + // 26-A: the desktop Statistics window + the sampler that feeds it and the status-line + // budget meter. `renderer.info` had exactly one reader before this (the VR plate). + import StatsOverlay from './components/menu/StatsOverlay.svelte' + import { startSceneMetrics, budgetSummary } from './lib/sceneBudget' import PlayReticle from './components/play/PlayReticle.svelte' import TouchPlayControls from './components/play/TouchPlayControls.svelte' import DrawToolbar from './components/menu/DrawToolbar.svelte' @@ -37,6 +41,9 @@ // 27-G: the one overlay that must sit above everything, because nothing else on // screen is usable while the graphics context is gone. import ContextLostOverlay from './components/ContextLostOverlay.svelte' + // 26-G: the frame-freeze half of Stage 4 (the context-loss half is the overlay above) + import RenderPausedOverlay from './components/RenderPausedOverlay.svelte' + import './lib/overloadGuard' // 27-D: safe mode pauses the runtime BEFORE it is started, so a scene whose scripts // hang on load can still be opened and edited. import { flowPaused } from './stores/flowStore' @@ -113,6 +120,13 @@ import { startMusicToolbox } from './lib/musicToolbox' // 27-B: FIRST, so a failure during the rest of this boot is already recorded. startDiagnostics() startWireErrors() + // 26-A: one rAF loop, ~2 samples a second. Started here rather than inside Scene so + // it keeps measuring while the canvas is remounting — which is exactly when a + // report about the app freezing tends to be written. + startSceneMetrics() + // …and the numbers ride the bundle, so a report carries them (audit H4 + roadmap + // 26 section 3's last row). + registerDiagnosticsSection('scene-budget', () => budgetSummary()) // The bundle reads its context through registered sections, which is what keeps // diagnostics.js a leaf: it never imports a store, the root component does. registerDiagnosticsSection('session', () => { @@ -438,9 +452,11 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/diagnostics'), import('./lib/wireValidate'), import('./lib/wireErrors'), - import('./lib/safeStorage') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib } + import('./lib/safeStorage'), + import('./lib/sceneBudget'), + import('./lib/overloadGuard') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib } }) } }) @@ -500,6 +516,7 @@ import { startMusicToolbox } from './lib/musicToolbox' {/if} + @@ -510,6 +527,7 @@ import { startMusicToolbox } from './lib/musicToolbox' + -
- {#if $objectsGroup.children.length > 0} +
+ {#if virtualising} + + {#each windowRows as row (row.uuid)} + + {/each} + + {:else if $objectsGroup.children.length > 0} {#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element} {/each} @@ -2210,9 +2325,17 @@ {/if}
-
- {objectCount} object{objectCount === 1 ? '' : 's'}{hiddenCount ? ' · ' + hiddenCount + ' hidden' : ''} -
+ +
v); + pokeScene(); showToast(own.length === 1 ? 'Shader removed from this object' : 'Shader removed from ' + own.length + ' objects'); } @@ -1185,7 +1184,7 @@ } } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** A picked image file → every selected material, decoded once. @param {File} file */ @@ -1199,7 +1198,7 @@ if (uuids.length > 1) endHistoryBatch(`Texture (${uuids.length})`); } selectedObject.update((s) => s); - objectsGroup.update((v) => v); + pokeScene(); } /** CL-A A4: which material preset matches the current values (else 'custom') @param {any} p */ @@ -1221,7 +1220,7 @@ } function sendName() { - objectsGroup.update((value) => value); // refresh the object list + pokeScene(); // refresh the object list $peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid }); } @@ -2578,7 +2577,7 @@ $selectedObject.uuid, selected?.name === 'Level Up' ? 'up' : val ); - objectsGroup.update((v) => v); + pokeScene(); rerenderSelectGroup = !rerenderSelectGroup; }} /> @@ -3353,7 +3352,7 @@ object.material.needsUpdate = true; $peers.send({ type: 'color', uuid: object.uuid, color: c.hex }); } - objectsGroup.update((v) => v); + pokeScene(); }} /> {/if} diff --git a/src/components/menu/Objects.svelte b/src/components/menu/Objects.svelte index d30407a3..a80328f7 100644 --- a/src/components/menu/Objects.svelte +++ b/src/components/menu/Objects.svelte @@ -1,7 +1,11 @@ + +{#if $statsOpen} +
+
+
+ +
+

+ Judged against the {profile === 'vr' ? 'VR / mobile' : 'desktop'} budget. + Nothing here leaves this device. +

+ + + + {#each rows as row (row.key)} + + + + + + {/each} + +
+ + {row.label} + {num(row.value)}{row.unit} + {num(row.green)} / {num(row.amber)} +
+ +

Frame

+
+ p50 / p95 / p99 + + {num($sceneMetrics.frameP50)} / {num($sceneMetrics.frameP95)} / {num($sceneMetrics.frameP99)} ms + + Long tasks (1 min) + + {#if $sceneMetrics.longTasksAvailable} + {num($sceneMetrics.longTasks)} · worst {num($sceneMetrics.longestTask)} ms + {:else} + not available in this browser + {/if} + + JS heap + {$sceneMetrics.heap == null ? 'not available' : mb($sceneMetrics.heap)} + Meshes / hidden + {num($sceneMetrics.meshes)} / {num($sceneMetrics.hidden)} + {#if $sceneMetrics.ingestBacklog} + Objects still arriving + {num($sceneMetrics.ingestBacklog)} + {/if} +
+ +

+ Wire, last {Math.round(wire.seconds)}s +

+ {#if wire.rows.length === 0} +

Nothing sent or received yet.

+ {:else} + + + {#each wire.rows.slice(0, 10) as row (row.type)} + + + + + + + + {/each} + +
{row.type}{num(row.in)} in{num(row.out)} out{row.perSecond.toFixed(1)}/s{row.bytes == null ? '' : '≈' + num(row.bytes) + 'B'}
+ {/if} +
+
+{/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); +});