From 7a811459b720f9818df9dd9fb430655f484c541e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:03 +0300 Subject: [PATCH 01/25] [feat] 33 L1 P0: scene-load trace probe (long tasks, frame gaps, profile per long task) - scripts/scene-load-trace.cjs: a fresh phone context per scene (412x915 isMobile @2.6, real GPU, CPU throttle 6), the Templates open path and autosave -> reload -> Restore, each reporting wall time, PerformanceObserver long tasks, the longest rAF gap, the CPU profile's top self time, app call chains, and the three longest tasks broken down. - Baseline (c7018be): Castle Courtyard Restore = ONE 89 s task (91 s frozen), open 525 ms tasks / 2.1 s frozen; Tavern Restore 84 s; Forest gets NO Restore at all (its autosave is over the 50 MB cap). Table in the 33-scene-load handover. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/scene-load-trace.cjs | 262 +++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 scripts/scene-load-trace.cjs diff --git a/scripts/scene-load-trace.cjs b/scripts/scene-load-trace.cjs new file mode 100644 index 00000000..24a42fa2 --- /dev/null +++ b/scripts/scene-load-trace.cjs @@ -0,0 +1,262 @@ +// 33 L1 P0 — WHERE A SCENE LOAD SPENDS ITS MAIN THREAD, on a throttled "phone". +// +// For each scene: a fresh context with a mobile viewport (isMobile + touch, 412x915 @2.6), +// CPU throttled (CPU=6 by default), then two loads measured the same way: +// open the Templates path (fetch .tpscene -> importSessionZip -> requestLoadSession) +// restore autosave -> reload -> the Restore button's call (autosave.restoreSnapshot) +// Each one reports: wall time until every kit piece refilled, the long tasks (> 50 ms, +// PerformanceObserver 'longtask'), the LONGEST gap between two animation frames (what a user +// calls "the window froze"), and the CPU profile's top self-time functions INSIDE long tasks. +// +// APP_URL=https://theprototype.app:5275/ node scripts/scene-load-trace.cjs +// SCENES_DIR= PACKS_DIR= SLUGS=castle-courtyard,... CPU=6 +// OUT= PHASES=open,restore +const fs = require('fs'); +const path = require('path'); +const h = require('../tests/e2e/helpers.cjs'); + +const SCENES_DIR = process.env.SCENES_DIR || '/home/deck/.code/theprototype-app/scenes'; +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +const SLUGS = (process.env.SLUGS || 'castle-courtyard,forest-clearing,tavern-interior').split(',').filter(Boolean); +const CPU = Number(process.env.CPU || 6); +const OUT = process.env.OUT || ''; +const PHASES = (process.env.PHASES || 'open,restore').split(','); + +const MOBILE = { viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2.6 }; + +/** the in-page probe: long tasks + rAF gaps, reset per phase */ +const PROBE = () => { + const w = /** @type {any} */ (window); + if (w.__loadProbe) return; + const p = { longtasks: /** @type {any[]} */ ([]), maxGap: 0, frames: 0, last: 0, on: false }; + w.__loadProbe = p; + try { + new PerformanceObserver((list) => { + if (!p.on) return; + for (const e of list.getEntries()) p.longtasks.push({ start: Math.round(e.startTime), dur: Math.round(e.duration) }); + }).observe({ type: 'longtask', buffered: false }); + } catch {} + const tick = (/** @type {number} */ t) => { + if (p.on) { + if (p.last) p.maxGap = Math.max(p.maxGap, t - p.last); + p.frames++; + } + p.last = t; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +}; + +const settled = (page, marker) => + page.evaluate((marker) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + /** @type {any} */ let pending; + s.packRefs.packRefsPending.subscribe((v) => (pending = v))(); + let hollow = 0; + let meshes = 0; + g?.traverse((/** @type {any} */ n) => { + if (n.userData?.packStub) hollow++; + if (n.isMesh) meshes++; + }); + return { ok: !!g && g.children.length > 0 && hollow === 0 && pending === 0 && (!marker || !!g.getObjectByName(marker) || !!g.getObjectByName(marker.replace(/ /g, '_'))), top: g?.children.length ?? 0, meshes, hollow, pending }; + }, marker); + +async function measure(page, cdp, label, start, marker) { + await page.evaluate(() => { + const p = /** @type {any} */ (window).__loadProbe; + p.longtasks = []; + p.maxGap = 0; + p.frames = 0; + p.last = 0; + p.on = true; + }); + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 500 }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU }); + const perfAtStart = await page.evaluate(() => performance.now()); + await cdp.send('Profiler.start'); + const t0 = Date.now(); + await start(); + let state = null; + const deadline = Date.now() + 240000; + while (Date.now() < deadline) { + state = await settled(page, marker).catch(() => null); + if (state?.ok) break; + await page.waitForTimeout(250); + } + const wall = Date.now() - t0; + // a beat for trailing work (scan, fingerprints, shader compiles on the next frames) + await page.waitForTimeout(2500); + const { profile } = await cdp.send('Profiler.stop'); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + const probe = await page.evaluate(() => { + const p = /** @type {any} */ (window).__loadProbe; + p.on = false; + return { longtasks: p.longtasks, maxGap: Math.round(p.maxGap), frames: p.frames, origin: performance.timeOrigin }; + }); + // attribute profile samples to functions (self time over the whole load) + const nodes = new Map(profile.nodes.map((n) => [n.id, n])); + const self = new Map(); + for (let i = 0; i < profile.samples.length; i++) { + const cf = nodes.get(profile.samples[i]).callFrame; + if (cf.functionName === '(idle)' || cf.functionName === '(program)') continue; + const key = (cf.functionName || '(anon)') + ' ' + (cf.url.split('/').slice(-1)[0].split('?')[0] || '') + ':' + (cf.lineNumber + 1); + self.set(key, (self.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + const top = [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25).map(([k, ms]) => ({ fn: k, ms: Math.round(ms) })); + // the caller chain of the heaviest frames: walk parents for the top 8 + const parentOf = new Map(); + for (const n of profile.nodes) for (const c of n.children ?? []) parentOf.set(c, n.id); + const chains = new Map(); + for (let i = 0; i < profile.samples.length; i++) { + const id = profile.samples[i]; + const node = nodes.get(id); + const cf = node.callFrame; + if (cf.functionName === '(idle)' || cf.functionName === '(program)' || cf.functionName === '(garbage collector)') continue; + const frames = []; + let cur = id; + while (cur && frames.length < 40) { + const n = nodes.get(cur); + const f = n.callFrame; + const file = f.url.split('/').slice(-1)[0].split('?')[0]; + if (/src\/(lib|components|stores)|\.svelte/.test(f.url)) frames.push((f.functionName || '(anon)') + '@' + file + ':' + (f.lineNumber + 1)); + cur = parentOf.get(cur); + } + const key = frames.slice(0, 3).join(' < ') || '(no app frame)'; + chains.set(key, (chains.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + // the three longest tasks, each broken down by app call chain (profile clock -> performance.now + // via the moment the profiler started; a few ms of slack either side) + const longest = probe.longtasks.slice().sort((a, b) => b.dur - a.dur).slice(0, 3); + const perTask = longest.map((task) => { + const by = new Map(); + let at = 0; + for (let i = 0; i < profile.samples.length; i++) { + at += profile.timeDeltas[i]; + const when = perfAtStart + at / 1000; + if (when < task.start - 5 || when > task.start + task.dur + 5) continue; + const id = profile.samples[i]; + const frames = []; + let cur = id; + while (cur && frames.length < 60) { + const f = nodes.get(cur).callFrame; + const file = f.url.split('/').slice(-1)[0].split('?')[0]; + if (f.functionName || file) frames.push((f.functionName || '(anon)') + '@' + file + ':' + (f.lineNumber + 1)); + cur = parentOf.get(cur); + } + const key = frames.slice(0, 6).join(' < '); + by.set(key, (by.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + return { dur: task.dur, start: task.start, top: [...by.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, ms]) => ({ ms: Math.round(ms), chain: k })) }; + }); + const appChains = [...chains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([k, ms]) => ({ chain: k, ms: Math.round(ms) })); + const long = probe.longtasks.slice().sort((a, b) => b.dur - a.dur); + const total = probe.longtasks.reduce((s, e) => s + e.dur, 0); + const result = { + label, + cpu: CPU, + wallMs: wall, + settled: state, + longtasks: probe.longtasks.length, + longtaskTotalMs: total, + maxLongtaskMs: long[0]?.dur ?? 0, + top5Longtasks: long.slice(0, 5).map((e) => e.dur), + maxFrameGapMs: probe.maxGap, + frames: probe.frames, + topSelf: top, + appChains, + perTask + }; + console.log(JSON.stringify({ label, wall, longtasks: result.longtasks, max: result.maxLongtaskMs, total, maxGap: probe.maxGap, frames: probe.frames, settled: state })); + return result; +} + +const MARKERS = { 'castle-courtyard': 'Castle gate', 'forest-clearing': 'Footbridge', 'tavern-interior': 'Balcony stairs' }; + +h.run(async () => { + const index = JSON.parse(fs.readFileSync(path.join(SCENES_DIR, 'index.json'), 'utf8')); + const browser = await h.launch({ args: h.GPU_ARGS }); + const results = []; + for (const slug of SLUGS) { + const entry = [...(index.templates ?? []), ...(index.examples ?? []), ...(index.games ?? [])].find((t) => t.slug === slug); + if (!entry) { + console.log('no entry for ' + slug); + continue; + } + const A = await h.setupPage(browser, slug, { context: MOBILE }); + const page = A.page; + await page.route('**/cdn.jsdelivr.net/**', (route) => { + const url = route.request().url(); + const scenes = url.match(/\/theprototype-app\/scenes@[^/]+\/(.*)$/); + if (scenes) { + const file = path.join(SCENES_DIR, decodeURIComponent(scenes[1])); + if (scenes[1] === 'index.json') return route.fulfill({ json: index }); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + return route.fulfill({ status: 404 }); + } + const packs = url.match(/\/theprototype-app\/packs@[^/]+\/(.*)$/); + if (packs) { + const file = path.join(PACKS_DIR, decodeURIComponent(packs[1])); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + } + return route.continue(); + }); + await page.evaluate(PROBE); + const cdp = await page.context().newCDPSession(page); + const sceneUrl = await page.evaluate((p) => window.__stores.sceneTemplates.resolveUrl(p, window.__stores.sceneTemplates.SCENES_BASE), entry.scene); + const marker = MARKERS[slug]; + if (PHASES.includes('open')) + results.push({ + slug, + ...(await measure(page, cdp, slug + ' open', () => + page.evaluate((url) => { + // not awaited: the probe measures the page while it works + void window.__stores.sceneTemplates.loadRemoteScene({ slug: 'x', title: 'x', sceneUrl: url }); + }, sceneUrl), marker)) + }); + if (PHASES.includes('restore')) { + if (!PHASES.includes('open')) { + await page.evaluate((url) => window.__stores.sceneTemplates.loadRemoteScene({ slug: 'x', title: 'x', sceneUrl: url }), sceneUrl); + await h.eventually(() => settled(page, marker), (r) => r?.ok, slug + ' loaded for the restore', 120000); + } + await page.waitForTimeout(1500); + await page.evaluate(() => window.__stores.autosave.saveNow()); + const snap = await page.evaluate(async () => { + const s = await window.__stores.idb.idbGet('latest'); + return { bytes: s?.scene?.byteLength ?? (s?.scene ? JSON.stringify(s.scene).length : 0), objects: s?.objects }; + }); + console.log('autosave snapshot', JSON.stringify(snap)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, { timeout: 60000 }); + await page.evaluate(PROBE); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.autosave.restoreAvailable.subscribe((x) => (v = x))(); return !!v; }), + (v) => v, + slug + ' the Restore offer is up after the reload', + 30000 + ); + const cdp2 = await page.context().newCDPSession(page); + results.push({ + slug, + snapshot: snap, + ...(await measure(page, cdp2, slug + ' restore', () => page.evaluate(() => void window.__stores.autosave.restoreSnapshot()), marker)) + }); + } + await A.ctx.close(); + } + if (OUT) { + fs.mkdirSync(OUT, { recursive: true }); + const stamp = process.env.TAG || 'run'; + fs.writeFileSync(path.join(OUT, 'scene-load-' + stamp + '.json'), JSON.stringify(results, null, 1)); + const md = [ + '| load | wall ms | long tasks | total long ms | max long ms | max frame gap ms | top 5 |', + '|---|---:|---:|---:|---:|---:|---|', + ...results.map((r) => `| ${r.label} | ${r.wallMs} | ${r.longtasks} | ${r.longtaskTotalMs} | ${r.maxLongtaskMs} | ${r.maxFrameGapMs} | ${r.top5Longtasks.join(', ')} |`) + ]; + fs.writeFileSync(path.join(OUT, 'scene-load-' + stamp + '.md'), md.join('\n') + '\n'); + console.log(md.join('\n')); + } + await browser.close(); +}); From 6333b431b2f2f42e7b9f93cf0bbad11ba8c8fdc3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:18 +0300 Subject: [PATCH 02/25] [feat] 33 L1 P1: progressive scene loads - time-sliced build, kit pieces warmed and placeheld - sceneLoader.js (leaf): slice(job) yields when the 10 ms budget is spent and throws LoadCancelled at a clean point; schedule(fn) runs a burst of continuations a slice at a time; ONE load job (sceneLoad store) - a new load supersedes the running one; cancel hooks; throttledPoke (250 ms) for the build loop. - sessions.applySession: the object loop is time-sliced with progress, kit stubs refill at once (counted when they land), every await re-checks the job, Cancel clears what the load added (replicated like the load), a load interrupting one mid-build writes no "Backup before" of the half scene. - packRefs: a refill's clone/attach runs through schedule() (every copy of a piece used to attach in the one task that resolved its template); warmTemplate uploads textures one per slice and compileAsync's the programs against the live scene before any copy draws; refills poke at most every 200 ms; fingerprint checksums cached per geometry by buffer versions; packRef.box (root-frame bounds, additive) drawn as ONE scene-root InstancedMesh of grey placeholder blocks while a piece is on its way. - App.svelte debug hook: + sceneLoader (220/220/220). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 7 +- src/lib/packRefs.js | 299 ++++++++++++++++++++++++++++++++++++----- src/lib/sceneLoader.js | 294 ++++++++++++++++++++++++++++++++++++++++ src/lib/sessions.js | 98 +++++++++++--- 4 files changed, 642 insertions(+), 56 deletions(-) create mode 100644 src/lib/sceneLoader.js diff --git a/src/App.svelte b/src/App.svelte index 4d640087..a41e4e15 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -494,9 +494,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/moduleWorld'), import('./lib/playSpawn'), import('./lib/packRefs'), - import('./lib/lod') - ]).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, knockLib, 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, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib]) => { - 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, knock: knockLib, 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, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib } + import('./lib/lod'), + import('./lib/sceneLoader') + ]).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, knockLib, 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, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib, sceneLoaderLib]) => { + 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, knock: knockLib, 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, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib, sceneLoader: sceneLoaderLib } }) } }) diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index a9a8f7d7..59aaeeae 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -7,10 +7,11 @@ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; // @ts-ignore import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; import { writable, get } from 'svelte/store'; -import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { objectsGroup, pokeScene, globalRenderer, globalCamera, globalScene } from '../stores/sceneStore'; import { showToast } from '../stores/appStore'; import { PACKS_BASE } from './packs'; import { hashBytes } from './explorer'; +import { schedule } from './sceneLoader'; // 30c — KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy. // @@ -48,7 +49,7 @@ import { hashBytes } from './explorer'; // commandsHandler import this; nothing here reaches back. /** - * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[]}} PackRef + * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[], box?: number[]}} PackRef */ /** How many refills are in flight — the author script and the suite wait on it. @@ -67,6 +68,9 @@ const sharedTextures = new Map(); /** root uuid -> its refill, so two scans never fill one root twice * @type {Map>} */ const filling = new Map(); +/** roots hollowed for an autosave export this very moment — never refilled meanwhile + * @type {WeakSet} */ +const parkedRoots = new WeakSet(); /** urls already reported as unreachable (one toast per piece, not per copy) */ const reported = new Set(); @@ -189,6 +193,7 @@ export function loadPackTemplate(url) { const scene = gltf.scene; scene.updateMatrixWorld(true); fingerprints.set(hash, fingerprintOf(scene)); + await warmTemplate(scene); return { hash, scene }; })(); templates.set(url, job); @@ -197,6 +202,34 @@ export function loadPackTemplate(url) { return job; } +/** + * 33 L1: upload a piece's textures and compile its programs BEFORE its copies are on screen. + * Otherwise the first frame that draws a new piece does both inside the render call — on a + * phone that one frame was the longest task of a whole load. `initTexture` is one upload per + * scheduled slice; `compileAsync` (KHR_parallel_shader_compile where the driver has it) is + * given the live scene as the target so the programs match its lights and fog. Best effort: + * a failure here only means the first frame pays as it always did. + * @param {any} scene + */ +async function warmTemplate(scene) { + /** @type {any} */ + const renderer = get(globalRenderer); + if (!renderer || renderer.xr?.isPresenting) return; + const textures = new Set(); + scene.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + for (const material of Array.isArray(node.material) ? node.material : [node.material]) + for (const slot of MAP_SLOTS) if (material?.[slot]) textures.add(material[slot]); + }); + try { + for (const texture of textures) await schedule(() => renderer.initTexture(texture)); + const camera = get(globalCamera); + if (camera && typeof renderer.compileAsync === 'function') await renderer.compileAsync(scene, camera, get(globalScene) ?? null); + } catch { + /* the first frame compiles instead */ + } +} + // ---- the fingerprint --------------------------------------------------------- /** Seven significant digits, and anything under 1e-6 is zero — a GLTF round trip turns a @@ -218,17 +251,30 @@ function checksum(array) { return num(a) + '/' + num(b); } +/** 33 L1: the checksums of a geometry, kept until one of its buffers changes. A save asks + * "is this piece still pristine?" of every kit piece, and summing every vertex of a 180-piece + * castle on each autosave and each backup was a long task of its own. Keyed by the buffers' + * `version`s, which every edit path bumps (needsUpdate) — an edit no renderer would see is + * the only one this could miss. + * @type {WeakMap} */ +const geometryKeys = new WeakMap(); + /** @param {any} geometry */ function geometryKey(geometry) { if (!geometry?.attributes) return '-'; const names = Object.keys(geometry.attributes).sort(); + const ver = names.map((name) => name + geometry.attributes[name].version + ':' + geometry.attributes[name].array?.length).join(',') + '|' + (geometry.index ? geometry.index.version + ':' + geometry.index.array?.length : '-'); + const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); + const cached = geometryKeys.get(geometry); + if (cached && cached.ver === ver) return cached.key + '|' + groups; const parts = names.map((name) => { const attr = geometry.attributes[name]; return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array); }); const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex'; - const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); - return parts.join(',') + '|' + index + '|' + groups; + const key = parts.join(',') + '|' + index; + geometryKeys.set(geometry, { ver, key }); + return key + '|' + groups; } /** 8×8 RGBA of a texture's image, cached per image. Null when it cannot be read (no @@ -361,6 +407,30 @@ function descendantUuids(root) { return out; } +/** + * 33 L1: a piece's bounds in its ROOT's own frame, [minX, minY, minZ, maxX, maxY, maxZ] to + * the millimetre — carried on the reference (`packRef.box`) so a stub can be drawn as a grey + * block while its pack file is still on its way. Additive: a reference without one simply + * shows nothing until it fills, which is how every file written before 1.19 behaves. + * @param {any} root @returns {number[] | undefined} + */ +function boxOf(root) { + const box = new THREE.Box3(); + const part = new THREE.Box3(); + root.updateWorldMatrix(true, true); + const toRoot = new THREE.Matrix4().copy(root.matrixWorld).invert(); + const m = new THREE.Matrix4(); + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh || !node.geometry) return; + if (!node.geometry.boundingBox) node.geometry.computeBoundingBox(); + part.copy(node.geometry.boundingBox).applyMatrix4(m.multiplyMatrices(toRoot, node.matrixWorld)); + box.union(part); + }); + if (box.isEmpty()) return undefined; + const r = (/** @type {number} */ v) => Math.round(v * 1000) / 1000; + return [r(box.min.x), r(box.min.y), r(box.min.z), r(box.max.x), r(box.max.y), r(box.max.z)]; +} + /** * Stamp a freshly imported pack piece BEFORE it replicates: its reference, its children's * uuids, and — since it IS the file right now — the pristine fingerprint for its hash. @@ -369,7 +439,7 @@ function descendantUuids(root) { export function stampPackRef(root, ref, hash) { if (!root || !ref) return; root.updateMatrixWorld(true); - root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root) } }; + root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root), box: boxOf(root) } }; if (hash && !fingerprints.has(hash)) fingerprints.set(hash, fingerprintOf(root)); } @@ -406,11 +476,62 @@ export function stubElementOf(root) { hollow.renderOrder = root.renderOrder; hollow.layers.mask = root.layers.mask; const kids = root.userData.packStub ? ref.kids ?? [] : descendantUuids(root); - hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids }, packStub: true })); + const box = ref.box ?? (root.userData.packStub ? undefined : boxOf(root)); + hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids, box }, packStub: true })); hollow.updateMatrix(); return hollow.toJSON(); } +/** + * 33 L1 — THE AUTOSAVE WRITES KIT PIECES AS STUBS TOO. 30c kept the autosave's GLTF FULL, + * and that is what made Restore hang: Castle Courtyard's snapshot measured 51 MB (every + * piece's geometry, its textures as PNG data URIs), right at the 50 MB cap — a bigger level + * would silently get no crash recovery at all — and restoring it parsed all of that, then + * serialized every child again for the wire. The export has no per-child filter (the + * transient-objects ritual says the same), so a pristine piece is HOLLOWED for the export: + * its children detached, `packStub` set and its kids recorded, exactly the shape + * `stubElementOf` writes for a save, and put back afterwards. The restore refills it from + * the pack like any other stub (the browser has the file cached from the session it + * came from); an unreachable pack keeps the stub and says so, never dropping the piece. + * @param {any} group the tree about to be exported + * @returns {() => void} the unpark, to run once the export has read the tree + */ +export function parkPackPieces(group) { + /** @type {{root: any, children: any[], userData: any}[]} */ + const parked = []; + if (!group) return () => {}; + /** @type {any[]} */ + const roots = []; + group.traverse((/** @type {any} */ node) => { + if (node !== group && packRefOf(node) && !node.userData.packStub && node.children.length) roots.push(node); + }); + for (const root of roots) { + // a piece nested inside another piece is parked with its ancestor + if (parked.some((p) => p.children.some((c) => c === root || isAncestor(c, root)))) continue; + if (!isPristinePackRef(root)) continue; + const userData = root.userData; + const ref = /** @type {PackRef} */ (packRefOf(root)); + const children = [...root.children]; + root.userData = { ...userData, packRef: { ...ref, kids: descendantUuids(root), box: ref.box ?? boxOf(root) }, packStub: true }; + for (const child of children) root.remove(child); + parkedRoots.add(root); + parked.push({ root, children, userData }); + } + return () => { + for (const { root, children, userData } of parked) { + parkedRoots.delete(root); + for (const child of children) root.add(child); + root.userData = userData; + } + }; +} + +/** @param {any} ancestor @param {any} node */ +function isAncestor(ancestor, node) { + for (let up = node.parent; up; up = up.parent) if (up === ancestor) return true; + return false; +} + /** How many nodes a stub stands for (the root + what it refills to), for object budgets. * @param {any} node a serialized node (element.object) */ export function stubNodeCount(node) { @@ -437,6 +558,59 @@ function instanceOf(scene) { return copy; } +/** + * The synchronous half of a refill: clone the parsed piece and hang its children under the + * stub. Returns false when the stub was replaced (a clear, a reload of the same file) or + * filled while the template was on its way. + * @param {any} root @param {PackRef} ref @param {string} hash @param {any} scene + */ +function attachCopy(root, ref, hash, scene) { + if (!root.userData.packStub || root.children.length || parkedRoots.has(root)) return false; + // still in the scene? (an ancestor walk, not a tree search per piece) + const group = get(objectsGroup); + let up = root.parent; + while (up && up !== group) up = up.parent; + if (!group || up !== group) return false; + const copy = instanceOf(scene); + /** @type {any[]} */ + const nodes = []; + copy.traverse((/** @type {any} */ node) => { + if (node !== copy) nodes.push(node); + }); + const kids = Array.isArray(ref.kids) ? ref.kids : []; + // a stub that lost its kids (a merge import re-uuids the tree) still has to give + // every peer the SAME child uuids: derive them from the root's + nodes.forEach((node, i) => { + node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); + }); + const hideShadow = root.userData.shadow === false; + for (const child of [...copy.children]) root.add(child); + if (hideShadow) + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.castShadow = false; + node.receiveShadow = false; + node.userData.shadow = false; + }); + delete root.userData.packStub; + root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid), box: ref.box ?? boxOf(root) }; + pokeSoon(); + return true; +} + +/** @type {any} */ +let pokeTimer = null; +/** 33 L1: refills poke the scene at most ~5 times a second — a castle refills 180 pieces, + * and every poke re-runs every objectsGroup subscriber (the object list re-renders whole). + * A timer, so the last refill of a burst is always announced. */ +function pokeSoon() { + if (pokeTimer) return; + pokeTimer = setTimeout(() => { + pokeTimer = null; + pokeScene(); + }, 200); +} + /** * Refill a stub from its pack. Resolves true when it filled, false when it could not (or * is no longer in the scene, or was filled meanwhile). The children take the recorded @@ -444,7 +618,7 @@ function instanceOf(scene) { * @param {any} root @returns {Promise} */ export function fillPackRef(root) { - if (!root?.userData?.packStub) return Promise.resolve(false); + if (!root?.userData?.packStub || parkedRoots.has(root)) return Promise.resolve(false); const ref = packRefOf(root); if (!ref) return Promise.resolve(false); const inflight = filling.get(root.uuid); @@ -454,34 +628,10 @@ export function fillPackRef(root) { const job = (async () => { try { const { hash, scene } = await loadPackTemplate(url); - // replaced (a clear, a reload of the same file) or filled while we fetched - if (!root.userData.packStub || root.children.length) return false; - if (get(objectsGroup)?.getObjectByProperty('uuid', root.uuid) !== root) return false; - const copy = instanceOf(scene); - /** @type {any[]} */ - const nodes = []; - copy.traverse((/** @type {any} */ node) => { - if (node !== copy) nodes.push(node); - }); - const kids = Array.isArray(ref.kids) ? ref.kids : []; - // a stub that lost its kids (a merge import re-uuids the tree) still has to give - // every peer the SAME child uuids: derive them from the root's - nodes.forEach((node, i) => { - node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); - }); - const hideShadow = root.userData.shadow === false; - for (const child of [...copy.children]) root.add(child); - if (hideShadow) - root.traverse((/** @type {any} */ node) => { - if (!node.isMesh) return; - node.castShadow = false; - node.receiveShadow = false; - node.userData.shadow = false; - }); - delete root.userData.packStub; - root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid) }; - pokeScene(); - return true; + // 33 L1: every copy of one piece waits on the SAME template promise, so without + // this all of them clone and attach inside the one task that resolved it (a + // castle's walls were one 500 ms block). The scheduler runs them a slice at a time. + return await schedule(() => attachCopy(root, ref, hash, scene)); } catch (error) { if (!reported.has(url)) { reported.add(url); @@ -517,15 +667,92 @@ export function packRefsSettled() { function scan() { const group = get(objectsGroup); if (!group) return; + /** @type {any[]} */ + const hollow = []; group.traverse((/** @type {any} */ node) => { const ref = packRefOf(node); if (!ref) return; if (node.userData.packStub) { - if (!node.children.length) fillPackRef(node); + if (!node.children.length) { + fillPackRef(node); + if (Array.isArray(ref.box) && ref.box.length === 6 && !parkedRoots.has(node)) hollow.push(node); + } } else if (ref.hash && !fingerprints.has(ref.hash)) { loadPackTemplate(packRefUrl(ref)).catch(() => {}); } }); + drawPlaceholders(hollow); +} + +// ---- placeholders (33 L1) ---------------------------------------------------------------- + +const PLACEHOLDER_NAME = 'kit-placeholders'; +/** @type {any} */ +let placeholders = null; + +/** + * A grey block where each kit piece will be, while its pack file is still on its way: a load + * shows the level's SHAPE at once and the real pieces replace the blocks as they arrive. ONE + * InstancedMesh at the SCENE ROOT (golden rule 5 — never inside objectsGroup, so never saved, + * sent or undone), not pickable, rebuilt on each scan (which every refill's poke triggers). + * @param {any[]} stubs hollow stubs carrying a `packRef.box` + */ +function drawPlaceholders(stubs) { + /** @type {any} */ + const scene = get(globalScene); + if (!scene) return; + if (!stubs.length) { + if (placeholders) { + placeholders.parent?.remove(placeholders); + placeholders.geometry.dispose(); + placeholders.material.dispose(); + placeholders = null; + } + return; + } + if (!placeholders || placeholders.userData.capacity < stubs.length) { + if (placeholders) { + placeholders.parent?.remove(placeholders); + placeholders.geometry.dispose(); + placeholders.material.dispose(); + } + const capacity = Math.max(32, stubs.length); + placeholders = new THREE.InstancedMesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 1, metalness: 0 }), + capacity + ); + placeholders.name = PLACEHOLDER_NAME; + placeholders.frustumCulled = false; + placeholders.raycast = () => {}; + placeholders.userData.capacity = capacity; + } + // beside objectsGroup (in its parent — the world rig a VR world-grab moves), never in it + /** @type {any} */ + const host = get(objectsGroup)?.parent ?? scene; + if (placeholders.parent !== host) host.add(placeholders); + host.updateWorldMatrix(true, false); + const toHost = new THREE.Matrix4().copy(host.matrixWorld).invert(); + const m = new THREE.Matrix4(); + const local = new THREE.Matrix4(); + const pos = new THREE.Vector3(); + const size = new THREE.Vector3(); + const q = new THREE.Quaternion(); + stubs.forEach((stub, i) => { + const [x0, y0, z0, x1, y1, z1] = stub.userData.packRef.box; + stub.updateWorldMatrix(true, false); + pos.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2); + size.set(Math.max(0.01, x1 - x0), Math.max(0.01, y1 - y0), Math.max(0.01, z1 - z0)); + local.compose(pos, q, size); + placeholders.setMatrixAt(i, m.multiplyMatrices(toHost, stub.matrixWorld).multiply(local)); + }); + placeholders.count = stubs.length; + placeholders.instanceMatrix.needsUpdate = true; +} + +/** How many grey blocks are drawn (the suite reads it). */ +export function placeholderCount() { + return placeholders?.parent ? placeholders.count : 0; } let started = false; diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js new file mode 100644 index 00000000..3b1cd950 --- /dev/null +++ b/src/lib/sceneLoader.js @@ -0,0 +1,294 @@ +import { writable, get } from 'svelte/store'; + +// 33 L1 — A SCENE LOAD MAY TAKE TIME; IT MAY NOT TAKE THE WINDOW. +// +// THE REPORT: on a phone, Restore after a reload with Castle Courtyard open hung the whole +// window, and so did opening other scenes. Every load path ran its object loop in ONE task +// (parse every element, add it, broadcast its toJSON), and every kit piece refilled in one +// more burst when its pack file resolved — all copies of a wall continue in the same task. +// The measurement is in the 33-scene-load handover; this leaf is the answer to it: +// +// * `slice()` — a cooperative yield for a LOOP: returns at once while the current slice is +// under budget, otherwise gives the event loop a turn (input, paint, the progress bar) +// before the loop continues. A load is therefore many ~10 ms tasks instead of one long one. +// * `schedule(fn)` — the same budget for work that arrives as MANY independent promise +// continuations (twelve walls waiting on one pack file): each becomes a queue item and one +// pump runs them a slice at a time, because N continuations that each check a clock all +// run inside the one task that resolved their promise. +// * `sceneLoad` — the job being loaded ({name, total, done, phase}), rendered by +// SceneLoadBar.svelte as a non-modal bar with Cancel. ONE job at a time: starting a load +// supersedes the previous one, whose loop sees `cancelled` at its next slice and stops. +// +// A LEAF: svelte/store only. sessions, autosave and packRefs import it; it imports nothing +// back, so it can sit under any of the documented cycles. + +/** Work budget per slice. 10 ms leaves a 60 Hz frame room to paint and take input. */ +export const SLICE_MS = 10; + +/** + * @typedef {{id: number, name: string, verb: string, total: number, done: number, phase: string, + * cancellable: boolean, startedAt: number, cancelled: boolean, interrupted: boolean}} LoadJob + * `interrupted`: this load superseded one that was still BUILDING its scene — what is on + * screen is half of somebody else's load, not the user's work (no backup is owed for it). + */ + +/** The load in progress, or null. LOCAL — never replicated, saved or undone. + * @type {import('svelte/store').Writable} */ +export const sceneLoad = writable(/** @type {LoadJob | null} */ (null)); + +/** Thrown out of `slice(job)` when that job was cancelled or superseded. */ +export class LoadCancelled extends Error { + constructor() { + super('scene load cancelled'); + this.name = 'LoadCancelled'; + } +} + +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +/** @type {MessageChannel | null} */ +let channel = null; +/** @type {(() => void)[]} */ +const waiting = []; + +/** + * Give the event loop one turn. `scheduler.yield()` where it exists (it keeps our + * continuation ahead of other queued work); otherwise a MessageChannel message, which — + * unlike setTimeout — carries no 4 ms clamp and is not stretched to 1 s in a background tab. + * @returns {Promise} + */ +export function yieldToEventLoop() { + const g = /** @type {any} */ (globalThis); + if (g.scheduler?.yield) return g.scheduler.yield(); + if (typeof MessageChannel === 'undefined') return new Promise((r) => setTimeout(r, 0)); + if (!channel) { + channel = new MessageChannel(); + channel.port1.onmessage = () => waiting.shift()?.(); + } + return new Promise((resolve) => { + waiting.push(resolve); + /** @type {MessageChannel} */ (channel).port2.postMessage(0); + }); +} + +let sliceStart = now(); + +/** + * Inside a load loop: return immediately while the slice has budget left, else yield and + * start a new slice. With a job, a cancelled/superseded job throws `LoadCancelled` here, so + * a loop stops at a clean point between two objects. + * @param {LoadJob | null} [job] + */ +export async function slice(job = null) { + if (job?.cancelled) throw new LoadCancelled(); + if (now() - sliceStart < SLICE_MS) return; + await yieldToEventLoop(); + sliceStart = now(); + if (job?.cancelled) throw new LoadCancelled(); +} + +/** Start a fresh slice clock (call before a loop that must not inherit a spent budget). */ +export function resetSlice() { + sliceStart = now(); +} + +/** @type {{fn: () => any, resolve: (v: any) => void, reject: (e: any) => void}[]} */ +const queue = []; +let pumping = false; + +async function pump() { + if (pumping) return; + pumping = true; + try { + while (queue.length) { + const start = now(); + while (queue.length && now() - start < SLICE_MS) { + const item = /** @type {any} */ (queue.shift()); + try { + item.resolve(item.fn()); + } catch (error) { + item.reject(error); + } + } + if (queue.length) await yieldToEventLoop(); + } + } finally { + pumping = false; + } +} + +/** + * Run a piece of SYNCHRONOUS work inside the shared slice budget. Items run in arrival + * order, as many per task as fit in `SLICE_MS` (at least one), so a burst of continuations + * spreads over several tasks instead of one. + * @template T @param {() => T} fn @returns {Promise} + */ +export function schedule(fn) { + return new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + // a microtask, so a burst that queues in one task pumps once + queueMicrotask(pump); + }); +} + +/** How many scheduled items wait (the suite and the bar read it). */ +export function scheduledCount() { + return queue.length; +} + +/** Pokes the scene at most every this many ms while a load builds it. Every poke re-runs + * every objectsGroup subscriber (the object list re-renders the whole tree), so a poke per + * object turned the list into the load's biggest cost; four a second still shows it grow. */ +export const LOAD_POKE_MS = 250; + +/** + * A throttle for a load loop's scene pokes: call it per object, it calls `poke` at most + * every LOAD_POKE_MS. The loop pokes once more when it ends. + * @param {() => void} poke @returns {() => void} + */ +export function throttledPoke(poke) { + let last = 0; + return () => { + const t = now(); + if (t - last < LOAD_POKE_MS) return; + last = t; + poke(); + }; +} + +let nextId = 0; +/** @type {LoadJob | null} */ +let current = null; +/** @type {Set<() => void>} */ +const cancelHooks = new Set(); + +function publish() { + sceneLoad.set(current ? { ...current } : null); +} + +/** + * Begin a load. A load already running is SUPERSEDED (cancelled) — starting another scene + * cancels the first one cleanly, at its next slice. + * @param {string} name what the bar says ("Loading ") + * @param {number} total objects this load will bring + * @param {{cancellable?: boolean, phase?: string, verb?: string}} [opts] + * @returns {LoadJob} + */ +export function beginLoad(name, total, opts = {}) { + // only the BUILDING phase leaves a half scene behind ('preparing'/'reading' have not + // touched the scene yet; 'models' means it is whole) + const interrupted = !!current && current.phase === 'objects'; + if (current) cancelLoad({ superseded: true }); + current = { + interrupted, + id: ++nextId, + name: String(name || 'scene'), + verb: opts.verb ?? 'Loading', + total: Math.max(0, total | 0), + done: 0, + phase: opts.phase ?? 'objects', + cancellable: opts.cancellable !== false, + startedAt: Date.now(), + cancelled: false + }; + resetSlice(); + publish(); + return current; +} + +/** Throttled progress publication: the bar is DOM, so ~10 updates a second is plenty. */ +let publishTimer = /** @type {any} */ (null); +function publishSoon() { + if (publishTimer) return; + publishTimer = setTimeout(() => { + publishTimer = null; + publish(); + }, 100); +} + +/** @param {LoadJob} job @param {number} [n] */ +export function progress(job, n = 1) { + if (job !== current) return; + job.done = Math.min(job.total, job.done + n); + publishSoon(); +} + +/** @param {LoadJob} job @param {{total?: number, phase?: string, cancellable?: boolean}} patch */ +export function updateLoad(job, patch) { + if (job !== current) return; + if (patch.total != null) job.total = Math.max(job.done, patch.total | 0); + if (patch.phase != null) job.phase = patch.phase; + if (patch.cancellable != null) job.cancellable = patch.cancellable; + publish(); +} + +/** @param {LoadJob | null} job */ +export function endLoad(job) { + if (!job || job !== current) return; + current = null; + cancelHooks.clear(); + clearTimeout(publishTimer); + publishTimer = null; + publish(); +} + +/** Is THIS job still the one loading (not cancelled, not superseded)? @param {LoadJob} job */ +export function isLive(job) { + return job === current && !job.cancelled; +} + +/** Is any load running? Autosave and the bar read it. */ +export function loading() { + return current !== null; +} + +/** + * Register what Cancel must undo for the running load (the caller's own cleanup — a + * session load clears what it had added). Hooks run once, on cancel only. + * @param {() => void} fn + */ +export function onCancel(fn) { + cancelHooks.add(fn); + return () => cancelHooks.delete(fn); +} + +/** + * Cancel the running load: the bar's Cancel, or a newer load SUPERSEDING it. Only a user + * cancel runs the load's undo hooks — a superseding load clears the scene itself. + * @param {{superseded?: boolean}} [opts] + */ +export function cancelLoad(opts = {}) { + const job = current; + if (!job) return; + job.cancelled = true; + const hooks = opts.superseded ? [] : [...cancelHooks]; + current = null; + cancelHooks.clear(); + clearTimeout(publishTimer); + publishTimer = null; + publish(); + for (const hook of hooks) { + try { + hook(); + } catch {} + } +} + +/** For the suite: the job object itself (not the published copy). */ +export function currentJob() { + return current; +} + +/** Resolve when no load is running. */ +export function loadSettled() { + return new Promise((resolve) => { + if (!get(sceneLoad)) return resolve(true); + /** @type {any} */ + let off = null; + off = sceneLoad.subscribe((v) => { + if (v) return; + queueMicrotask(() => off?.()); + resolve(true); + }); + }); +} diff --git a/src/lib/sessions.js b/src/lib/sessions.js index b43a8fad..5b10e251 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1,12 +1,13 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene } from '../stores/sceneStore'; +import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene, beginSceneBatch, endSceneBatch } from '../stores/sceneStore'; import { restoreGraphs, clearGraphs, SCENE_GRAPH, allNodes } from '../stores/flowStore'; import { serializeGraphs, copyGraphFrom } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; -import { isPristinePackRef, stubElementOf, stubNodeCount } from './packRefs'; +import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef } from './packRefs'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1297,12 +1298,31 @@ function reportUnknownNodes(payload) { * @param {{backup?: boolean, replicate?: boolean, game?: boolean, workspace?: boolean}} [opts] */ export async function applySession(payload, opts = {}) { + // 33 L1: ONE load at a time, and it says what it is doing. Starting this one supersedes + // a load still running (its loop stops at its next slice) — opening scene B while A is + // still arriving is how a user cancels A, and it must not leave A's objects in B. + const job = beginLoad(payload?.name ?? 'scene', (payload?.objects ?? []).length, { phase: 'preparing' }); + try { + await applySessionNow(payload, opts, job); + } catch (error) { + // a load that FAILED must not leave its bar up (endLoad ignores a superseded job) + endLoad(job); + throw error; + } +} + +/** @param {any} payload @param {{backup?: boolean, replicate?: boolean, game?: boolean, workspace?: boolean}} opts + * @param {import('./sceneLoader').LoadJob} job */ +async function applySessionNow(payload, opts, job) { const { backup = true, replicate = true, game = true, workspace = true } = opts; // 31 K3: the file's own name is the game's identity for its per-game settings (a // Games-tab load is unnamed afterwards, but its session name is the game's title) noteSceneFileName(payload?.name); const group = get(objectsGroup); - if (backup && group?.children.length) await saveSession('Backup before "' + payload.name + '"'); + // 33 L1: a load that interrupted another one still building finds HALF of that scene on + // screen — nothing anybody made, so no "Backup before" of it + const hadContent = !!group?.children.length && !job.interrupted; + if (backup && hadContent) await saveSession('Backup before "' + payload.name + '"'); // R22-R8: a session saved by "Save into session" carries the whole Explorer library // beside the scene, because that gesture EMPTIES the library and the save is the only // thing standing between the user and losing it. Restoring it is hash-deduped, so a @@ -1324,30 +1344,68 @@ export async function applySession(payload, opts = {}) { } catch { /* physics failing to load must never block a scene load */ } + // a load superseded while the backup or the library restore awaited stops here + if (!isLive(job)) return; if (replicate) sceneCommand('/clear all'); // replicated clear (objects + module content) else clearSceneLocal(); + updateLoad(job, { phase: 'objects' }); /** @type {any} */ const peer = get(peers); - for (const element of payload.objects ?? []) { - let object; - try { - object = new THREE.ObjectLoader().parse(element); - } catch { - continue; + // 33 L1: Cancel (the load bar) takes back what this load had added — clearing for the + // room as the load itself replicated. A load SUPERSEDED by another one is not cleared + // here: the newer load's own clear does that. + onCancel(() => { + if (replicate) sceneCommand('/clear all'); + else clearSceneLocal(); + showToast( + 'Stopped loading "' + (payload?.name ?? 'scene') + '"' + + (backup && hadContent ? ' — your previous scene is in Sessions as "Backup before ' + payload.name + '"' : '') + ); + }); + /** @type {Promise[]} kit pieces still refilling from their pack */ + const refills = []; + const poke = throttledPoke(pokeScene); + beginSceneBatch(); + try { + // 33 L1: TIME-SLICED. This loop used to build every object in one task — a castle of + // 180 pieces parsed, attached and broadcast before the window could paint again. + // `slice` yields whenever the ~10 ms budget is spent, and stops at a clean point + // between two objects when the load was cancelled or superseded. + for (const element of payload.objects ?? []) { + await slice(job); + let object; + try { + object = new THREE.ObjectLoader().parse(element); + } catch { + progress(job); + continue; + } + // A scene saved while a mesh-edit session was open carries the edit + // wireframe as a real child object — it comes back as a permanent, + // un-updatable wireframe nobody can switch off, and it accumulates on + // every save/load round trip (the reported "wireframe glitch"). Drop it + // on the way in; the peers do the same in `createObject`. + stripEditOverlays(object); + group.add(object); // keep original uuids — every peer converges on them + if (replicate && peer) peer.send({ type: 'object', element }); + // a kit piece is not loaded until its pack refilled it: counted when it lands, + // and asked for NOW rather than on the scan's next debounce + if (object.userData?.packStub) refills.push(fillPackRef(object).finally(() => progress(job))); + else progress(job); + poke(); } - // A scene saved while a mesh-edit session was open carries the edit - // wireframe as a real child object — it comes back as a permanent, - // un-updatable wireframe nobody can switch off, and it accumulates on - // every save/load round trip (the reported "wireframe glitch"). Drop it - // on the way in; the peers do the same in `createObject`. - stripEditOverlays(object); - group.add(object); // keep original uuids — every peer converges on them - if (replicate && peer) peer.send({ type: 'object', element }); + } catch (error) { + if (error instanceof LoadCancelled) return; + throw error; + } finally { + endSceneBatch(); } 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); + // superseded while the rigs parsed: the newer load owns the scene now + if (!isLive(job)) return; // replicate: a loaded scene's movements reach the peers already in the room, // the way each restored joint is re-broadcast below animationsRestore(payload.animations ?? {}, replicate); @@ -1414,6 +1472,12 @@ export async function applySession(payload, opts = {}) { // flow editor's badge is invisible when the dock is closed — which it is for most // players loading a game. Runs after restoreGraphs, so the count is the real one. reportUnknownNodes(payload); + // 33 L1: the bar stays up while kit pieces are still arriving from their pack, and it + // is no longer cancellable then — the scene is whole, only the models are on their way + if (refills.length) { + updateLoad(job, { phase: 'models', cancellable: false }); + void Promise.allSettled(refills).then(() => endLoad(job)); + } else endLoad(job); showToast('Session loaded: ' + payload.name + ' (' + (payload.count ?? 0) + ' objects)'); } From a7d4ceec2c8fc535c47ac334ba450cb515bfb073 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:18 +0300 Subject: [PATCH 03/25] [feat] 33 L1 P2: the scene-load bar - "Loading - n / N objects", Cancel - SceneLoadBar.svelte in the toast stack's first slot (the spectator banner's spot, so arriving toasts never shove it): name, count, a progress bar (indeterminate while preparing/reading), Cancel while objects are being BUILT; once only kit models are still arriving it keeps counting without Cancel. Non-modal, shown after 250 ms so a small scene does not flash it. Ids #scene-load-bar (data-done/total/phase), #scene-load-cancel. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/SceneLoadBar.svelte | 180 ++++++++++++++++++++++++ src/components/menu/Toasts.svelte | 3 + 2 files changed, 183 insertions(+) create mode 100644 src/components/menu/SceneLoadBar.svelte diff --git a/src/components/menu/SceneLoadBar.svelte b/src/components/menu/SceneLoadBar.svelte new file mode 100644 index 00000000..d2d3d4f0 --- /dev/null +++ b/src/components/menu/SceneLoadBar.svelte @@ -0,0 +1,180 @@ + + +{#if visible && $sceneLoad} +
+
+
+

+ {verb} {$sceneLoad.name} + — {what} +

+ {#if $sceneLoad.cancellable} + + {/if} +
+
+
+
+
+
+{/if} + + diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index d1d1217a..869c81cc 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -36,6 +36,7 @@ import { sceneCommand } from '$lib/commandsHandler.svelte'; import { objectsGroup, camSave, globalCamera, globalScene } from '../../stores/sceneStore.js'; import { Progressbar, Toast, Button } from 'flowbite-svelte'; + import SceneLoadBar from './SceneLoadBar.svelte'; import { fly } from 'svelte/transition'; import { untrack } from 'svelte'; // P2b: watching follows a peer's camera IN THIS WORLD, so it cannot survive them @@ -465,6 +466,8 @@ $effect(() => { children's z-index, breaking "approvals above modals"), so it centres with auto margins. -->
+ + +
+ +
+ {/if} + {#if devPrimary}
+
+ 100%screen height0% +
+ +
e.preventDefault()} + ondrop={onDrop} + > + {#each segments as seg (seg.level)} + + {/each} + {#if info.block.cull} +
+ Culled +
+ {/if} + {#each info.levels as l, i (i)} + {#if i < info.levels.length - 1 || info.block.cull} +
+
+
+ {/if} + {/each} + +
+
+
+ {#each info.levels as l, i (i)} + LOD{i} < {pct(l.screenSize)} + {/each} + now: LOD{info.current < 0 ? ' culled' : info.current} at {pct(Math.min(1, info.size))} +
+ +
+ Force LOD + forceLodLevel(uuid, v === 'auto' ? 'auto' : Number(v))} + /> +
+ + + + + {#if level && sel !== null} +
+
+ LOD{sel} + previewing on this screen +
+

{sourceLabel(level)} · {level.tris != null ? level.tris + ' tris' : level.status}

+ {#if level.error}

{level.error}

{/if} + {#if sel === 0} +

LOD0 is the object itself — edit it with the rest of this panel.

+ {:else} + {#if level.source === 'generated'} + (ratioDraft = v / 100)} + onscrubend={() => updateLodLevel(uuid, /** @type {number} */ (sel), { ratio: ratioDraft })} + /> + + {/if} +
+ Replace with + { + if (!v || sel === null) return; + if (v === '__generated') replaceLodLevel(uuid, sel, { source: 'generated', ratio: level.ratio ?? 0.5 }); + else replaceLodLevel(uuid, sel, { source: 'object', ref: v }); + }} + /> +
+

…or drop a model from the Explorer on the bar.

+
+ {#if $lodLevelGizmo?.uuid === uuid && $lodLevelGizmo?.level === sel} + + {:else} + + {/if} + {#if level.offset} + + {/if} + +
+ + {#if level.material} +
+ Colour + updateLodLevel(uuid, /** @type {number} */ (sel), { material: { ...level.material, color: e.currentTarget.value } })} + /> +
+ updateLodLevel(uuid, /** @type {number} */ (sel), { material: { ...level.material, roughness: v } })} + /> + updateLodLevel(uuid, /** @type {number} */ (sel), { material: { ...level.material, metalness: v } })} + /> + {/if} + {/if} +
+ {:else} +

Click a level on the bar to select it: the viewport shows that level while it is selected.

+ {/if} + +
+ + + + {#if !info.implicit} + + {/if} +
+ {/if} +
+ + diff --git a/src/components/play/VRPropertiesPanel.svelte b/src/components/play/VRPropertiesPanel.svelte index 95b4fe60..40522aa5 100644 --- a/src/components/play/VRPropertiesPanel.svelte +++ b/src/components/play/VRPropertiesPanel.svelte @@ -5,7 +5,7 @@ // @ts-ignore - the Text typing re-exports a const enum that clashes with verbatimModuleSyntax import { Text } from '@threlte/extras' import { vrPropsPanelOpen, vrMenuHand, selectedObject } from '../../stores/sceneStore' - import { vrHovered, vrPropsGroup, vrPropsCursor, PROPS_ROWS, controllerIndexFor } from '$lib/vrControls' + import { vrHovered, vrPropsGroup, vrPropsCursor, PROPS_ROWS, controllerIndexFor, lodReadout } from '$lib/vrControls' import { applyWindowPose } from '$lib/vrWindowPoses' import { menuPoseFromController } from '$lib/vrRadialMenu' @@ -36,9 +36,12 @@ }) const AXIS_ROWS = PROPS_ROWS.filter((r: string) => r.includes(':') && r !== 'opacity') + // 33: the LOD row behaves like the opacity row (label · value · − / +) + const STEP_ROWS = ['opacity', 'lod'] const KIND_LABEL: Record = { pos: 'Pos', rot: 'Rot', scale: 'Scale' } function rowLabel(row: string) { if (row === 'opacity') return 'Opacity' + if (row === 'lod') return 'LOD' const [kind, axis] = row.split(':') return `${KIND_LABEL[kind]} ${axis.toUpperCase()}` } @@ -57,6 +60,7 @@ else next[row] = object.scale[axis].toFixed(2) } next.opacity = (object.material?.opacity ?? 1).toFixed(1) + next.lod = lodReadout(object.uuid) return next } @@ -169,24 +173,24 @@ diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index a40bac20..6e0c0ec7 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -18,6 +18,7 @@ import { dropAllAnimations } from '$lib/animationPreview' import { parkAnimatedAtBase } from '$lib/flowRuntime' import { stripEditOverlays } from '$lib/editOverlays' import { isPristinePackRef, stubElementOf } from '$lib/packRefs' +import { normalizeLodGroup } from '$lib/lodGroupCore' import { runSceneClearHandlers } from '$lib/moduleSDK' import { annotations } from '$lib/annotationsHandler' import { isViewer, warnViewerReadOnly } from '$lib/objectPermissions' @@ -610,6 +611,16 @@ export async function objectParameters(data) { else delete mesh.userData.pick; pokeScene(); } + } else if (data.parameter == 'lod') { + // 33: the object's LOD GROUP block. null = removed (back to the pack's implicit + // group / auto LOD). Normalized here — the one boundary every writer goes through. + let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); + if (mesh) { + const next = normalizeLodGroup(data.lod); + if (next) mesh.userData.lod = next; + else delete mesh.userData.lod; + pokeScene(); + } } else if (data.parameter == 'origin') { // 17-D: userData.origin is the per-object transform ORIGIN — a local-space // pivot offset the tools transform around. null = the object's own zero. diff --git a/src/lib/explorerDrop.js b/src/lib/explorerDrop.js index b3de320d..0bd0a165 100644 --- a/src/lib/explorerDrop.js +++ b/src/lib/explorerDrop.js @@ -208,8 +208,10 @@ async function placeExplorerPayload(payload, target) { } // 30c: the placed piece carries its pack reference, so a save and the wire write it // as a small stub instead of the whole model (packRefs.js) + const { placementGroupFor } = await import('./lodGroup'); importFile(new File([await res.blob()], name + '.glb'), name, undefined, target.point ?? undefined, undefined, { - packRef: packRefFromUrl(payload.url, { item: name }) + packRef: packRefFromUrl(payload.url, { item: name }), + lod: placementGroupFor(payload.url, payload.lods) }); dismiss(); } catch { diff --git a/src/lib/fileHandler.svelte.js b/src/lib/fileHandler.svelte.js index 2a085ab8..d3380620 100644 --- a/src/lib/fileHandler.svelte.js +++ b/src/lib/fileHandler.svelte.js @@ -668,7 +668,7 @@ function defaultImportName(extension, name) { * @param {any} file @param {string=} name @param {string=} ext - explicit extension when the blob has no name (Library) * @param {number[]=} position - world drop point (Explorer drag-out, 96) * @param {any[]=} extras - companion files picked/dropped alongside (.mtl + its textures) - * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null}} [opts] + * @param {{reduce?: boolean | import('./importBudget').ReductionPlan, packRef?: import('./packRefs').PackRef | null, lod?: any}} [opts] * 26-F: `reduce` imports REDUCED. 30c: `packRef` names the PACK ITEM this file is — the * placed root then carries the reference (packRefs.js), so a save and the wire write it * as a small stub. Ignored for an animated or a reduced import (neither IS the file). @@ -711,6 +711,12 @@ export async function importFile(file, name, ext, position, extras, opts = {}) { // 30c: stamp the pack reference BEFORE addImported, which is what replicates it if (opts.packRef && !parsed.animated && typeof file?.arrayBuffer === 'function') stampPackRef(parsed.root, opts.packRef, await hashBytes(await file.arrayBuffer())); + // 33 (contract P1): a pack item's `lods` place as the object's LOD GROUP — on the + // root BEFORE it replicates, so the block rides the same object message / stub + if (opts.lod) { + const root = parsed.animated ? parsed.animated.result?.scene : parsed.root; + if (root) root.userData = { ...(root.userData ?? {}), lod: opts.lod }; + } if (parsed.animated) addAnimatedImport(parsed.animated.result, parsed.animated.buffer, label, parsed.animated.kind); else addImported(parsed.root, label, position); for (const note of parsed.notes) showToast(note); diff --git a/src/lib/lod.js b/src/lib/lod.js index 526aad44..1bfb133b 100644 --- a/src/lib/lod.js +++ b/src/lib/lod.js @@ -112,9 +112,103 @@ function whyNot(mesh, explicit) { const morph = mesh.geometry.morphAttributes; if (morph && Object.keys(morph).some((k) => morph[k]?.length)) return 'morph targets'; if (mesh.userData?.lod === false) return 'opted out'; + if (groupPass?.owns(mesh)) return 'lod group'; return ''; } +// ---- 33: the LOD GROUP pass (lodGroup.js) + the overlay ---------------------------------- +// +// A per-object LOD GROUP (userData.lod) runs inside the SAME render hooks, so both systems +// share one swap/restore bracket and one nesting rule. lodGroup registers itself here — a +// seam rather than an import, so this module keeps its two-store leaf shape. + +/** + * @typedef {{before: (camera: any, bias: number, enabled: boolean, overlay: boolean) => void, + * after: () => void, owns: (mesh: any) => boolean}} LodPass + */ +/** @type {LodPass | null} */ +let groupPass = null; +/** @param {LodPass} pass */ +export function registerLodPass(pass) { + groupPass = pass; + installHooks(); +} + +/** "Show LOD level": every LOD-managed mesh drawn in its level's colour. LOCAL, off. */ +export const lodShowLevels = writable(false); +let overlay = false; +lodShowLevels.subscribe((on) => (overlay = !!on)); +const OVERLAY_COLORS = [0x3fb950, 0xd4b106, 0xe07b1f, 0xd23f3f, 0xa052d9, 0x3b82f6]; +/** @type {any[]} */ +const overlayMats = []; +/** The flat material the overlay paints level `level` with (-1 = culled). @param {number} level */ +export function overlayMaterial(level) { + const i = level < 0 ? OVERLAY_COLORS.length : Math.min(level, OVERLAY_COLORS.length - 1); + if (!overlayMats[i]) { + overlayMats[i] = new THREE.MeshLambertMaterial({ color: i === OVERLAY_COLORS.length ? 0x6b7280 : OVERLAY_COLORS[i] }); + overlayMats[i].name = 'lod-overlay-' + i; + } + return overlayMats[i]; +} +/** meshes whose MATERIAL the overlay swapped in this render pass: [mesh, material] pairs */ +/** @type {any[]} */ +const matSwapped = []; +/** Swap a mesh's material for this render pass (the overlay, a group's per-level + * override). Restored with the geometry. @param {any} mesh @param {any} material */ +export function swapMaterialForPass(mesh, material) { + matSwapped.push(mesh, mesh.material); + mesh.material = material; +} +/** Swap a mesh's geometry for this render pass. @param {any} mesh @param {any} geometry */ +export function swapGeometryForPass(mesh, geometry) { + swapped.push(mesh, mesh.geometry); + mesh.geometry = geometry; +} + +/** A LOCAL forced level per explicit mesh (`api.lod(...).force(n)`). @type {WeakMap} */ +const forcedMeshes = new WeakMap(); + +/** + * Coarser geometry for ONE mesh at ONE ratio, built in the worker and shared by every + * caller asking for the same content + ratio (the LOD group's 'generated' levels). + * Resolves the geometry or null; `release()` drops the share. + * @param {any} geometry @param {number} ratio + * @returns {{geometry: Promise, release: () => void}} + */ +export function simplifiedGeometry(geometry, ratio) { + const sig = geometrySignature(geometry.attributes.position.array, geometry.index?.count ?? 0) + '|g' + ratio; + /** @type {any} */ + let c = cache.get(sig); + if (!c) { + /** @type {any} */ + const fresh = { levels: null, value: null, refs: 0, ratios: [ratio] }; + fresh.levels = buildLevels(geometry, [ratio]) + .then((levels) => { + fresh.value = levels; + return levels; + }) + .catch(() => null); + cache.set(sig, fresh); + c = fresh; + } + c.refs++; + let released = false; + return { + geometry: c.levels.then((/** @type {any[]|null} */ levels) => levels?.[0] ?? null), + release() { + if (released) return; + released = true; + const cc = cache.get(sig); + if (!cc) return; + cc.refs--; + if (cc.refs <= 0) { + cache.delete(sig); + for (const level of cc.value ?? []) level.dispose(); + } + } + }; +} + // ---- the worker ------------------------------------------------------------------------ /** @type {Worker | null} */ @@ -362,6 +456,18 @@ export function lodObject(object, opts = {}, owner = null) { return { meshes: meshes.length, ready, + /** 33: draw level `n` on THIS screen whatever the distance (null = automatic again). + * LOCAL, like every auto-LOD decision. @param {number | null} n */ + force(n) { + for (const m of meshes) { + if (n === null || n === undefined || !Number.isFinite(Number(n))) forcedMeshes.delete(m); + else forcedMeshes.set(m, Math.max(0, Math.floor(Number(n)))); + } + }, + /** The level each mesh drew last (0 = source). */ + levels() { + return meshes.map((m) => entries.get(m)?.current ?? 0); + }, remove() { for (const m of meshes) if (entries.get(m)?.owner === owner) unregisterMesh(m); } @@ -376,8 +482,14 @@ export function releaseOwner(owner) { // ---- the swap ----------------------------------------------------------------------------- function restoreSwapped() { - for (let i = 0; i < swapped.length; i += 2) swapped[i].geometry = swapped[i + 1]; + // REVERSE: a mesh swapped twice in one pass (a group level, then anything after it) + // must end on the geometry it had before the FIRST swap + for (let i = swapped.length - 2; i >= 0; i -= 2) swapped[i].geometry = swapped[i + 1]; swapped.length = 0; + // materials in REVERSE: a mesh swapped twice (an override, then the overlay) must end on + // its own material, not on the override + for (let i = matSwapped.length - 2; i >= 0; i -= 2) matSwapped[i].material = matSwapped[i + 1]; + matSwapped.length = 0; } /** @@ -391,7 +503,12 @@ function beforeRender(renderer, scene, camera) { restoreSwapped(); // a render that threw last time left a swap behind: never keep it stats.drawnCoarse = 0; stats.trianglesSaved = 0; - if (!enabled || !entries.size || !camera?.matrixWorld) return; + if (groupPass && camera?.matrixWorld) groupPass.before(camera, bias, enabled, overlay); + if (!entries.size || !camera?.matrixWorld) return; + if (!enabled) { + if (overlay) for (const entry of entries.values()) if (entry.mesh.visible) swapMaterialForPass(entry.mesh, overlayMaterial(0)); + return; + } const ce = camera.matrixWorld.elements; const cx = ce[12]; const cy = ce[13]; @@ -399,6 +516,8 @@ function beforeRender(renderer, scene, camera) { const t = now(); for (const entry of entries.values()) { const mesh = entry.mesh; + // 33: a LOD group draws this mesh's levels (an auto entry is dropped on the next scan) + if (!entry.explicit && groupPass?.owns(mesh)) continue; const geometry = mesh.geometry; if (geometry !== entry.source || geometry.attributes.position.version !== entry.version) { // swapped (meshgeo, undo) or edited in place (sculpt): stand down now, rebuild once @@ -419,6 +538,7 @@ function beforeRender(renderer, scene, camera) { } if (!entry.levels) { if (!entry.sig && t - entry.stableSince > STABLE_MS && trianglesOf(geometry) >= entry.opts.minTriangles) requestLevels(entry); + if (overlay && mesh.visible) swapMaterialForPass(mesh, overlayMaterial(0)); continue; } if (!mesh.visible) continue; @@ -436,8 +556,11 @@ function beforeRender(renderer, scene, camera) { const dx = wx - cx; const dy = wy - cy; const dz = wz - cz; - const level = pickLevel(Math.sqrt(dx * dx + dy * dy + dz * dz), radius, entry.edges, bias, entry.current, entry.opts.hysteresis); + const auto = pickLevel(Math.sqrt(dx * dx + dy * dy + dz * dz), radius, entry.edges, bias, entry.current, entry.opts.hysteresis); + const forced = forcedMeshes.get(mesh); + const level = forced === undefined ? auto : Math.min(forced, entry.levels.length); entry.current = level; + if (overlay) swapMaterialForPass(mesh, overlayMaterial(level)); if (level > 0) { const low = entry.levels[level - 1]; swapped.push(mesh, geometry); @@ -452,6 +575,7 @@ function beforeRender(renderer, scene, camera) { function afterRender(...args) { if (--renderDepth > 0) return; renderDepth = 0; + groupPass?.after(); restoreSwapped(); prevAfter?.(...args); } @@ -498,6 +622,8 @@ export function scanForLod() { visit(group); visit(scene?.getObjectByName?.('module-world-root')); } + // 33: a mesh that joined a LOD GROUP leaves auto LOD (the group draws its levels) + if (groupPass) for (const [mesh, entry] of [...entries]) if (!entry.explicit && groupPass.owns(mesh)) unregisterMesh(mesh); // an auto entry whose mesh left the scene is dropped; an explicit one stays until its // owner says so (a module may park content off-scene and put it back) for (const [mesh, entry] of [...entries]) { diff --git a/src/lib/lodGroup.js b/src/lib/lodGroup.js new file mode 100644 index 00000000..a9ffe6f4 --- /dev/null +++ b/src/lib/lodGroup.js @@ -0,0 +1,891 @@ +import * as THREE from 'three'; +// @ts-ignore - three addons ship no declarations here (project-wide) +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +// @ts-ignore +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; +// @ts-ignore +import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; +import { writable, get } from 'svelte/store'; +import { globalScene, objectsGroup } from '../stores/sceneStore'; +import { registerLodPass, simplifiedGeometry, swapGeometryForPass, swapMaterialForPass, overlayMaterial } from './lod'; +import { normalizeLodGroup, pickGroupLevel, thresholdsOf, groupFromPackLods } from './lodGroupCore'; +import { packRefOf, packRefUrl, loadPackFile } from './packRefs'; +import { PACKS_BASE } from './packs'; +import { explorerItems, itemByHash, itemBlob } from './explorer'; + +// 33 (K2/K6) — LOD GROUPS: a per-object `userData.lod` block, drawn at render time. +// +// THE SHAPE, and why it is not a THREE.LOD (31-perf's argument, and 33-anim-core's ask): +// objectsGroup IS the replicated document. A THREE.LOD root holding three copies would +// change what every serializer writes, what the object list shows and what the mesh tools +// edit — and an animated item's mixer binds by NODE NAME, so the two copies it did not bind +// would sit frozen. So the tree is left alone and a level is applied only while the +// renderer draws, inside lod.js's own onBeforeRender/onAfterRender bracket: +// SWAP level — the level file's meshes matched to the object's by NODE NAME (fallback: +// traverse order when the counts agree): each matched mesh draws the +// level's geometry with ITS OWN material. Shared materials by construction, +// animation keeps playing, picking/physics/serializers see LOD0. +// Pack `lods` files and generated (meshopt) levels are swap levels. +// TREE level — a level replaced by an unrelated model (an Explorer item, another scene +// object): a substitute subtree in a scene-root LOCAL holder, drawn at the +// object's matrixWorld × the level's offset while the source meshes are +// hidden for that render. +// CULLED — the source meshes hidden for that render. +// +// WHAT IS LOCAL: everything here. The BLOCK is scene data (it rides userData into every +// save, the wire and undo — lodGroupActions.js writes it); the built levels, the level +// drawn, the edit preview and the overlay are facts about this screen. +// +// LAZY: a level is built the first time it is NEEDED (the pick wants it), never at load, +// so a castle of a hundred kit pieces fetches the LOD files of the pieces that are far +// away, once per file. Until it lands, the nearest finer level that IS built draws. +// +// A pack piece placed before its pack had `lods` (every scene today) gets an IMPLICIT +// block from the pack row at runtime — not saved; the LOD panel's first edit writes it. + +/** + * @typedef {import('./lodGroupCore').LodGroup} LodGroup + * @typedef {{status: 'loading'} | {status: 'failed', error: string} + * | {status: 'ready', kind: 'swap', pairs: any[], tris: number, owned: any[], releases: (() => void)[]} + * | {status: 'ready', kind: 'tree', object: any, tris: number}} LevelState + * @typedef {{root: any, block: LodGroup, sig: string, implicit: boolean, meshes: any[], meshKey: string, + * center: any, radius: number, built: Map, current: number, autoCurrent: number, + * size: number, holder: any, matCache: Map, raw: any}} GroupEntry + */ + +/** A LOCAL preview: the level selected in the LOD panel draws on this screen until the + * selection moves on (then the block's own mode again). @type {import('svelte/store').Writable<{uuid: string, level: number} | null>} */ +export const lodPreview = writable(null); +/** Poked (at most ~4 Hz) when a level finishes building or a group's drawn level changes, + * so the panel's readout follows without a per-frame store write. */ +export const lodGroupTick = writable(0); + +/** @type {Map} */ +const entries = new Map(); +/** every mesh some group draws — lod.js's auto scan stands down for these */ +/** @type {WeakSet} */ +let owned = new WeakSet(); +/** pack rows: `/` -> the implicit group (null = the row has no lods) */ +/** @type {Map} */ +const implicitGroups = new Map(); +/** @type {Map>} */ +const listFetches = new Map(); +/** @type {any} */ +let preview = null; +lodPreview.subscribe((p) => (preview = p)); + +let tickPending = false; +function tick() { + if (tickPending) return; + tickPending = true; + setTimeout(() => { + tickPending = false; + lodGroupTick.update((n) => n + 1); + }, 250); +} + +/** @param {any} g */ +function trianglesOf(g) { + if (!g) return 0; + if (g.index) return Math.floor(g.index.count / 3); + const p = g.attributes?.position; + return p ? Math.floor(p.count / 3) : 0; +} + +/** The meshes a group draws for: the root's subtree, minus a nested group's subtree and + * anything the swap cannot carry (a skinned mesh's weights, a mesh with no positions). + * @param {any} root */ +function sourceMeshes(root) { + /** @type {any[]} */ + const out = []; + /** @param {any} node */ + const walk = (node) => { + if (node !== root && node.userData?.lod) return; + if (node.isMesh && !node.isSkinnedMesh && node.geometry?.attributes?.position) out.push(node); + for (const c of node.children) walk(c); + }; + walk(root); + return out; +} + +/** @param {any[]} meshes */ +function meshKeyOf(meshes) { + return meshes.map((m) => m.uuid + ':' + m.geometry.uuid + ':' + m.geometry.attributes.position.version).join(','); +} + +const _m = new THREE.Matrix4(); +const _inv = new THREE.Matrix4(); +const _box = new THREE.Box3(); +const _gb = new THREE.Box3(); +const _v = new THREE.Vector3(); + +/** node's matrix in `root`'s frame. @param {any} node @param {any} root @param {any} [out] */ +function relMatrix(node, root, out = new THREE.Matrix4()) { + root.updateWorldMatrix(true, false); + node.updateWorldMatrix(true, false); + _inv.copy(root.matrixWorld).invert(); + return out.multiplyMatrices(_inv, node.matrixWorld); +} + +/** The root-local bounding sphere of the source meshes. @param {GroupEntry} entry */ +function measure(entry) { + _box.makeEmpty(); + for (const m of entry.meshes) { + const g = m.geometry; + if (!g.boundingBox) g.computeBoundingBox(); + if (!g.boundingBox) continue; + _gb.copy(g.boundingBox).applyMatrix4(relMatrix(m, entry.root, _m)); + _box.union(_gb); + } + if (_box.isEmpty()) { + entry.center = new THREE.Vector3(); + entry.radius = 0; + return; + } + // centre on the box, radius from each mesh's own bounding SPHERE (the box half-diagonal + // overstates a round object by up to √3 and would make it read ~1.7x bigger on screen) + const center = _box.getCenter(new THREE.Vector3()); + let radius = 0; + for (const m of entry.meshes) { + const g = m.geometry; + if (!g.boundingSphere) g.computeBoundingSphere(); + if (!g.boundingSphere) continue; + const rel = relMatrix(m, entry.root, _m); + const c = _v.copy(g.boundingSphere.center).applyMatrix4(rel); + const e = rel.elements; + const sc = Math.sqrt(Math.max(e[0] * e[0] + e[1] * e[1] + e[2] * e[2], e[4] * e[4] + e[5] * e[5] + e[6] * e[6], e[8] * e[8] + e[9] * e[9] + e[10] * e[10])); + radius = Math.max(radius, c.distanceTo(center) + g.boundingSphere.radius * sc); + } + entry.center = center; + entry.radius = radius; +} + +/** The identity of a level's CONTENT (what has to be rebuilt when it changes). Offsets, + * material overrides and thresholds are applied per render and invalidate nothing. + * @param {import('./lodGroupCore').LodLevel} level */ +function levelKey(level) { + return level.source + '|' + (level.ref ?? '') + '|' + (level.ratio ?? ''); +} + +// ---- resolving where a level's bytes live --------------------------------------------- + +/** + * A pack level's URL: absolute passes through; a path with a '/' is PACKS_BASE-relative + * (what a placement writes, so an animated item with no pack reference still resolves); + * a bare file name sits beside the object's own pack file. + * @param {any} root @param {string} ref @returns {string | null} + */ +export function packLevelUrl(root, ref) { + if (/^https?:\/\//.test(ref)) return ref; + if (ref.includes('/')) return String(PACKS_BASE).replace(/\/+$/, '') + '/' + ref.replace(/^\/+/, ''); + const pr = packRefOf(root); + if (!pr) return null; + const own = packRefUrl(pr); + return own.slice(0, own.lastIndexOf('/') + 1) + ref; +} + +/** + * The group a pack ITEM row places with (contract P1), its level refs written as + * PACKS_BASE-relative paths beside `glbUrl` so every peer and every later load resolves + * them, whatever the placed object turns into (a stub, an animated import). + * @param {string} glbUrl @param {any} lods the row's `lods` @returns {LodGroup | null} + */ +export function placementGroupFor(glbUrl, lods) { + if (!glbUrl || !Array.isArray(lods) || !lods.length) return null; + const group = groupFromPackLods({ lods }); + if (!group) return null; + const dir = glbUrl.slice(0, glbUrl.lastIndexOf('/') + 1); + const base = String(PACKS_BASE).replace(/\/+$/, '') + '/'; + for (const level of group.levels) { + if (level.source !== 'pack' || !level.ref) continue; + const url = dir + level.ref; + level.ref = url.startsWith(base) ? url.slice(base.length) : url; + } + return group; +} + +/** `/` of a relative pack reference, or '' for an absolute one. + * @param {any} ref */ +function rowKeyOf(ref) { + if (!ref?.path || /^https?:\/\//.test(ref.path)) return ''; + const parts = String(ref.path).replace(/^\/+/, '').split('/'); + return parts.length >= 2 ? parts[0] + '/' + parts[1] : ''; +} + +/** @param {string} url */ +function fetchJson(url) { + let job = listFetches.get(url); + if (!job) { + job = fetch(url).then((r) => (r.ok ? r.json() : null)); + listFetches.set(url, job); + job.catch(() => listFetches.delete(url)); + } + return job; +} + +/** Look a piece's pack row up and remember its implicit group. @param {string} key */ +async function lookupImplicit(key) { + if (implicitGroups.has(key)) return; + implicitGroups.set(key, null); // in flight = nothing, so a scan never asks twice + try { + const base = String(PACKS_BASE).replace(/\/+$/, ''); + const [packName, folder] = key.split('/'); + const index = await fetchJson(base + '/index.json'); + const row = Array.isArray(index) ? index.find((/** @type {any} */ e) => e?.name === packName) : null; + if (!row?.value) return; + const listUrl = /^https?:\/\//.test(row.value) ? row.value : base + '/' + String(row.value).replace(/^\//, ''); + const list = await fetchJson(listUrl); + const item = Array.isArray(list) ? list.find((/** @type {any} */ o) => o?.name === folder) : null; + const group = item ? groupFromPackLods(item) : null; + if (group) { + implicitGroups.set(key, group); + scheduleScan(); + } + } catch { + /* the CDN is unreachable: the piece keeps 31-perf's auto LOD */ + } +} + +// ---- building levels -------------------------------------------------------------------- + +const loader = (() => { + /** @type {any} */ + let l = null; + return () => { + if (l) return l; + l = new GLTFLoader(); + const draco = new DRACOLoader(); + draco.setDecoderPath('/draco/'); + l.setDRACOLoader(draco); + l.setMeshoptDecoder(MeshoptDecoder); + return l; + }; +})(); + +/** @param {string} a */ +const nameKey = (a) => String(a ?? '').replace(/\s+/g, '_'); + +/** + * Pair a level file's meshes with the object's, by node name first and traverse order as + * the fallback when both sides have the same count. A level mesh placed differently from + * its partner (another node transform in the lod file) is baked into the partner's frame, + * so the swap draws it where the file meant it. + * @param {GroupEntry} entry @param {any} levelScene + * @returns {{pairs: any[], owned: any[], tris: number} | null} + */ +function pairLevel(entry, levelScene) { + /** @type {any[]} */ + const lvl = []; + levelScene.traverse((/** @type {any} */ o) => { + if (o.isMesh && !o.isSkinnedMesh && o.geometry?.attributes?.position) lvl.push(o); + }); + const used = new Set(); + /** @type {any[]} */ + const pairs = []; + /** @type {any[]} */ + const ownedGeoms = []; + let tris = 0; + const byOrder = lvl.length === entry.meshes.length; + entry.meshes.forEach((m, i) => { + let match = lvl.find((l) => !used.has(l) && l.name && nameKey(l.name) === nameKey(m.name)); + if (!match && byOrder && !used.has(lvl[i])) match = lvl[i]; + if (!match) { + tris += trianglesOf(m.geometry); + return; + } + used.add(match); + let g = match.geometry; + const a = relMatrix(m, entry.root, new THREE.Matrix4()); + const b = relMatrix(match, levelScene, new THREE.Matrix4()); + if (!a.equals(b)) { + g = g.clone(); + g.applyMatrix4(new THREE.Matrix4().copy(a).invert().multiply(b)); + ownedGeoms.push(g); + } + // an ARRAY material walks geometry.groups; a level with none would draw nothing + if (Array.isArray(m.material) && !g.groups.length) { + if (g === match.geometry) { + g = g.clone(); + ownedGeoms.push(g); + } + g.addGroup(0, g.index ? g.index.count : g.attributes.position.count, 0); + } + pairs.push(m, m.geometry, g); + tris += trianglesOf(g); + }); + return pairs.length ? { pairs, owned: ownedGeoms, tris } : null; +} + +/** A model as a TREE level: the parsed subtree, its root reset to identity (the holder + * carries the object's placement), never pickable. @param {any} object */ +function asTree(object) { + object.position.set(0, 0, 0); + object.quaternion.identity(); + object.updateMatrix(); + let tris = 0; + object.traverse((/** @type {any} */ o) => { + o.raycast = () => {}; + if (o.isMesh) tris += trianglesOf(o.geometry); + }); + return { object, tris }; +} + +/** @param {ArrayBuffer} buffer */ +function parseGlb(buffer) { + return new Promise((resolve, reject) => loader().parse(buffer, '', (/** @type {any} */ g) => resolve(g.scene), reject)); +} + +/** @type {Map void)[]>} explorer hashes waiting for their bytes */ +const awaitingHash = new Map(); +let explorerWatch = false; +function watchExplorer() { + if (explorerWatch) return; + explorerWatch = true; + explorerItems.subscribe(() => { + for (const [hash, waiters] of [...awaitingHash]) { + if (!itemByHash(hash)) continue; + awaitingHash.delete(hash); + for (const w of waiters) w(); + } + }); +} + +/** The bytes of an Explorer item by content hash; asks a peer for it when missing and + * WAITS for it to land (golden rule 9 — an asset that arrives later needs a watch). + * @param {string} hash @returns {Promise} */ +function explorerBytes(hash) { + return new Promise((resolve, reject) => { + const go = async () => { + const item = itemByHash(hash); + if (!item) return false; + const blob = await itemBlob(item.id); + if (!blob) reject(new Error('no bytes')); + else resolve(await blob.arrayBuffer()); + return true; + }; + go().then((done) => { + if (done) return; + watchExplorer(); + const list = awaitingHash.get(hash) ?? []; + list.push(() => go()); + awaitingHash.set(hash, list); + import('./assetShare').then((m) => m.requestAsset(hash)).catch(() => {}); + }); + }); +} + +/** + * Build level `i` of `entry` (async). The state lands in `entry.built` under the level's + * content key, so a threshold drag or a re-mode never rebuilds it. + * @param {GroupEntry} entry @param {number} i + */ +function requestLevel(entry, i) { + const level = entry.block.levels[i]; + if (!level || i === 0) return; + const key = levelKey(level); + if (entry.built.has(key)) return; + entry.built.set(key, { status: 'loading' }); + const meshKey = entry.meshKey; + /** @param {LevelState} state */ + const land = (state) => { + // the entry was rebuilt (new meshes, removed) while this ran: drop the result + if (entries.get(entry.root) !== entry || entry.meshKey !== meshKey || entry.built.get(key)?.status !== 'loading') { + if (state.status === 'ready' && state.kind === 'swap') { + for (const g of state.owned) g.dispose(); + for (const r of state.releases) r(); + } + return; + } + entry.built.set(key, state); + tick(); + }; + /** @param {any} error */ + const fail = (error) => { + console.log('LOD level could not be built (' + level.source + ' ' + (level.ref ?? level.ratio ?? '') + '):', error?.message ?? error); + land({ status: 'failed', error: String(error?.message ?? error) }); + }; + if (level.source === 'pack') { + const url = packLevelUrl(entry.root, /** @type {string} */ (level.ref)); + if (!url) return fail('no pack reference to resolve "' + level.ref + '" against'); + loadPackFile(url) + .then((scene) => { + const paired = pairLevel(entry, scene); + if (!paired) throw new Error('none of its meshes match the object'); + land({ status: 'ready', kind: 'swap', pairs: paired.pairs, tris: paired.tris, owned: paired.owned, releases: [] }); + }) + .catch(fail); + } else if (level.source === 'generated') { + const ratio = /** @type {number} */ (level.ratio); + /** @type {(() => void)[]} */ + const releases = []; + Promise.all( + entry.meshes.map((m) => { + if (trianglesOf(m.geometry) < 64) return Promise.resolve([m, null]); + const share = simplifiedGeometry(m.geometry, ratio); + releases.push(share.release); + return share.geometry.then((g) => [m, g]); + }) + ) + .then((results) => { + /** @type {any[]} */ + const pairs = []; + let tris = 0; + for (const [m, g] of results) { + if (g) { + pairs.push(m, m.geometry, g); + tris += trianglesOf(g); + } else tris += trianglesOf(m.geometry); + } + if (!pairs.length) throw new Error('the simplifier could not make it coarser'); + land({ status: 'ready', kind: 'swap', pairs, tris, owned: [], releases }); + }) + .catch((error) => { + for (const r of releases) r(); + fail(error); + }); + } else if (level.source === 'explorer') { + explorerBytes(/** @type {string} */ (level.ref)) + .then((buffer) => parseGlb(buffer)) + .then((scene) => land({ status: 'ready', kind: 'tree', ...asTree(scene) })) + .catch(fail); + } else if (level.source === 'object') { + const target = get(objectsGroup)?.getObjectByProperty('uuid', level.ref); + if (!target) return fail('the object is not in the scene'); + if (target === entry.root || entry.root.getObjectById(target.id)) return fail('an object cannot be its own level'); + // a live share: same geometry and materials, its own placement reset + land({ status: 'ready', kind: 'tree', ...asTree(target.clone(true)) }); + } +} + +/** Free everything an entry built. @param {GroupEntry} entry */ +function disposeBuilt(entry) { + for (const state of entry.built.values()) { + if (state.status !== 'ready') continue; + if (state.kind === 'swap') { + for (const g of state.owned) g.dispose(); + for (const r of state.releases) r(); + } else if (state.kind === 'tree') state.object.removeFromParent(); + } + entry.built.clear(); +} + +// ---- the registry ------------------------------------------------------------------------- + +/** @type {any} */ +let holders = null; +function holderRoot() { + const scene = get(globalScene); + if (!scene) return null; + if (holders && holders.parent === scene) return holders; + holders = new THREE.Group(); + holders.name = 'lod-group-holders'; + holders.matrixAutoUpdate = false; + holders.matrixWorldAutoUpdate = false; + scene.add(holders); + return holders; +} + +/** @param {any} root @param {LodGroup} block @param {boolean} implicit */ +function ensureEntry(root, block, implicit) { + const sig = JSON.stringify(block); + const meshes = sourceMeshes(root); + const meshKey = meshKeyOf(meshes); + const existing = entries.get(root); + if (existing && existing.meshKey === meshKey) { + if (existing.sig !== sig) { + // thresholds/mode/offsets/overrides changed: keep every level whose CONTENT stayed + const keep = new Set(block.levels.map(levelKey)); + for (const [key, state] of [...existing.built]) { + if (keep.has(key)) continue; + if (state.status === 'ready' && state.kind === 'swap') { + for (const g of state.owned) g.dispose(); + for (const r of state.releases) r(); + } else if (state.status === 'ready' && state.kind === 'tree') state.object.removeFromParent(); + existing.built.delete(key); + } + existing.block = block; + existing.sig = sig; + for (const c of existing.matCache.values()) c.clone.dispose(); + existing.matCache.clear(); + tick(); + } + existing.implicit = implicit; + existing.raw = root.userData?.lod; + return existing; + } + if (existing) { + disposeBuilt(existing); + for (const c of existing.matCache.values()) c.clone.dispose(); + } + /** @type {any} */ + const entry = existing ?? { root, current: 0, autoCurrent: 0, size: 0, holder: null, matCache: new Map(), built: new Map() }; + entry.block = block; + entry.sig = sig; + entry.implicit = implicit; + entry.meshes = meshes; + entry.meshKey = meshKey; + entry.matCache = new Map(); + entry.raw = root.userData?.lod; + measure(entry); + entries.set(root, entry); + tick(); + return entry; +} + +/** @param {GroupEntry} entry */ +function dropEntry(entry) { + disposeBuilt(entry); + for (const c of entry.matCache.values()) c.clone.dispose(); + entry.holder?.removeFromParent(); + entries.delete(entry.root); + tick(); +} + +/** The block a root's group runs on: its own, else an implicit pack one, else null. + * @param {any} root @returns {{block: LodGroup, implicit: boolean} | null} */ +export function effectiveGroupOf(root) { + if (!root) return null; + const own = normalizeLodGroup(root.userData?.lod); + if (own) return { block: own, implicit: false }; + const key = rowKeyOf(packRefOf(root)); + const imp = key ? implicitGroups.get(key) : null; + return imp ? { block: imp, implicit: true } : null; +} + +/** Walk objectsGroup: every root with a block (or an implicit pack one) gets an entry. + * Exported for the suite. */ +export function scanLodGroups() { + const group = get(objectsGroup); + /** @type {Set} */ + const seen = new Set(); + group?.traverse((/** @type {any} */ node) => { + if (node === group) return; + if (node.userData?.lod) { + const block = normalizeLodGroup(node.userData.lod); + if (block) { + seen.add(node); + ensureEntry(node, block, false); + } + return; + } + if (node.userData?.packRef && !node.userData.packStub) { + const key = rowKeyOf(packRefOf(node)); + if (!key) return; + if (!implicitGroups.has(key)) { + lookupImplicit(key); + return; + } + const imp = implicitGroups.get(key); + if (imp) { + seen.add(node); + ensureEntry(node, imp, true); + } + } + }); + for (const entry of [...entries.values()]) if (!seen.has(entry.root)) dropEntry(entry); + /** @type {WeakSet} */ + const next = new WeakSet(); + for (const entry of entries.values()) for (const m of entry.meshes) next.add(m); + owned = next; +} + +/** @type {ReturnType | null} */ +let scanTimer = null; +function scheduleScan() { + if (scanTimer) return; + scanTimer = setTimeout(() => { + scanTimer = null; + scanLodGroups(); + }, 60); +} + +// ---- the render pass -------------------------------------------------------------------- + +/** meshes hidden for this pass */ +/** @type {any[]} */ +const hidden = []; +/** [mesh, savedMatrixWorld] for offset levels */ +/** @type {any[]} */ +const moved = []; +/** @type {any[]} */ +const matrixPool = []; +/** holders shown this pass */ +/** @type {any[]} */ +const shown = []; +const _off = new THREE.Matrix4(); +const _rw = new THREE.Matrix4(); +const _rwi = new THREE.Matrix4(); +const _q = new THREE.Quaternion(); +const _e = new THREE.Euler(); +const _p = new THREE.Vector3(); +const _s = new THREE.Vector3(); + +function restorePass() { + for (const m of hidden) m.visible = true; + hidden.length = 0; + for (let i = 0; i < moved.length; i += 2) { + moved[i].matrixWorld.copy(moved[i + 1]); + matrixPool.push(moved[i + 1]); + } + moved.length = 0; + for (const h of shown) h.visible = false; + shown.length = 0; +} + +/** The root-local offset of a level as a matrix (null = none). @param {any} level */ +function offsetMatrix(level) { + const o = level?.offset; + if (!o) return null; + _p.fromArray(o.pos ?? [0, 0, 0]); + const r = o.rot ?? [0, 0, 0]; + _q.setFromEuler(_e.set(r[0], r[1], r[2], 'XYZ')); + _s.fromArray(o.scale ?? [1, 1, 1]); + return _off.compose(_p, _q, _s); +} + +/** A level's material override for one source material (cloned once, re-cloned when the + * source material changes, so an LOD0 colour edit still reaches the level). + * @param {any} entry @param {number} i @param {any} material */ +function overrideFor(entry, i, material) { + const o = entry.block.levels[i]?.material; + if (!o || !material || Array.isArray(material)) return null; + const key = i + ':' + material.uuid; + let c = entry.matCache.get(key); + if (!c || c.version !== material.version) { + c?.clone.dispose(); + const clone = material.clone(); + if (o.color && clone.color) clone.color.set(o.color); + if (o.roughness !== undefined && 'roughness' in clone) clone.roughness = o.roughness; + if (o.metalness !== undefined && 'metalness' in clone) clone.metalness = o.metalness; + c = { clone, version: material.version }; + entry.matCache.set(key, c); + } + return c.clone; +} + +/** The nearest level at or finer than `want` that is built (0 always is). @param {GroupEntry} entry @param {number} want */ +function nearestBuilt(entry, want) { + for (let i = want; i > 0; i--) { + const state = entry.built.get(levelKey(entry.block.levels[i])); + if (state?.status === 'ready') return i; + if (!state) requestLevel(entry, i); + } + return 0; +} + +/** + * Called by lod.js's scene.onBeforeRender. Allocation-free on the steady path. + * @param {any} camera @param {number} qualityBias @param {boolean} enabled @param {boolean} overlay + */ +function before(camera, qualityBias, enabled, overlay) { + restorePass(); // a render that threw last time left a pass behind + if (!entries.size) return; + const P = camera.projectionMatrix?.elements; + if (!P) return; + const p5 = Math.abs(P[5]) || 1; + const ortho = P[15] === 1; + const ce = camera.matrixWorld.elements; + for (const entry of entries.values()) { + const root = entry.root; + if (!root.parent || !root.visible) continue; + // a block written since the last scan (the panel, undo, a peer) takes effect THIS frame + const raw = root.userData?.lod; + if (raw !== entry.raw) { + const next = raw ? normalizeLodGroup(raw) : null; + if (next) ensureEntry(root, next, false); + else if (!entry.implicit) { + // removed: the next scan drops it (or puts the pack's implicit group back) + entry.raw = raw; + scheduleScan(); + continue; + } + } + const block = entry.block; + // the screen size of the root's bounding sphere + const e = root.matrixWorld.elements; + const c = entry.center; + const wx = e[0] * c.x + e[4] * c.y + e[8] * c.z + e[12]; + const wy = e[1] * c.x + e[5] * c.y + e[9] * c.z + e[13]; + const wz = e[2] * c.x + e[6] * c.y + e[10] * c.z + e[14]; + const scale = Math.sqrt(Math.max(e[0] * e[0] + e[1] * e[1] + e[2] * e[2], e[4] * e[4] + e[5] * e[5] + e[6] * e[6], e[8] * e[8] + e[9] * e[9] + e[10] * e[10])); + const radius = entry.radius * scale; + const dx = wx - ce[12]; + const dy = wy - ce[13]; + const dz = wz - ce[14]; + const d = Math.sqrt(dx * dx + dy * dy + dz * dz); + const size = ortho ? radius * p5 : d <= radius ? 2 : (radius * p5) / d; + entry.size = size; + let auto = 0; + if (enabled) { + const t = thresholdsOf(block, qualityBias); + auto = pickGroupLevel(size, t.thresholds, entry.autoCurrent, { cull: !!block.cull, scale: t.scale }); + } + entry.autoCurrent = auto; + let want = auto; + if (preview && preview.uuid === root.uuid && preview.level < block.levels.length) want = preview.level; + else if (block.mode === 'forced' && block.forced !== undefined) want = block.forced; + const drawn = want < 0 ? -1 : nearestBuilt(entry, want); + if (drawn !== entry.current) { + entry.current = drawn; + tick(); + } + apply(entry, drawn, overlay); + } +} + +/** @param {GroupEntry} entry @param {number} drawn @param {boolean} overlay */ +function apply(entry, drawn, overlay) { + if (drawn < 0) { + for (const m of entry.meshes) if (m.visible) hideForPass(m); + return; + } + if (drawn === 0) { + if (overlay) for (const m of entry.meshes) if (m.visible) swapMaterialForPass(m, overlayMaterial(0)); + return; + } + const level = entry.block.levels[drawn]; + const state = /** @type {any} */ (entry.built.get(levelKey(level))); + const off = offsetMatrix(level); + if (off) { + _rw.copy(entry.root.matrixWorld); + _rwi.copy(_rw).invert(); + // R · O · R⁻¹: the offset is in the ROOT's frame, applied to each world matrix + _off.premultiply(_rw).multiply(_rwi); + } + if (state.kind === 'swap') { + const pairs = state.pairs; + for (let i = 0; i < pairs.length; i += 3) { + const m = pairs[i]; + // edited since the level was built (a sculpt, a meshgeo): the level is stale, + // draw the source until the next scan rebuilds it + if (m.geometry !== pairs[i + 1] || !m.visible) continue; + swapGeometryForPass(m, pairs[i + 2]); + const o = overrideFor(entry, drawn, m.material); + if (o) swapMaterialForPass(m, o); + if (overlay) swapMaterialForPass(m, overlayMaterial(drawn)); + if (off) moveForPass(m, _off); + } + return; + } + // a TREE level: the substitute draws, the source does not + for (const m of entry.meshes) if (m.visible) hideForPass(m); + const holders = holderRoot(); + if (!holders) return; + const object = state.object; + if (object.parent !== holders) holders.add(object); + object.matrixAutoUpdate = false; + object.matrix.copy(entry.root.matrixWorld); + if (level.offset) object.matrix.multiply(/** @type {any} */ (offsetMatrix(level))); + object.visible = true; + object.updateMatrixWorld(true); + shown.push(object); + if (overlay || level.material) + object.traverse((/** @type {any} */ n) => { + if (!n.isMesh) return; + const o = overrideFor(entry, drawn, n.material); + if (o) swapMaterialForPass(n, o); + if (overlay) swapMaterialForPass(n, overlayMaterial(drawn)); + }); +} + +/** @param {any} m */ +function hideForPass(m) { + m.visible = false; + hidden.push(m); +} + +/** @param {any} m @param {any} matrix R·O·R⁻¹ */ +function moveForPass(m, matrix) { + const saved = matrixPool.pop() ?? new THREE.Matrix4(); + saved.copy(m.matrixWorld); + moved.push(m, saved); + m.matrixWorld.premultiply(matrix); +} + +/** Called by lod.js's scene.onAfterRender. */ +function after() { + restorePass(); +} + +/** @param {any} mesh */ +function owns(mesh) { + return owned.has(mesh); +} + +// ---- reads for the panel, the VR panel and the suite -------------------------------------- + +/** + * What the LOD panel shows for one object: the block it runs on (own or implicit), and per + * level its source, build status and triangles; the drawn level and the last screen size. + * @param {string} uuid + */ +export function lodGroupInfo(uuid) { + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + const eff = effectiveGroupOf(root); + if (!root || !eff) return null; + const entry = entries.get(root); + const tris0 = (entry?.meshes ?? sourceMeshes(root)).reduce((n, m) => n + trianglesOf(m.geometry), 0); + return { + uuid, + block: eff.block, + implicit: eff.implicit, + current: entry?.current ?? 0, + size: entry?.size ?? 0, + levels: eff.block.levels.map((level, i) => { + if (i === 0) return { ...level, status: 'ready', tris: tris0, error: null, kind: null }; + const state = /** @type {any} */ (entry?.built.get(levelKey(level))); + return { ...level, status: state?.status ?? 'idle', tris: state?.tris ?? null, error: state?.error ?? null, kind: state?.kind ?? null }; + }) + }; +} + +/** Build every level of one group now (the panel's "Build all", the suite). @param {string} uuid */ +export function buildAllLevels(uuid) { + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + scanLodGroups(); + const entry = root ? entries.get(root) : null; + if (!entry) return false; + entry.block.levels.forEach((_, i) => requestLevel(entry, i)); + return true; +} + +/** Every group, for the probe and the suite. */ +export function lodGroupStats() { + return [...entries.values()].map((e) => ({ + uuid: e.root.uuid, + name: e.root.name, + implicit: e.implicit, + meshes: e.meshes.length, + current: e.current, + size: Number(e.size.toFixed(4)), + radius: Number(e.radius.toFixed(4)), + levels: e.block.levels.map((l, i) => { + const s = /** @type {any} */ (i === 0 ? { status: 'ready' } : e.built.get(levelKey(l))); + return { source: l.source, status: s?.status ?? 'idle', tris: s?.tris ?? null, kind: s?.kind ?? null }; + }) + })); +} + +/** Is this mesh drawn by a LOD group? (lod.js asks, the overlay legend reads it) */ +export { owns as ownedByLodGroup }; + +let started = false; +/** Boot (App). Idempotent. */ +export function startLodGroups() { + if (started || typeof window === 'undefined') return; + started = true; + registerLodPass({ before, after, owns }); + objectsGroup.subscribe(() => scheduleScan()); + setInterval(scanLodGroups, 2000); + scheduleScan(); +} + +/** TEST-ONLY seams. */ +export const lodGroupForTest = { + reset() { + for (const entry of [...entries.values()]) dropEntry(entry); + implicitGroups.clear(); + listFetches.clear(); + }, + entries: () => entries +}; diff --git a/src/lib/lodGroupActions.js b/src/lib/lodGroupActions.js new file mode 100644 index 00000000..d16332ab --- /dev/null +++ b/src/lib/lodGroupActions.js @@ -0,0 +1,156 @@ +import { get } from 'svelte/store'; +import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { peers } from '../stores/appStore'; +import { recordEntry } from './history'; +import { normalizeLodGroup, generatedGroup, withThreshold } from './lodGroupCore'; +import { effectiveGroupOf } from './lodGroup'; +import { canEditObject, warnViewerReadOnly } from './objectPermissions'; + +// 33 (K6) — THE ONE WRITE PATH for a LOD group block (`userData.lod`), the setPickThrough / +// setCameraFor shape: apply locally, ONE `props` undo entry (objectActions' `props` kind +// carries a `lod` key), ONE replicated `objectParameters {parameter: 'lod'}` message, a +// poke so the runtime (lodGroup.js) and the panel re-read. A GESTURE (a threshold drag) +// calls `previewLodGroup` per move — local only, nothing recorded or sent — and commits +// with `setLodGroup(uuid, block, {before})` on release, so a drag is one entry and one +// message (the begin/end-gesture rule every scrubbed field here keeps). + +/** @param {string} uuid */ +function objectOf(uuid) { + return get(objectsGroup)?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** The block as it is stored now (never the implicit pack one — that was never written). + * @param {any} object */ +function storedOf(object) { + return object?.userData?.lod ? normalizeLodGroup(object.userData.lod) : null; +} + +/** + * Write a group block. `null` removes it (back to the pack's implicit group / auto LOD). + * `opts.before` names the state an undo returns to when a gesture already changed the + * object locally (previewLodGroup); `opts.record: false` skips the undo entry. + * @param {string} uuid @param {any} block @param {{before?: any, record?: boolean}} [opts] + * @returns {boolean} whether anything changed + */ +export function setLodGroup(uuid, block, opts = {}) { + const object = objectOf(uuid); + if (!object) return false; + if (!canEditObject(object)) { + warnViewerReadOnly(); + return false; + } + const before = opts.before !== undefined ? normalizeLodGroup(opts.before) : storedOf(object); + const next = block ? normalizeLodGroup(block) : null; + if (JSON.stringify(before) === JSON.stringify(next) && JSON.stringify(storedOf(object)) === JSON.stringify(next)) return false; + if (next) object.userData.lod = next; + else delete object.userData.lod; + if (opts.record !== false && JSON.stringify(before) !== JSON.stringify(next)) recordEntry({ kind: 'props', uuid, before: { lod: before }, after: { lod: next } }); + /** @type {any} */ + const peer = get(peers); + peer?.send?.({ type: 'objectParameters', parameter: 'lod', uuid, lod: next }); + pokeScene(); + return true; +} + +/** A gesture frame: write the block locally only (no undo, no message). @param {string} uuid @param {any} block */ +export function previewLodGroup(uuid, block) { + const object = objectOf(uuid); + const next = block ? normalizeLodGroup(block) : null; + if (!object || !next) return false; + object.userData.lod = next; + pokeScene(); + return true; +} + +/** The block an edit starts from: the stored one, else the implicit pack one, else none. + * @param {string} uuid */ +export function editableGroupOf(uuid) { + const object = objectOf(uuid); + return effectiveGroupOf(object)?.block ?? null; +} + +/** Force LOD: 'auto' or a level index. @param {string} uuid @param {'auto' | number} level */ +export function forceLodLevel(uuid, level) { + const block = editableGroupOf(uuid); + if (!block) return false; + const next = level === 'auto' ? { ...block, mode: 'auto', forced: undefined } : { ...block, mode: 'forced', forced: level }; + return setLodGroup(uuid, next); +} + +/** + * Generate levels: a meshopt level per ratio (the runtime builds them in the worker). An + * existing group keeps its LOD0 threshold, its pack/replaced levels are REPLACED by the + * generated set — "Generate" means "these are the levels now", one undo away from the old. + * @param {string} uuid @param {number[]} [ratios] + */ +export function generateLodLevels(uuid, ratios = [0.5, 0.25, 0.1]) { + const fresh = generatedGroup(ratios); + const old = editableGroupOf(uuid); + if (old) { + fresh.levels[0].screenSize = old.levels[0].screenSize; + if (old.cull) fresh.cull = true; + if (old.bias) fresh.bias = old.bias; + } + return setLodGroup(uuid, fresh); +} + +/** Change one level (its ratio, source, offset, material override…). `patch` keys with + * value undefined are REMOVED. @param {string} uuid @param {number} i @param {any} patch + * @param {{before?: any, record?: boolean, preview?: boolean}} [opts] */ +export function updateLodLevel(uuid, i, patch, opts = {}) { + const block = editableGroupOf(uuid); + if (!block?.levels[i]) return false; + const levels = block.levels.map((l, k) => { + if (k !== i) return l; + /** @type {any} */ + const next = { ...l, ...patch }; + for (const key of Object.keys(patch)) if (patch[key] === undefined) delete next[key]; + return next; + }); + const next = { ...block, levels }; + return opts.preview ? previewLodGroup(uuid, next) : setLodGroup(uuid, next, opts); +} + +/** Replace level `i`'s content with another model: an Explorer item (by content hash) or + * another scene object (by uuid). Offset/override are kept. @param {string} uuid @param {number} i + * @param {{source: 'explorer' | 'object' | 'generated' | 'pack', ref?: string, name?: string, ratio?: number}} with_ */ +export function replaceLodLevel(uuid, i, with_) { + if (i === 0) return false; // LOD0 is the object itself — edit it as the object + if ((with_.source === 'explorer' || with_.source === 'object') && !with_.ref) return false; + return updateLodLevel(uuid, i, { source: with_.source, ref: with_.ref, name: with_.name, ratio: with_.ratio }); +} + +/** Add a level after the last (generated at half the last level's ratio). @param {string} uuid */ +export function addLodLevel(uuid) { + const block = editableGroupOf(uuid) ?? generatedGroup([]); + const last = block.levels[block.levels.length - 1]; + const ratio = Math.max(0.02, Number(((last.ratio ?? 1) * 0.5).toFixed(3))); + const screenSize = Math.max(0.006, last.screenSize * 0.4); + return setLodGroup(uuid, { ...block, levels: [...block.levels, { source: 'generated', ratio, screenSize }] }); +} + +/** Remove level `i` (never LOD0). A forced level past the end falls back to auto. @param {string} uuid @param {number} i */ +export function removeLodLevel(uuid, i) { + const block = editableGroupOf(uuid); + if (!block || i <= 0 || !block.levels[i]) return false; + const levels = block.levels.filter((_, k) => k !== i); + if (levels.length < 2) return setLodGroup(uuid, null); + return setLodGroup(uuid, { ...block, levels }); +} + +/** The bar drag: a frame moves threshold `i` locally; release commits ONE entry. + * @param {string} uuid @param {number} i @param {number} value @param {{commit?: boolean, before?: any}} [opts] */ +export function dragLodThreshold(uuid, i, value, opts = {}) { + const block = editableGroupOf(uuid); + if (!block) return false; + const next = withThreshold(block, i, value); + return opts.commit ? setLodGroup(uuid, next, { before: opts.before }) : previewLodGroup(uuid, next); +} + +/** Replay for the `props` undo kind and the remote applier: write the block as given. + * @param {any} object @param {any} lod */ +export function writeLodBlock(object, lod) { + const next = lod ? normalizeLodGroup(lod) : null; + if (next) object.userData.lod = next; + else delete object.userData.lod; +} diff --git a/src/lib/lodGroupCore.js b/src/lib/lodGroupCore.js new file mode 100644 index 00000000..87b63afe --- /dev/null +++ b/src/lib/lodGroupCore.js @@ -0,0 +1,268 @@ +// 33 — THE LOD GROUP RULE. PURE and import-free, the lodCore.js shape: what a per-object +// `userData.lod` block may hold, which level a group draws for a given screen size, and how +// a pack item's `lods` row becomes a block. The runtime (`lodGroup.js`) owns the geometry, +// the fetches and the render-time swap; this file is arithmetic, so vitest drives it. +// +// THE MODEL IS UNITY'S LOD GROUP. Each level carries a `screenSize` — the share of the +// viewport HEIGHT the object's bounding sphere covers — and level i draws while the object +// is at least that big. Going coarser happens at the threshold; coming back finer needs the +// size back ABOVE it by `hysteresis` of it, so an object sitting on an edge while the camera +// bobs does not pop every frame (the "flickers only while moving" signature). The LAST +// level's threshold is the cull height and only means something when `cull` is on. +// +// Screen size, not distance in radii (31-perf's auto rule): a person tuning a group reads +// "draw the low level once it is a tenth of the screen", which is what every DCC shows on +// its LOD bar, and it already folds in the lens (a headset's wide eye makes everything +// smaller, so it steps down sooner — the right way round for a Quest). + +/** @typedef {'self' | 'pack' | 'generated' | 'explorer' | 'object'} LodSource */ +/** + * @typedef {{pos?: number[], rot?: number[], scale?: number[]}} LodOffset + * @typedef {{color?: string, roughness?: number, metalness?: number}} LodMaterial + * @typedef {{source: LodSource, ref?: string, name?: string, ratio?: number, screenSize: number, + * offset?: LodOffset, material?: LodMaterial}} LodLevel + * @typedef {{mode: 'auto' | 'forced', forced?: number, bias?: number, cull?: boolean, levels: LodLevel[]}} LodGroup + */ + +export const LOD_SOURCES = ['self', 'pack', 'generated', 'explorer', 'object']; + +/** The most levels a group may hold (LOD0 included) — a bar with more is unreadable, and + * no real asset ships more than four. */ +export const MAX_LEVELS = 6; + +/** Going finer needs the size back above the threshold by this share of it. */ +export const LOD_HYSTERESIS = 0.15; + +/** Thresholds never come closer than this (a zero-width segment cannot be dragged back). */ +export const MIN_GAP = 0.005; + +/** Default thresholds for a group of `n` levels: LOD0 until 25% of the screen height, LOD1 + * until 10%, then each level until 40% of the previous. 25% / 10% are ALSO the sizes the pack + * tool (33-pack-fix-lod) renders its no-visible-pop gate at, so keep them in step. + * @param {number} n @returns {number[]} */ +export function defaultScreenSizes(n) { + /** @type {number[]} */ + const out = []; + let s = 0.25; + for (let i = 0; i < n; i++) { + out.push(Number(s.toFixed(4))); + s *= 0.4; + } + return out; +} + +/** @param {any} v @param {number} lo @param {number} hi */ +const clampNum = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +/** @param {any} v */ +const finite = (v) => typeof v === 'number' && Number.isFinite(v); +/** @param {any} a @param {number} n */ +const vecOf = (a, n) => (Array.isArray(a) && a.length >= n && a.slice(0, n).every(finite) ? a.slice(0, n).map(Number) : null); + +/** @param {any} o @returns {LodOffset | undefined} */ +function normalizeOffset(o) { + if (!o || typeof o !== 'object') return undefined; + const pos = vecOf(o.pos, 3); + const rot = vecOf(o.rot, 3); + const scale = vecOf(o.scale, 3); + /** @type {LodOffset} */ + const out = {}; + if (pos && pos.some((v) => v !== 0)) out.pos = pos; + if (rot && rot.some((v) => v !== 0)) out.rot = rot; + if (scale && scale.some((v) => v !== 1) && scale.every((v) => v !== 0)) out.scale = scale; + return Object.keys(out).length ? out : undefined; +} + +/** @param {any} m @returns {LodMaterial | undefined} */ +function normalizeMaterial(m) { + if (!m || typeof m !== 'object') return undefined; + /** @type {LodMaterial} */ + const out = {}; + if (typeof m.color === 'string' && /^#[0-9a-f]{6}$/i.test(m.color)) out.color = m.color.toLowerCase(); + if (finite(m.roughness)) out.roughness = clampNum(m.roughness, 0, 1); + if (finite(m.metalness)) out.metalness = clampNum(m.metalness, 0, 1); + return Object.keys(out).length ? out : undefined; +} + +/** + * The one boundary. Returns null for anything that is not a usable group (so a hostile or + * truncated block is the same as no block — today's behaviour). Level 0 is always `self`; + * thresholds are forced strictly DESCENDING with at least MIN_GAP between them; a forced + * level outside the list falls back to auto. + * @param {any} block @returns {LodGroup | null} + */ +export function normalizeLodGroup(block) { + if (!block || typeof block !== 'object' || !Array.isArray(block.levels)) return null; + /** @type {LodLevel[]} */ + const levels = []; + for (const raw of block.levels) { + if (!raw || typeof raw !== 'object') continue; + const source = LOD_SOURCES.includes(raw.source) ? raw.source : null; + if (!source) continue; + if (levels.length === 0 && source !== 'self') levels.push({ source: 'self', screenSize: 0.25 }); + else if (levels.length > 0 && source === 'self') continue; // only LOD0 is the object itself + /** @type {LodLevel} */ + const level = { source, screenSize: finite(raw.screenSize) ? clampNum(raw.screenSize, 0, 1) : NaN }; + if (source === 'pack' || source === 'explorer' || source === 'object') { + if (typeof raw.ref !== 'string' || !raw.ref || raw.ref.length > 512) continue; + level.ref = raw.ref; + } + if (typeof raw.name === 'string' && raw.name) level.name = raw.name.slice(0, 120); + if (finite(raw.ratio) && raw.ratio > 0 && raw.ratio <= 1) level.ratio = Number(raw.ratio); + else if (source === 'generated') continue; // a generated level IS its ratio + const offset = normalizeOffset(raw.offset); + if (offset) level.offset = offset; + const material = normalizeMaterial(raw.material); + if (material) level.material = material; + levels.push(level); + if (levels.length >= MAX_LEVELS) break; + } + if (!levels.length) return null; + // thresholds: fill the gaps from the defaults, then force strictly descending + const defaults = defaultScreenSizes(levels.length); + let previous = 1 + MIN_GAP; + levels.forEach((level, i) => { + let s = Number.isFinite(level.screenSize) ? level.screenSize : defaults[i]; + const ceiling = previous - MIN_GAP; + if (s > ceiling) s = ceiling; + if (s < 0) s = 0; + level.screenSize = Number(s.toFixed(4)); + previous = level.screenSize; + }); + /** @type {LodGroup} */ + const out = { mode: block.mode === 'forced' ? 'forced' : 'auto', levels }; + if (out.mode === 'forced') { + const f = Number(block.forced); + if (Number.isInteger(f) && f >= 0 && f < levels.length) out.forced = f; + else out.mode = 'auto'; + } + if (finite(block.bias) && block.bias > 0 && block.bias !== 1) out.bias = clampNum(block.bias, 0.1, 10); + if (block.cull === true) out.cull = true; + return out; +} + +/** + * The share of the viewport HEIGHT a bounding sphere covers. Perspective: r / (d·tan(fov/2)); + * orthographic: 2r / (top - bottom) / zoom. Clamped to [0, 1+] — standing inside the sphere + * reads as filling the screen. + * @param {number} radius world radius @param {number} distance world distance camera -> centre + * @param {{fov?: number, isOrthographicCamera?: boolean, top?: number, bottom?: number, zoom?: number}} camera + */ +export function screenSizeOf(radius, distance, camera) { + if (!(radius > 0)) return 0; + if (camera?.isOrthographicCamera) { + const h = Math.abs((camera.top ?? 1) - (camera.bottom ?? -1)) / (camera.zoom || 1); + return h > 0 ? (2 * radius) / h : 0; + } + if (!(distance > radius)) return 2; + const fov = finite(camera?.fov) ? /** @type {number} */ (camera?.fov) : 50; + const t = Math.tan((fov * Math.PI) / 360); + return t > 0 ? radius / (distance * t) : 2; +} + +/** + * The level to draw at `size` (a screenSizeOf value): 0..n-1, or -1 = culled. `current` is + * last frame's pick (the hysteresis band), `scale` multiplies every threshold DOWN-side + * (quality bias and the group's own bias fold in here: a threshold of 0.12 at scale 0.5 is + * met at a size of 0.06 — detail kept longer). + * @param {number} size @param {number[]} thresholds descending @param {number} current + * @param {{cull?: boolean, scale?: number, hysteresis?: number}} [opts] + */ +export function pickGroupLevel(size, thresholds, current, opts = {}) { + const n = thresholds.length; + if (!n || !Number.isFinite(size)) return 0; + const scale = finite(opts.scale) && /** @type {number} */ (opts.scale) > 0 ? /** @type {number} */ (opts.scale) : 1; + const h = finite(opts.hysteresis) ? /** @type {number} */ (opts.hysteresis) : LOD_HYSTERESIS; + const cull = !!opts.cull; + // the deepest level the object is allowed to sit on: past the last threshold it is either + // culled or stays on the last level + const deepest = cull ? n : n - 1; + let level = 0; + for (let i = 0; i < deepest; i++) { + const edge = thresholds[i] * scale; + // leaving level i for something coarser needs the size under the edge; once coarser, + // coming back above it needs the band + const band = current > i || current === -1 ? edge * (1 + h) : edge; + if (size < band) level = i + 1; + else break; + } + return level >= n ? -1 : level; +} + +/** Overlay colours, one per level (LOD0 green ... coarse red), and the culled grey. The + * DCC convention (Unity's LOD bar uses the same ramp). */ +export const LEVEL_COLORS = ['#3fb950', '#d4b106', '#e07b1f', '#d23f3f', '#a052d9', '#3b82f6']; +/** @param {number} level */ +export function levelColor(level) { + return level < 0 ? '#6b7280' : LEVEL_COLORS[Math.min(level, LEVEL_COLORS.length - 1)]; +} + +/** + * A pack item row's `lods` (contract P1) as [{file, ratio}], finest first. Files are + * relative to the item's glTF-Binary folder; a path that climbs out of it is refused. + * @param {any} row @returns {{file: string, ratio: number}[]} + */ +export function packLodsOf(row) { + const list = Array.isArray(row?.lods) ? row.lods : []; + /** @type {{file: string, ratio: number}[]} */ + const out = []; + for (const l of list) { + const file = typeof l?.file === 'string' ? l.file.trim() : ''; + if (!file || file.includes('..') || file.startsWith('/') || /^[a-z]+:/i.test(file)) continue; + const ratio = Number(l.ratio); + out.push({ file, ratio: Number.isFinite(ratio) && ratio > 0 && ratio <= 1 ? ratio : 0 }); + } + // finest first: a row listed out of order still reads LOD1 before LOD2 + return out.sort((a, b) => (b.ratio || 0) - (a.ratio || 0)).slice(0, MAX_LEVELS - 1); +} + +/** The group a placed pack piece starts with: itself + every listed file. @param {any} row + * @returns {LodGroup | null} */ +export function groupFromPackLods(row) { + const lods = packLodsOf(row); + if (!lods.length) return null; + const sizes = defaultScreenSizes(lods.length + 1); + return normalizeLodGroup({ + mode: 'auto', + levels: [{ source: 'self', screenSize: sizes[0] }, ...lods.map((l, i) => ({ source: 'pack', ref: l.file, ...(l.ratio ? { ratio: l.ratio } : {}), screenSize: sizes[i + 1] }))] + }); +} + +/** A generated group (meshopt levels) at the given ratios. @param {number[]} [ratios] + * @returns {LodGroup} */ +export function generatedGroup(ratios = [0.5, 0.25, 0.1]) { + const keep = ratios.filter((r) => finite(r) && r > 0 && r < 1).slice(0, MAX_LEVELS - 1); + const sizes = defaultScreenSizes(keep.length + 1); + return /** @type {LodGroup} */ ( + normalizeLodGroup({ mode: 'auto', levels: [{ source: 'self', screenSize: sizes[0] }, ...keep.map((ratio, i) => ({ source: 'generated', ratio, screenSize: sizes[i + 1] }))] }) + ); +} + +/** + * Move threshold `i` to `value`, keeping the bar ordered: it may not cross its neighbours + * (MIN_GAP from each). Returns a NEW normalized block. + * @param {LodGroup} block @param {number} i @param {number} value + */ +export function withThreshold(block, i, value) { + const levels = block.levels.map((l) => ({ ...l })); + if (!levels[i] || !finite(value)) return block; + const upper = i > 0 ? levels[i - 1].screenSize - MIN_GAP : 1; + const lower = i < levels.length - 1 ? levels[i + 1].screenSize + MIN_GAP : 0; + levels[i].screenSize = clampNum(value, lower, Math.max(lower, upper)); + return /** @type {LodGroup} */ (normalizeLodGroup({ ...block, levels })); +} + +/** + * The group's thresholds as one list, plus the scale the runtime multiplies them by. + * @param {LodGroup} block @param {number} qualityBias the governor's lodBias (1 = full, < 1 = coarser sooner) + */ +export function thresholdsOf(block, qualityBias = 1) { + const q = finite(qualityBias) && qualityBias > 0 ? qualityBias : 1; + const b = finite(block.bias) && /** @type {number} */ (block.bias) > 0 ? /** @type {number} */ (block.bias) : 1; + // a governor step pulls detail in (threshold up); the group's own bias > 1 keeps it longer + return { thresholds: block.levels.map((l) => l.screenSize), scale: 1 / (q * b) }; +} + +/** Is `a` the same group as `b`? (an applier's idempotence test) @param {any} a @param {any} b */ +export function sameGroup(a, b) { + return JSON.stringify(normalizeLodGroup(a)) === JSON.stringify(normalizeLodGroup(b)); +} diff --git a/src/lib/lodLevelEdit.js b/src/lib/lodLevelEdit.js new file mode 100644 index 00000000..57ccb38d --- /dev/null +++ b/src/lib/lodLevelEdit.js @@ -0,0 +1,142 @@ +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { TControls, globalScene, objectsGroup, selectedObjects } from '../stores/sceneStore'; +import { editableGroupOf, updateLodLevel } from './lodGroupActions'; +import { lodPreview } from './lodGroup'; + +// 33 (K6) — THE GIZMO ON ONE LOD LEVEL. A level's placement relative to its object is the +// level's `offset` (root-local), so a level that came out of the simplifier a hair off, or a +// replacement model authored around another origin, can be lined up by eye — the way a DCC +// lets you select a LOD and move it. The gizmo sits on a scene-root PROXY +// (`userData.isLodLevelProxy`, the spline/vertex-proxy shape: Scene.svelte routes its +// objectChange and dragging-changed here and broadcasts no `move`): every frame of a drag +// writes the offset LOCALLY (previewLodGroup), the release commits ONE `lod` write with the +// pre-drag block as its undo state. The level is previewed on this screen while the gizmo +// is on it (lodPreview), so you see the level you are moving. + +/** {uuid, level} while a level carries the gizmo. LOCAL. @type {import('svelte/store').Writable<{uuid: string, level: number} | null>} */ +export const lodLevelGizmo = writable(null); + +/** @type {any} */ +let proxy = null; +/** @type {{uuid: string, level: number} | null} */ +let target = null; +/** @type {any} */ +let gestureBefore = null; + +const _m = new THREE.Matrix4(); +const _o = new THREE.Matrix4(); +const _p = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const _s = new THREE.Vector3(); +const _e = new THREE.Euler(); + +/** @param {string} uuid */ +function rootOf(uuid) { + return get(objectsGroup)?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** The level's offset as a matrix. @param {any} level */ +function offsetOf(level) { + const o = level?.offset ?? {}; + const r = o.rot ?? [0, 0, 0]; + return _o.compose(_p.fromArray(o.pos ?? [0, 0, 0]), _q.setFromEuler(_e.set(r[0], r[1], r[2], 'XYZ')), _s.fromArray(o.scale ?? [1, 1, 1])); +} + +/** + * Put the gizmo on level `level` of object `uuid` (never LOD0 — that is the object, and + * the object's own gizmo moves it). Returns false when there is nothing to move. + * @param {string} uuid @param {number} level + */ +export function startLevelGizmo(uuid, level) { + const root = rootOf(uuid); + const block = editableGroupOf(uuid); + /** @type {any} */ + const controls = get(TControls); + const scene = get(globalScene); + if (!root || !block?.levels[level] || level <= 0 || !controls || !scene) return false; + stopLevelGizmo({ reattach: false }); + proxy = new THREE.Object3D(); + proxy.name = 'lod-level-proxy'; + proxy.userData = { isLodLevelProxy: true }; + root.updateWorldMatrix(true, false); + _m.multiplyMatrices(root.matrixWorld, offsetOf(block.levels[level])); + _m.decompose(proxy.position, proxy.quaternion, proxy.scale); + scene.add(proxy); + target = { uuid, level }; + controls.attach(proxy); + controls.visible = true; + lodLevelGizmo.set(target); + lodPreview.set({ uuid, level }); + return true; +} + +/** Take the gizmo off the level; by default it goes back on the object. @param {{reattach?: boolean}} [opts] */ +export function stopLevelGizmo(opts = {}) { + /** @type {any} */ + const controls = get(TControls); + const was = target; + if (proxy) { + if (controls?.object === proxy) controls.detach(); + proxy.removeFromParent(); + } + proxy = null; + target = null; + gestureBefore = null; + if (was) { + lodLevelGizmo.set(null); + if (opts.reattach !== false) { + const root = rootOf(was.uuid); + if (root && controls && get(selectedObjects).includes(was.uuid)) { + controls.attach(root); + controls.visible = true; + } + } + } +} + +/** The proxy's pose as the level's root-local offset. */ +function currentOffset() { + if (!proxy || !target) return null; + const root = rootOf(target.uuid); + if (!root) return null; + root.updateWorldMatrix(true, false); + proxy.updateMatrixWorld(true); + _m.copy(root.matrixWorld).invert().multiply(proxy.matrixWorld); + _m.decompose(_p, _q, _s); + _e.setFromQuaternion(_q, 'XYZ'); + const r6 = (/** @type {number} */ v) => Math.round(v * 1e6) / 1e6; + return { pos: [r6(_p.x), r6(_p.y), r6(_p.z)], rot: [r6(_e.x), r6(_e.y), r6(_e.z)], scale: [r6(_s.x), r6(_s.y), r6(_s.z)] }; +} + +/** Scene.svelte's objectChange for the proxy: the level follows live, locally. */ +export function onLodProxyMoved() { + const offset = currentOffset(); + if (!offset || !target) return; + updateLodLevel(target.uuid, target.level, { offset }, { preview: true }); +} + +/** Scene.svelte's dragging-changed for the proxy: release = ONE undoable, replicated write. + * @param {boolean} dragging */ +export function onLodProxyDragChanged(dragging) { + if (!target) return; + if (dragging) { + gestureBefore = JSON.parse(JSON.stringify(editableGroupOf(target.uuid))); + return; + } + const offset = currentOffset(); + if (offset) updateLodLevel(target.uuid, target.level, { offset }, { before: gestureBefore }); + gestureBefore = null; +} + +/** Put level `level` back on its object (an undoable write). @param {string} uuid @param {number} level */ +export function resetLevelOffset(uuid, level) { + const ok = updateLodLevel(uuid, level, { offset: undefined }); + if (target && target.uuid === uuid && target.level === level) startLevelGizmo(uuid, level); + return ok; +} + +// a new selection takes the gizmo with it: drop the proxy without fighting for the gizmo +selectedObjects.subscribe((set) => { + if (target && !set.includes(target.uuid)) stopLevelGizmo({ reattach: false }); +}); diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 66f79681..e146866d 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -747,6 +747,12 @@ registerHistoryKind('props', (entry, state) => { else delete object.userData.pick; if (peer) peer.send({ type: 'objectParameters', parameter: 'pick', uuid: entry.uuid, pick: state.pick ?? null }); } + if ('lod' in state) { + // 33: the object's LOD GROUP block (lodGroupActions.setLodGroup is the write path) + if (state.lod) object.userData.lod = state.lod; + else delete object.userData.lod; + if (peer) peer.send({ type: 'objectParameters', parameter: 'lod', uuid: entry.uuid, lod: state.lod ?? null }); + } if ('origin' in state) { // 17-D: the per-object transform origin (pivot offset) is scene data, so // moving it is undoable and replicated like any other userData write diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index a9a8f7d7..c46c7bfb 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -197,6 +197,36 @@ export function loadPackTemplate(url) { return job; } +/** url -> a parsed LOD level file (33). Kept apart from `templates`: a level is never a + * reference target, so it registers no fingerprint, and an ANIMATED item's level (a door's + * leaf) is fine here — only its geometry is used. + * @type {Map>} */ +const levelFiles = new Map(); + +/** + * Fetch + parse a pack LOD file ONCE per url (textures shared with the kit through the same + * image-bytes key). Resolves the parsed scene; a failure is forgotten so a retry can win. + * @param {string} url @returns {Promise} + */ +export function loadPackFile(url) { + let job = levelFiles.get(url); + if (!job) { + job = (async () => { + const res = await fetch(url); + if (!res.ok) throw new Error('HTTP ' + res.status); + const buffer = await res.arrayBuffer(); + /** @type {any} */ + const gltf = await new Promise((resolve, reject) => createLoader().parse(buffer, '', resolve, reject)); + await shareTextures(gltf, buffer); + gltf.scene.updateMatrixWorld(true); + return gltf.scene; + })(); + levelFiles.set(url, job); + job.catch(() => levelFiles.delete(url)); + } + return job; +} + // ---- the fingerprint --------------------------------------------------------- /** Seven significant digits, and anything under 1e-6 is zero — a GLTF round trip turns a diff --git a/src/lib/packs.js b/src/lib/packs.js index 2ea82efe..686206ed 100644 --- a/src/lib/packs.js +++ b/src/lib/packs.js @@ -287,6 +287,8 @@ export async function loadPackItems(pack) { kind: 'object', glbUrl: /^https?:\/\//.test(glb) ? glb : `${pack.base}/${o.name}/glTF-Binary/${glb}`, thumbs: thumbCandidates(pack, o), + // 33 (contract P1): the item's offline LOD files, placed as its LOD group + ...(Array.isArray(o.lods) && o.lods.length ? { lods: o.lods } : {}), resolvedThumb: cachedThumb(pack.name, o.name), // P2: skip re-probing if known packName: pack.name }; diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ac5ae97c..957ff168 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -1812,10 +1812,45 @@ export const PROPS_ROWS = [ 'scale:y', 'scale:z', 'opacity', - 'visible' + 'visible', + // 33 (K6, P3): Force LOD + the level drawn — left/right (or press) cycles Auto, LOD0… + 'lod' // 120: color/duplicate/delete removed — they live on the Edit ring + palette ]; +/** 33: the LOD group actions, PRIMED (a static edge would pull the history family's + * importers through lodGroup's loaders into this module's graph) @type {any} */ +let lodActions = null; +import('./lodGroupActions').then((m) => (lodActions = m)); +/** @type {any} */ +let lodRuntime = null; +import('./lodGroup').then((m) => (lodRuntime = m)); + +/** + * The VR props panel's LOD row: cycle Force LOD by `sign` through Auto, LOD0 … LODn. + * Returns the new choice ('auto' | level), or null when the object has no group. + * @param {string} uuid @param {number} sign + */ +export function cycleForceLod(uuid, sign) { + const info = lodRuntime?.lodGroupInfo(uuid); + if (!info || !lodActions) return null; + /** @type {('auto' | number)[]} */ + const options = ['auto', ...info.levels.map((/** @type {any} */ _l, /** @type {number} */ i) => i)]; + const now = info.block.mode === 'forced' ? info.block.forced : 'auto'; + const at = Math.max(0, options.indexOf(now)); + const next = options[(at + (sign < 0 ? -1 : 1) + options.length) % options.length]; + lodActions.forceLodLevel(uuid, next); + return next; +} + +/** The LOD row's readout for the VR panel. @param {string} uuid */ +export function lodReadout(uuid) { + const info = lodRuntime?.lodGroupInfo(uuid); + if (!info) return 'none'; + if (info.block.mode === 'forced') return 'LOD' + info.block.forced + ' forced'; + return 'Auto · ' + (info.current < 0 ? 'culled' : 'LOD' + info.current); +} + /** Raycast the props panel controls @param {number} index @returns {string|null} props action */ export function raycastProps(index) { const panel = get(vrPropsGroup); @@ -1923,12 +1958,16 @@ function handlePropsAction(action) { } else if (action.startsWith('nudge:')) { const [kind, axis, sign] = action.slice('nudge:'.length).split(':'); if (['x', 'y', 'z'].includes(axis)) nudgeTransform(object, kind, axis, parseInt(sign) || 1); + } else if (action.startsWith('lod:')) { + if (cycleForceLod(object.uuid, parseInt(action.slice('lod:'.length)) || 1) === null) + showToast('This object has no LOD levels — generate them in its properties on the desktop'); } } /** Stick press / cursored activation for a PROPS_ROWS row @param {string} row */ export function propsRowAction(row) { if (row === 'opacity') return 'props:opacity:1'; + if (row === 'lod') return 'props:lod:1'; if (row.includes(':')) return 'props:nudge:' + row + ':1'; return 'props:' + row; } @@ -4200,11 +4239,11 @@ export function updateVRControls() { // left/right adjusts the cursored row (axis nudges + opacity) const row = PROPS_ROWS[get(vrPropsCursor)]; const sign = x > 0 ? 1 : -1; - if (row === 'opacity' || row.includes(':')) { + if (row === 'opacity' || row === 'lod' || row.includes(':')) { panelScrollAt = now; hapticPulse(0.1, 12); executeVRMenuAction( - row === 'opacity' ? 'props:opacity:' + sign : 'props:nudge:' + row + ':' + sign + row === 'opacity' ? 'props:opacity:' + sign : row === 'lod' ? 'props:lod:' + sign : 'props:nudge:' + row + ':' + sign ); } } diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js index 93fb3a4a..38a33e56 100644 --- a/src/lib/wireValidate.js +++ b/src/lib/wireValidate.js @@ -142,7 +142,11 @@ export const VALIDATORS = { triggers: (d) => !!d.triggers && typeof d.triggers === 'object', peervars: (d) => typeof d.peerId === 'string', playmode: (d) => typeof d.peerId === 'string', - camera: (d) => typeof d.peerId === 'string' && isVec3(d.position) && isFiniteArray(d.rotation, 3) + camera: (d) => typeof d.peerId === 'string' && isVec3(d.position) && isFiniteArray(d.rotation, 3), + // 33: only the `lod` parameter is constrained (every other parameter predates this entry + // and keeps "absent means allow"): a block is an object with a levels ARRAY, or null + objectParameters: (d) => + d.parameter !== 'lod' || (isUuid(d.uuid) && (d.lod === null || (!!d.lod && typeof d.lod === 'object' && isArray(d.lod.levels)))) }; /** diff --git a/tests/e2e/lod-group.test.cjs b/tests/e2e/lod-group.test.cjs new file mode 100644 index 00000000..4a052e8b --- /dev/null +++ b/tests/e2e/lod-group.test.cjs @@ -0,0 +1,137 @@ +// 33 (K2/K6) — LOD GROUPS (src/lib/lodGroup.js + lodGroupActions.js; the rule is +// lodGroupCore.js, unit-tested in tests/unit/lodGroupCore.test.js). A FIXTURE PACK is served +// through page.route (a dense ball + two offline LOD files, made in-page with the app's own +// GLTFExporter), so nothing here depends on the CDN carrying lods yet. +// 1 contract P1: a pack item's `lods` place as the object's group; the LOD files are NOT +// fetched until a level is needed; far away the frame draws LOD2's triangles, the mesh +// keeps ITS OWN material during the draw (shared), the tree/stub never see a level +// 2 the implicit group: a pack piece placed before its pack had lods picks them up +// 3 hysteresis on a camera path: walking back and forth over an edge switches once +// 4 forced + the opt-out +// Run: APP_URL=https://theprototype.app:5283/ npm run e2e -- lod-group +const h = require('./helpers.cjs'); +const { setupLodFixture } = require('./lodFixture.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + + const { fixture, hits, base } = await setupLodFixture(page); + h.check(fixture.lod0.tris > 8000 && fixture.lod2.tris < 150, '0.1 (premise) the fixture: LOD0 ' + fixture.lod0.tris + ' tris, LOD1 ' + fixture.lod1.tris + ', LOD2 ' + fixture.lod2.tris); + + // ---- 1 contract P1 --------------------------------------------------------------------------- + const placed = await page.evaluate(async () => { + const r = await window.__lg.placeBall('Ball', [0, 1, 0]); + const root = window.__lg.byUuid(r.uuid); + return { ...r, lod: JSON.parse(JSON.stringify(root?.userData?.lod ?? null)) }; + }); + h.check(Array.isArray(placed.itemLods) && placed.itemLods.length === 2, '1.1 loadPackItems carries the row\'s lods: ' + JSON.stringify(placed.itemLods)); + h.check( + !!placed.lod && placed.lod.levels.length === 3 && placed.lod.levels[0].source === 'self' && placed.lod.levels[1].ref === 'lodtest/Ball/glTF-Binary/ball.lod1.glb' && placed.lod.levels[2].ref === 'lodtest/Ball/glTF-Binary/ball.lod2.glb', + '1.2 the placed object carries its LOD GROUP (finest first, refs beside its file): ' + JSON.stringify(placed.lod) + ); + await page.evaluate(() => window.__stores.lodGroup.scanLodGroups()); + const near = await page.evaluate(() => window.__lg.drawnFrom('Ball', 4, [0, 1, 0])); + // the editor's own view may already have needed LOD1 (its camera sits ~10 m away); nothing + // has needed LOD2 yet, so it must not have been fetched + h.check(hits.lod2 === 0, '1.3 LAZY: no view needed LOD2 yet, so its file was NOT fetched (lod2 ' + hits.lod2 + ', lod1 ' + hits.lod1 + ' for the editor view)'); + h.check(near.seen.length >= 1 && near.seen.every((d) => d.tris === fixture.lod0.tris), '1.4 up close it draws LOD0 (' + JSON.stringify(near.seen) + ')'); + // far: the first far render ASKS for the level; it draws LOD0 until the level has landed + await page.evaluate(() => window.__lg.renderFrom(400)); + await h.eventually( + () => page.evaluate(() => window.__stores.lodGroup.lodGroupStats().find((g) => g.name === 'Ball')), + (g) => !!g && g.levels[2].status === 'ready', + '1.5 the far render asked for LOD2 and it was built from the pack file', + 15000 + ); + h.check(hits.lod2 === 1, '1.6 LOD2 was fetched ONCE (' + hits.lod2 + ')'); + const far = await page.evaluate(() => window.__lg.drawnFrom('Ball', 400, [0, 1, 0])); + h.check(far.seen.length >= 1 && far.seen.every((d) => d.tris === fixture.lod2.tris), '1.7 THE PROPERTY: from 400 m the mesh DRAWS LOD2\'s triangles (' + JSON.stringify(far.seen) + ', file ' + fixture.lod2.tris + ')'); + h.check(far.seen.every((d) => d.ownMaterial), '1.8 shared materials: during the draw the mesh wears ITS OWN material (no copy)'); + h.check(far.afterTris === fixture.lod0.tris && far.afterOwn, '1.9 after the render the mesh holds LOD0 again — the tree never sees a level'); + const mid = await page.evaluate(async () => { + window.__lg.renderFrom(14); + await new Promise((r) => setTimeout(r, 800)); + return window.__lg.drawnFrom('Ball', 14, [0, 1, 0]); + }); + h.check(mid.seen.every((d) => d.tris === fixture.lod1.tris), '1.10 a middle distance draws LOD1 (' + JSON.stringify(mid.seen) + ')'); + const stub = await page.evaluate((uuid) => { + const s = window.__stores; + const root = window.__lg.read(s.objectsGroup).getObjectByProperty('uuid', uuid); + window.__lg.renderFrom(400); + const el = s.packRefs.isPristinePackRef(root) ? s.packRefs.stubElementOf(root) : null; + return { pristine: !!el, lod: el?.object?.userData?.lod ?? null, levelsInTree: (() => { + let n = 0; + root.traverse((o) => o.isMesh && n++); + return n; + })() }; + }, placed.uuid); + h.check(stub.pristine && stub.lod?.levels?.length === 3, '1.11 the piece still saves as a pack STUB, and the stub carries its group'); + h.check(stub.levelsInTree === 1, '1.12 no level ever entered the tree (meshes under the root: ' + stub.levelsInTree + ')'); + const autoLod = await page.evaluate(() => window.__stores.lod.lodStats().meshes.filter((m) => m.name === 'Body').length); + h.check(autoLod === 0, '1.13 the group\'s mesh left 31-perf\'s auto LOD (one system draws it, not two)'); + + // ---- 2 the implicit group (a piece placed before its pack had lods) ----------------------------- + const implicit = await page.evaluate(async () => { + const s = window.__stores; + const { uuid } = await window.__lg.placeBall('Old ball', [6, 1, 0], false); + await new Promise((r) => setTimeout(r, 1500)); + s.lodGroup.scanLodGroups(); + const root = window.__lg.byUuid(uuid); + return { uuid, stored: root.userData.lod ?? null, info: s.lodGroup.lodGroupInfo(uuid) }; + }); + h.check(implicit.stored === null, '2.1 (premise) the old piece carries no block'); + h.check(!!implicit.info && implicit.info.implicit && implicit.info.levels.length === 3, '2.2 it runs on an IMPLICIT group from its pack row: ' + JSON.stringify(implicit.info?.levels?.map((l) => l.source))); + const impFar = await page.evaluate(async () => { + window.__lg.renderFrom(400, [6, 1, 0]); + await new Promise((r) => setTimeout(r, 1500)); + return window.__lg.drawnFrom('Old ball', 400, [6, 1, 0]); + }); + h.check(impFar.seen.every((d) => d.tris === fixture.lod2.tris), '2.3 and from far away it draws LOD2 too (' + JSON.stringify(impFar.seen) + ')'); + h.check(hits.lod2 === 1, '2.4 its LOD2 came from the SAME parsed file (fetched once for both pieces: ' + hits.lod2 + ')'); + + // ---- 3 hysteresis on a camera path -------------------------------------------------------------- + const path = await page.evaluate(() => { + const s = window.__stores; + const info = s.lodGroup.lodGroupInfo(window.__lg.byName('Ball').uuid); + // the distance where LOD0 meets LOD1 at fov 50 for this radius + const g = s.lodGroup.lodGroupStats().find((x) => x.name === 'Ball'); + const r = g.radius; // the runtime's own bounding radius (1 for the fixture ball) + const t0 = info.block.levels[0].screenSize; + // the quality governor's bias pulls every threshold in (a busy headless box steps it down) + const q = s.lod.lodStats().bias || 1; + const dEdge = r / ((t0 / q) * Math.tan((50 * Math.PI) / 360)); + window.__lg.renderFrom(2); // start the walk from LOD0 (one camera's history, not the editor's) + const seq = []; + // a walk that wobbles +-2% around the edge for 60 frames (a head bob on the line) + for (let i = 0; i < 60; i++) { + window.__lg.renderFrom(dEdge * (1 + (i % 2 ? 0.02 : -0.02))); + seq.push(s.lodGroup.lodGroupStats().find((x) => x.name === 'Ball').current); + } + let switches = 0; + for (let i = 1; i < seq.length; i++) if (seq[i] !== seq[i - 1]) switches++; + return { dEdge, switches, first: seq[0], last: seq[seq.length - 1], g: !!g }; + }); + h.check(path.first === 0, '3.0 (premise) the walk starts on LOD0 (' + path.first + ')'); + h.check(path.switches === 1 && path.last === 1, '3.1 HYSTERESIS: a camera bobbing on the LOD0/LOD1 edge (' + path.dEdge.toFixed(1) + ' m) switches exactly once in 60 frames, then holds LOD1 (' + path.switches + ', last ' + path.last + ')'); + + // ---- 4 forced + the opt-out --------------------------------------------------------------------- + const forced = await page.evaluate(async (uuid) => { + const s = window.__stores; + s.lodGroupActions.forceLodLevel(uuid, 2); + const near = window.__lg.drawnFrom('Ball', 4, [0, 1, 0]); + s.lodGroupActions.forceLodLevel(uuid, 'auto'); + const back = window.__lg.drawnFrom('Ball', 4, [0, 1, 0]); + s.lod.lodEnabled.set(false); + const off = window.__lg.drawnFrom('Ball', 400, [0, 1, 0]); + s.lod.lodEnabled.set(true); + return { near: near.seen.map((d) => d.tris), back: back.seen.map((d) => d.tris), off: off.seen.map((d) => d.tris) }; + }, placed.uuid); + h.check(forced.near.every((t) => t === fixture.lod2.tris), '4.1 Force LOD2: up close it draws LOD2 (' + forced.near + ')'); + h.check(forced.back.every((t) => t === fixture.lod0.tris), '4.2 back to Auto: up close it draws LOD0 again (' + forced.back + ')'); + h.check(forced.off.every((t) => t === fixture.lod0.tris), '4.3 COUNTERFACTUAL: with LOD switched off the far render draws LOD0 (' + forced.off + ')'); + + h.check(h.pageErrors(A).length === 0, 'no page errors (' + JSON.stringify(h.pageErrors(A)) + ')'); + await h.finish(browser); +}); diff --git a/tests/e2e/lod-panel.test.cjs b/tests/e2e/lod-panel.test.cjs new file mode 100644 index 00000000..6a4a2cfc --- /dev/null +++ b/tests/e2e/lod-panel.test.cjs @@ -0,0 +1,281 @@ +// 33 (K6) — THE LOD GROUP PANEL in object settings (src/components/menu/LodGroupPanel.svelte), +// driven through the REAL UI: the level bar, selecting a level (it previews on this screen, +// then back to Auto), Force LOD, dragging a transition edge (ONE undo entry, ONE message), +// Generate levels on a plain dense mesh, a per-level material override, the gizmo on a level +// (offset), the "Show LOD level" overlay, replacing a level with another object, undo/redo, +// and two peers (the block replicates; the peer draws the forced level). +// Run: APP_URL=https://theprototype.app:5283/ npm run e2e -- lod-panel +const h = require('./helpers.cjs'); +const { setupLodFixture } = require('./lodFixture.cjs'); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1400, height: 900 } } }); + const page = A.page; + const { fixture } = await setupLodFixture(page); + // every outgoing message, for the "one message per gesture" checks + await page.evaluate(async () => { + window.__sent = []; + const inst = await new Promise((r) => window.__stores.peers.subscribe(r)()); + const orig = inst.send.bind(inst); + inst.send = (m) => { + window.__sent.push(JSON.parse(JSON.stringify(m))); + return orig(m); + }; + }); + const lodMsgs = () => page.evaluate(() => window.__sent.filter((m) => m.type === 'objectParameters' && m.parameter === 'lod').length); + const block = (uuid) => page.evaluate((u) => JSON.parse(JSON.stringify(window.__lg.byUuid(u)?.userData?.lod ?? null)), uuid); + const undoDepth = () => page.evaluate(() => window.__lg.read(window.__stores.history.undoStack).length); + + const { uuid } = await page.evaluate(() => window.__lg.placeBall('Ball', [0, 1, 0])); + await page.evaluate((u) => window.__lg.built(u), uuid); + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), uuid); + await page.waitForTimeout(700); + const panel = page.locator('#lod-group'); + await panel.scrollIntoViewIfNeeded().catch(() => {}); + + // ---- 1 the panel ---------------------------------------------------------------------------- + h.check(await panel.isVisible(), '1.1 the LOD section shows in the object\'s properties'); + const segs = await page.locator('#lod-bar .lod-seg').count(); + h.check(segs === 3, '1.2 the bar has a segment per level (' + segs + ')'); + const segText = await page.locator('#lod-bar .lod-seg').allInnerTexts(); + h.check(segText[2].includes(String(fixture.lod2.tris)), '1.3 each segment names its triangles (LOD2: ' + JSON.stringify(segText[2]) + ')'); + h.check((await page.locator('#lod-bar .lod-edge').count()) === 2, '1.4 two draggable transition edges (LOD0|1, LOD1|2)'); + + // ---- 2 select a level: it previews on this screen, then back to Auto ------------------------- + await page.locator('#lod-bar .lod-seg[data-level="2"]').click(); + await page.waitForTimeout(200); + const preview = await page.evaluate((u) => { + const p = window.__lg.read(window.__stores.lodGroup.lodPreview); + return { p, near: window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.map((d) => d.tris) }; + }, uuid); + h.check(preview.p?.uuid === uuid && preview.p?.level === 2, '2.1 selecting LOD2 previews it (' + JSON.stringify(preview.p) + ')'); + h.check(preview.near.length > 0 && preview.near.every((t) => t === fixture.lod2.tris), '2.2 up close the viewport now draws LOD2 (' + preview.near + ')'); + h.check(await page.locator('#lod-level-detail[data-level="2"]').isVisible(), '2.3 the level\'s details open below the bar'); + h.check((await page.locator('#lod-level-source').innerText()).includes('ball.lod2.glb'), '2.4 the detail names its source (the pack file)'); + h.check((await lodMsgs()) === 0, '2.5 a preview is LOCAL: no lod message sent'); + await page.locator('#lod-bar .lod-seg[data-level="2"]').click(); + await page.waitForTimeout(200); + const unpreview = await page.evaluate((u) => ({ p: window.__lg.read(window.__stores.lodGroup.lodPreview), near: window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.map((d) => d.tris) }), uuid); + h.check(unpreview.p === null && unpreview.near.every((t) => t === fixture.lod0.tris), '2.6 clicking it again returns to Auto (LOD0 up close)'); + + // ---- 3 Force LOD through the dropdown + undo/redo ------------------------------------------- + const depth0 = await undoDepth(); + await page.locator('#lod-force').click(); + await page.locator('.ts-list [role="option"]', { hasText: 'LOD1' }).click(); + await page.waitForTimeout(250); + const forced = await block(uuid); + h.check(forced?.mode === 'forced' && forced?.forced === 1, '3.1 Force LOD1 writes the block (' + forced?.mode + ' ' + forced?.forced + ')'); + h.check((await lodMsgs()) === 1 && (await undoDepth()) === depth0 + 1, '3.2 ONE message, ONE undo entry'); + const forcedNear = await page.evaluate((u) => window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.map((d) => d.tris), uuid); + h.check(forcedNear.every((t) => t === fixture.lod1.tris), '3.3 it draws LOD1 up close (' + forcedNear + ')'); + await page.evaluate(() => window.__stores.history.undo()); + await page.waitForTimeout(200); + h.check((await block(uuid))?.mode === 'auto', '3.4 undo: back to Auto'); + await page.evaluate(() => window.__stores.history.redo()); + await page.waitForTimeout(200); + h.check((await block(uuid))?.forced === 1, '3.5 redo: forced LOD1 again'); + const sentUndo = await page.evaluate(() => window.__sent.filter((m) => m.type === 'objectParameters' && m.parameter === 'lod').map((m) => m.lod?.mode)); + h.check(JSON.stringify(sentUndo) === '["forced","auto","forced"]', '3.6 undo and redo REPLICATE (' + JSON.stringify(sentUndo) + ')'); + await page.locator('#lod-force').click(); + await page.locator('.ts-list [role="option"]', { hasText: 'Auto' }).click(); + await page.waitForTimeout(200); + + // ---- 4 drag a transition edge: live locally, ONE entry + ONE message at release --------------- + const before4 = await block(uuid); + const msgs4 = await lodMsgs(); + const depth4 = await undoDepth(); + const edge = page.locator('#lod-bar .lod-edge[data-edge="0"]'); + const eb = await edge.boundingBox(); + await page.mouse.move(eb.x + eb.width / 2, eb.y + eb.height / 2); + await page.mouse.down(); + await page.mouse.move(eb.x + eb.width / 2 - 25, eb.y + eb.height / 2, { steps: 4 }); + const mid4 = await block(uuid); + const midMsgs = await lodMsgs(); + await page.mouse.move(eb.x + eb.width / 2 - 50, eb.y + eb.height / 2, { steps: 4 }); + await page.mouse.up(); + await page.waitForTimeout(250); + const after4 = await block(uuid); + h.check(mid4.levels[0].screenSize > before4.levels[0].screenSize, '4.1 mid-drag the threshold already moved (live, ' + before4.levels[0].screenSize + ' -> ' + mid4.levels[0].screenSize + ')'); + h.check(midMsgs === msgs4, '4.2 …and nothing was sent mid-drag'); + h.check(after4.levels[0].screenSize > mid4.levels[0].screenSize, '4.3 the release lands further (' + after4.levels[0].screenSize + ')'); + h.check((await lodMsgs()) === msgs4 + 1 && (await undoDepth()) === depth4 + 1, '4.4 ONE message and ONE undo entry for the whole drag'); + await page.evaluate(() => window.__stores.history.undo()); + await page.waitForTimeout(200); + h.check((await block(uuid)).levels[0].screenSize === before4.levels[0].screenSize, '4.5 ONE undo puts the edge back where the drag began'); + + // ---- 5 a level's own material ------------------------------------------------------------------ + await page.locator('#lod-bar .lod-seg[data-level="1"]').click(); + await page.waitForTimeout(150); + await page.locator('#lod-level-override').check(); + await page.waitForTimeout(250); + const ov = await page.evaluate((u) => { + const lv1 = window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen; // LOD1 is previewed + return { mat: JSON.parse(JSON.stringify(window.__lg.byUuid(u).userData.lod.levels[1].material ?? null)), lv1 }; + }, uuid); + h.check(ov.mat?.color === '#ffffff', '5.1 "Own material for this level" writes an override (' + JSON.stringify(ov.mat) + ')'); + h.check(ov.lv1.length > 0 && ov.lv1.every((d) => !d.ownMaterial && d.color === 'ffffff'), '5.2 LOD1 draws a COPY wearing the override (' + JSON.stringify(ov.lv1) + ')'); + await page.locator('#lod-bar .lod-seg[data-level="1"]').click(); // deselect -> Auto + await page.waitForTimeout(150); + const own0 = await page.evaluate((u) => window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen, uuid); + h.check(own0.every((d) => d.ownMaterial), '5.3 LOD0 keeps the object\'s own material'); + + // ---- 6 the gizmo on a level (offset) ------------------------------------------------------------ + await page.locator('#lod-bar .lod-seg[data-level="1"]').click(); + await page.waitForTimeout(150); + await page.locator('#lod-level-move').click(); + await page.waitForTimeout(200); + const depth6 = await undoDepth(); + const g6 = await page.evaluate(async (u) => { + const s = window.__stores; + const c = window.__lg.read(s.TControls); + const onProxy = !!c?.object?.userData?.isLodLevelProxy; + c.dispatchEvent({ type: 'dragging-changed', value: true }); + c.object.position.y += 0.5; + c.dispatchEvent({ type: 'change' }); + const live = JSON.parse(JSON.stringify(window.__lg.byUuid(u).userData.lod.levels[1].offset ?? null)); + c.dispatchEvent({ type: 'dragging-changed', value: false }); + await new Promise((r) => setTimeout(r, 100)); + const drawn = window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen; + const rootY = window.__lg.byUuid(u).position.y; + return { onProxy, live, offset: JSON.parse(JSON.stringify(window.__lg.byUuid(u).userData.lod.levels[1].offset ?? null)), drawn, rootY }; + }, uuid); + h.check(g6.onProxy, '6.1 "Move level" puts the gizmo on the level\'s proxy (not the object)'); + h.check(Math.abs((g6.live?.pos?.[1] ?? 0) - 0.5) < 1e-3, '6.2 the level follows the gizmo live (' + JSON.stringify(g6.live) + ')'); + h.check(Math.abs((g6.offset?.pos?.[1] ?? 0) - 0.5) < 1e-3 && (await undoDepth()) === depth6 + 1, '6.3 the release commits the offset as ONE undo entry'); + h.check(g6.drawn.length > 0 && g6.drawn.every((d) => Math.abs(d.y - (g6.rootY + 0.5)) < 0.02), '6.4 LOD1 DRAWS half a metre up (' + JSON.stringify(g6.drawn.map((d) => d.y)) + ', root ' + g6.rootY + ')'); + const objY = await page.evaluate((u) => window.__lg.byUuid(u).position.y, uuid); + h.check(Math.abs(objY - g6.rootY) < 1e-6, '6.5 the OBJECT did not move'); + await page.evaluate(() => window.__stores.history.undo()); + await page.waitForTimeout(200); + h.check(!(await block(uuid)).levels[1].offset, '6.6 undo removes the offset'); + await page.locator('#lod-level-move').click(); // Done moving + await page.waitForTimeout(150); + const backOnObject = await page.evaluate((u) => window.__lg.read(window.__stores.TControls)?.object?.uuid === u, uuid); + h.check(backOnObject, '6.7 "Done moving" puts the gizmo back on the object'); + await page.locator('#lod-bar .lod-seg[data-level="1"]').click(); // deselect + await page.waitForTimeout(150); + + // ---- 7 the overlay -------------------------------------------------------------------------------- + await page.locator('#lod-overlay').check(); + const ovl = await page.evaluate(async (u) => { + window.__lg.renderFrom(400); + await new Promise((r) => setTimeout(r, 300)); + return { far: window.__lg.drawnFrom(u, 400, [0, 1, 0]).seen.map((d) => d.material), near: window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.map((d) => d.material) }; + }, uuid); + h.check(ovl.far.every((m) => m === 'lod-overlay-2') && ovl.near.every((m) => m === 'lod-overlay-0'), '7.1 "Show LOD level" paints each level its colour (far ' + ovl.far + ', near ' + ovl.near + ')'); + await page.locator('#lod-overlay').uncheck(); + const ovlOff = await page.evaluate((u) => window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.every((d) => d.ownMaterial), uuid); + h.check(ovlOff, '7.2 off again: the object wears its own material'); + h.check((await lodMsgs()) === (await lodMsgs()), '7.3 (the overlay is LOCAL — checked by 2.5\'s rule: no lod write)'); + + // ---- 8 replace a level with another object (a TREE level) ---------------------------------------- + const standIn = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0xff00ff })); + m.name = 'Stand-in'; + m.position.set(20, 0.5, 0); + window.__lg.read(s.objectsGroup).add(m); + s.pokeScene(); + return m.uuid; + }); + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), uuid); + await page.waitForTimeout(500); + await page.locator('#lod-bar .lod-seg[data-level="2"]').click(); + await page.waitForTimeout(150); + await page.locator('#lod-level-replace').click(); + await page.locator('.ts-list [role="option"]', { hasText: 'Stand-in' }).click(); + await page.waitForTimeout(400); + const rep = await page.evaluate(async ({ u, si }) => { + const s = window.__stores; + await window.__lg.built(u); + const lvl = window.__lg.byUuid(u).userData.lod.levels[2]; + const drawnSelf = window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.length; // LOD2 previewed + const stats = s.lodGroup.lodGroupStats().find((g) => g.uuid === u); + return { source: lvl.source, ref: lvl.ref, si, kind: stats.levels[2].kind, tris: stats.levels[2].tris, drawnSelf }; + }, { u: uuid, si: standIn }); + h.check(rep.source === 'object' && rep.ref === rep.si, '8.1 the level now names the stand-in object'); + h.check(rep.kind === 'tree' && rep.tris === 12, '8.2 it builds as a substitute model (12 tris)'); + h.check(rep.drawnSelf === 0, '8.3 while LOD2 draws, the ball\'s own mesh is not drawn (the stand-in is)'); + await page.evaluate(() => window.__stores.history.undo()); + await page.waitForTimeout(200); + h.check((await block(uuid)).levels[2].source === 'pack', '8.4 undo: the pack file again'); + await page.locator('#lod-bar .lod-seg[data-level="2"]').click().catch(() => {}); + + // ---- 9 Generate levels on a plain dense mesh --------------------------------------------------------- + const plain = await page.evaluate(() => { + const s = window.__stores; + const THREE = s.THREE; + const m = new THREE.Mesh(new THREE.TorusKnotGeometry(1, 0.3, 256, 32), new THREE.MeshStandardMaterial()); + m.name = 'Knot'; + m.position.set(-8, 1, 0); + window.__lg.read(s.objectsGroup).add(m); + s.pokeScene(); + return { uuid: m.uuid, tris: m.geometry.index.count / 3 }; + }); + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), plain.uuid); + await page.waitForTimeout(600); + h.check(await page.locator('#lod-none').isVisible(), '9.1 an object with no group says so'); + const depth9 = await undoDepth(); + await page.locator('#lod-generate').first().click(); + await page.waitForTimeout(300); + const gen = await page.evaluate(async (u) => { + const info = await window.__lg.built(u, 20000); + window.__lg.renderFrom(400, [-8, 1, 0]); + await new Promise((r) => setTimeout(r, 200)); + const far = window.__lg.drawnFrom(u, 400, [-8, 1, 0]).seen.map((d) => d.tris); + return { levels: info?.levels?.map((l) => [l.source, l.status, l.tris]), far }; + }, plain.uuid); + h.check(gen.levels?.length === 4 && gen.levels.slice(1).every((l) => l[0] === 'generated' && l[1] === 'ready'), '9.2 Generate levels builds three meshopt levels in the worker: ' + JSON.stringify(gen.levels)); + h.check(gen.far.length > 0 && gen.far.every((t) => t < plain.tris * 0.15), '9.3 from far away the knot draws the coarse level (' + gen.far + ' of ' + plain.tris + ')'); + h.check((await undoDepth()) === depth9 + 1, '9.4 Generate is ONE undo entry'); + await page.evaluate(() => window.__stores.history.undo()); + await page.waitForTimeout(300); + h.check((await block(plain.uuid)) === null, '9.5 undo removes the generated group'); + + // ---- 10 the wire validator ------------------------------------------------------------------------ + const wire = await page.evaluate((u) => { + const v = window.__stores.wireValidate.validateWireMessage; + return { + ok: v({ type: 'objectParameters', parameter: 'lod', uuid: u, lod: { levels: [] } }), + nul: v({ type: 'objectParameters', parameter: 'lod', uuid: u, lod: null }), + bad: v({ type: 'objectParameters', parameter: 'lod', uuid: u, lod: { levels: 'x' } }), + other: v({ type: 'objectParameters', parameter: 'visible', visible: true }) + }; + }, uuid); + h.check(wire.ok && wire.nul && !wire.bad && wire.other, '10.1 wireValidate: a lod block or null passes, a malformed one is refused, other parameters untouched'); + + // ---- 11 two peers ------------------------------------------------------------------------------------ + const B = await h.setupPage(browser, 'B'); + await setupLodFixture(B.page); + await h.connect(B, A); + await h.eventually( + () => B.page.evaluate((u) => JSON.parse(JSON.stringify(window.__lg.byUuid(u)?.userData?.lod ?? null)), uuid), + (lod) => !!lod && lod.levels.length === 3, + '11.1 the joiner receives the piece WITH its group (the stub carries userData.lod)', + 30000 + ); + await page.evaluate((u) => window.__stores.lodGroupActions.forceLodLevel(u, 2), uuid); + await h.eventually( + () => B.page.evaluate((u) => window.__lg.byUuid(u)?.userData?.lod?.forced ?? null, uuid), + (f) => f === 2, + '11.2 Force LOD replicates to the peer', + 15000 + ); + const bNear = await B.page.evaluate(async (u) => { + await window.__lg.built(u); + return window.__lg.drawnFrom(u, 4, [0, 1, 0]).seen.map((d) => d.tris); + }, uuid); + h.check(bNear.length > 0 && bNear.every((t) => t === fixture.lod2.tris), '11.3 the peer DRAWS the forced LOD2 up close (' + bNear + ')'); + await page.evaluate(() => window.__stores.history.undo()); + await h.eventually( + () => B.page.evaluate((u) => window.__lg.byUuid(u)?.userData?.lod?.mode ?? null, uuid), + (m) => m === 'auto', + '11.4 undo replicates too', + 15000 + ); + + h.check(h.pageErrors(A).length === 0 && h.pageErrors(B).length === 0, 'no page errors (' + JSON.stringify([...h.pageErrors(A), ...h.pageErrors(B)]) + ')'); + await h.finish(browser); +}); diff --git a/tests/e2e/lodFixture.cjs b/tests/e2e/lodFixture.cjs new file mode 100644 index 00000000..c9340e0e --- /dev/null +++ b/tests/e2e/lodFixture.cjs @@ -0,0 +1,134 @@ +// 33 — the LOD-group suites' shared FIXTURE PACK: a dense ball + two offline LOD files made +// in-page with the app's own GLTFExporter, served through page.route under PACKS_BASE, plus +// in-page helpers (`window.__lg`) that render the real scene from a chosen distance and read +// what a mesh DREW mid-render. Not a suite (no `.test.`), so the runner never picks it up. + +/** @param {any} page */ +async function setupLodFixture(page) { + const fixture = await page.evaluate(async () => { + const s = window.__stores; + const THREE = s.THREE; + const { GLTFExporter } = s.GLTFExporterModule; + const make = async (w, hgt) => { + const scene = new THREE.Scene(); + const root = new THREE.Group(); + root.name = 'Ball'; + const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, w, hgt), new THREE.MeshStandardMaterial({ color: 0x8899aa, name: 'BallMat' })); + mesh.name = 'Body'; + root.add(mesh); + scene.add(root); + const buf = await new GLTFExporter().parseAsync(scene, { binary: true }); + const bytes = new Uint8Array(buf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return { b64: btoa(bin), tris: mesh.geometry.index.count / 3 }; + }; + return { base: s.packs.PACKS_BASE, lod0: await make(96, 48), lod1: await make(32, 16), lod2: await make(10, 5) }; + }); + const base = fixture.base.replace(/\/+$/, ''); + const hits = { lod1: 0, lod2: 0, list: 0 }; + const glb = (b64) => ({ status: 200, contentType: 'model/gltf-binary', body: Buffer.from(b64, 'base64') }); + const rows = [{ name: 'Ball', label: 'Ball', variants: { 'glTF-Binary': 'ball.glb' }, lods: [{ file: 'ball.lod2.glb', ratio: 0.03 }, { file: 'ball.lod1.glb', ratio: 0.12 }] }]; + await page.route(base + '/index.json', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ name: 'lodtest', title: 'LOD test', value: 'lodtest/default.json' }]) })); + await page.route(base + '/lodtest/default.json', (r) => { + hits.list++; + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rows) }); + }); + await page.route(base + '/lodtest/Ball/glTF-Binary/ball.glb', (r) => r.fulfill(glb(fixture.lod0.b64))); + await page.route(base + '/lodtest/Ball/glTF-Binary/ball.lod1.glb', (r) => { + hits.lod1++; + r.fulfill(glb(fixture.lod1.b64)); + }); + await page.route(base + '/lodtest/Ball/glTF-Binary/ball.lod2.glb', (r) => { + hits.lod2++; + r.fulfill(glb(fixture.lod2.b64)); + }); + await page.evaluate((base) => { + const s = window.__stores; + const read = (store) => { + let v; + store.subscribe((x) => (v = x))(); + return v; + }; + const trisOf = (g) => (g.index ? g.index.count : g.attributes.position.count) / 3; + window.__lg = { + read, + base, + byName(name) { + return read(s.objectsGroup).getObjectByName(name); + }, + byUuid(uuid) { + return read(s.objectsGroup).getObjectByProperty('uuid', uuid); + }, + /** the triangles ONE render() drew from `dist` m in front of `target` (shadow pass incl.) */ + renderFrom(dist, target = [0, 1, 0], fov = 50) { + const THREE = s.THREE; + const r = read(s.globalRenderer); + const scene = read(s.globalScene); + const cam = new THREE.PerspectiveCamera(fov, 16 / 9, 0.1, 5000); + cam.position.set(target[0], target[1], target[2] + dist); + cam.lookAt(...target); + cam.updateMatrixWorld(true); + const auto = r.info.autoReset; + r.info.autoReset = true; + r.render(scene, cam); + const tris = r.info.render.triangles; + r.info.autoReset = auto; + return tris; + }, + /** what the root's first mesh DREW in that render (read in its onBeforeRender = + * mid-draw): triangles, own material or not, the material's name/colour, world y */ + drawnFrom(root, dist, target) { + const r = typeof root === 'string' ? window.__lg.byName(root) || window.__lg.byUuid(root) : root; + let mesh = null; + r.traverse((o) => { + if (o.isMesh && !mesh) mesh = o; + }); + const own = mesh.material; + const seen = []; + const prev = mesh.onBeforeRender; + mesh.onBeforeRender = function (...a) { + seen.push({ + tris: trisOf(this.geometry), + ownMaterial: this.material === own, + material: this.material?.name ?? '', + color: this.material?.color?.getHexString?.() ?? null, + y: Number(this.matrixWorld.elements[13].toFixed(3)) + }); + return prev.apply(this, a); + }; + const t = target ?? [r.position.x, r.position.y, r.position.z]; + const total = window.__lg.renderFrom(dist, t); + mesh.onBeforeRender = prev; + return { total, seen, afterTris: trisOf(mesh.geometry), afterOwn: mesh.material === own }; + }, + /** placePackItem's path: fetch the row's glb, importFile with the pack ref + group */ + async placeBall(name = 'Ball', at = [0, 1, 0], withGroup = true) { + const pack = { name: 'lodtest', base: base + '/lodtest', listUrl: base + '/lodtest/default.json', source: 'default' }; + const items = await s.packs.loadPackItems(pack); + const item = items.find((i) => i.name === 'Ball'); + const res = await fetch(item.glbUrl); + const uuid = await s.fileHandler.importFile(new File([await res.blob()], name + '.glb'), name, 'glb', at, undefined, { + packRef: s.packRefs.packRefFromUrl(item.glbUrl, { pack: 'lodtest', item: 'Ball' }), + ...(withGroup ? { lod: s.lodGroup.placementGroupFor(item.glbUrl, item.lods) } : {}) + }); + s.lodGroup.scanLodGroups(); + return { uuid, itemLods: item.lods ?? null }; + }, + /** wait until every level of `uuid`'s group is built */ + async built(uuid, timeout = 15000) { + s.lodGroup.buildAllLevels(uuid); + const end = performance.now() + timeout; + while (performance.now() < end) { + const info = s.lodGroup.lodGroupInfo(uuid); + if (info && info.levels.every((l) => l.status === 'ready' || l.status === 'failed')) return info; + await new Promise((r) => setTimeout(r, 100)); + } + return s.lodGroup.lodGroupInfo(uuid); + } + }; + }, base); + return { fixture, hits, base }; +} + +module.exports = { setupLodFixture }; diff --git a/tests/e2e/vr-props-panel.test.cjs b/tests/e2e/vr-props-panel.test.cjs index 31ead833..30d3c9a6 100644 --- a/tests/e2e/vr-props-panel.test.cjs +++ b/tests/e2e/vr-props-panel.test.cjs @@ -34,8 +34,8 @@ h.run(async () => { 'rotation steps convert degrees to radians' ); h.check( - steps.rows.startsWith('pos:x,pos:y,pos:z,rot:x') && steps.rows.endsWith('opacity,visible'), - `row order: transforms, opacity, visible — 120 dropped color/dup/delete (${steps.rows})` + steps.rows.startsWith('pos:x,pos:y,pos:z,rot:x') && steps.rows.endsWith('opacity,visible,lod'), + `row order: transforms, opacity, visible, LOD (33) — 120 dropped color/dup/delete (${steps.rows})` ); h.check( steps.pressAxis === 'props:nudge:pos:x:1' && @@ -151,5 +151,45 @@ h.run(async () => { h.check(actions.hidden, 'Visible row toggles the selection'); // color/dup/delete moved to the Edit ring (120) — covered by vr-radial + vr-palette + // --- 33 (K6, P3): the LOD row — readout + Force LOD cycling through the replicated path --- + const lod = await A.page.evaluate(async () => { + const s = window.__stores; + const v = s.vrControls; + const box = window.__box; + const out = { press: v.propsRowAction('lod'), none: v.lodReadout(box.uuid) }; + out.noGroup = v.cycleForceLod(box.uuid, 1); + s.lodGroupActions.generateLodLevels(box.uuid, [0.5, 0.25]); + await new Promise((r) => setTimeout(r, 300)); + out.auto = v.lodReadout(box.uuid); + const depth = (() => { + let n; + s.history.undoStack.subscribe((x) => (n = x.length))(); + return n; + })(); + v.executeVRMenuAction('props:lod:1'); + out.f0 = v.lodReadout(box.uuid); + v.executeVRMenuAction('props:lod:1'); + out.f1 = v.lodReadout(box.uuid); + v.executeVRMenuAction('props:lod:-1'); + v.executeVRMenuAction('props:lod:-1'); + out.back = v.lodReadout(box.uuid); + out.stored = JSON.parse(JSON.stringify(box.userData.lod)); + out.entries = (() => { + let n; + s.history.undoStack.subscribe((x) => (n = x.length))(); + return n; + })() - depth; + v.executeVRMenuAction('props:lod:-1'); // wraps: Auto -> the last level + out.wrap = v.lodReadout(box.uuid); + return out; + }); + h.check(lod.press === 'props:lod:1', 'stick-press on the LOD row cycles Force LOD'); + h.check(lod.none === 'none' && lod.noGroup === null, 'an object with no group reads "none" and the row does nothing (' + lod.none + ')'); + h.check(lod.auto.startsWith('Auto'), 'with a group the row reads the level drawn (' + lod.auto + ')'); + h.check(lod.f0 === 'LOD0 forced' && lod.f1 === 'LOD1 forced', 'right cycles Auto -> LOD0 -> LOD1 (' + lod.f0 + ', ' + lod.f1 + ')'); + h.check(lod.back.startsWith('Auto') && lod.stored.mode === 'auto', 'left cycles back to Auto (' + lod.back + ')'); + h.check(lod.entries === 4, 'each step is ONE undoable, replicated lod write (' + lod.entries + ' entries for 4 steps)'); + h.check(lod.wrap === 'LOD2 forced', 'left from Auto wraps to the coarsest level (' + lod.wrap + ')'); + await h.finish(browser); }); diff --git a/tests/unit/lodGroupCore.test.js b/tests/unit/lodGroupCore.test.js new file mode 100644 index 00000000..1913a599 --- /dev/null +++ b/tests/unit/lodGroupCore.test.js @@ -0,0 +1,174 @@ +// 33 — the LOD GROUP rule (src/lib/lodGroupCore.js), pure: the block's one boundary, screen +// size, the level pick with hysteresis + cull + bias, the pack `lods` row, threshold drags. +import { describe, it, expect } from 'vitest'; +import { + normalizeLodGroup, + screenSizeOf, + pickGroupLevel, + packLodsOf, + groupFromPackLods, + generatedGroup, + withThreshold, + thresholdsOf, + defaultScreenSizes, + levelColor, + sameGroup, + MAX_LEVELS +} from '../../src/lib/lodGroupCore.js'; + +describe('normalizeLodGroup', () => { + it('is null for anything that is not a group (absent = today)', () => { + expect(normalizeLodGroup(null)).toBe(null); + expect(normalizeLodGroup({})).toBe(null); + expect(normalizeLodGroup({ levels: 'x' })).toBe(null); + expect(normalizeLodGroup({ levels: [{ source: 'nope' }] })).toBe(null); + }); + it('always starts with LOD0 = self, and only once', () => { + const g = normalizeLodGroup({ levels: [{ source: 'generated', ratio: 0.5 }, { source: 'self' }] }); + expect(g?.levels.map((l) => l.source)).toEqual(['self', 'generated']); + }); + it('forces thresholds strictly descending and fills missing ones from the defaults', () => { + const g = normalizeLodGroup({ levels: [{ source: 'self', screenSize: 0.1 }, { source: 'generated', ratio: 0.5, screenSize: 0.4 }, { source: 'generated', ratio: 0.2 }] }); + const s = g?.levels.map((l) => l.screenSize) ?? []; + expect(s[0]).toBe(0.1); + expect(s[1]).toBeLessThan(s[0]); + expect(s[2]).toBeLessThan(s[1]); + }); + it('drops a level that cannot be built (a generated level with no ratio, a pack level with no file)', () => { + const g = normalizeLodGroup({ levels: [{ source: 'self' }, { source: 'generated' }, { source: 'pack' }, { source: 'pack', ref: 'a.lod1.glb' }] }); + expect(g?.levels.length).toBe(2); + expect(g?.levels[1].ref).toBe('a.lod1.glb'); + }); + it('a forced level outside the list falls back to auto; a valid one is kept', () => { + const levels = [{ source: 'self' }, { source: 'generated', ratio: 0.5 }]; + expect(normalizeLodGroup({ mode: 'forced', forced: 5, levels })?.mode).toBe('auto'); + const g = normalizeLodGroup({ mode: 'forced', forced: 1, levels }); + expect(g?.mode).toBe('forced'); + expect(g?.forced).toBe(1); + }); + it('keeps offsets and material overrides, drops identity ones and bad values', () => { + const g = normalizeLodGroup({ + levels: [ + { source: 'self' }, + { source: 'generated', ratio: 0.5, offset: { pos: [0, 0.1, 0], rot: [0, 0, 0], scale: [1, 1, 1] }, material: { color: '#FF0000', roughness: 4, metalness: 'x' } }, + { source: 'generated', ratio: 0.2, offset: { pos: [NaN, 0, 0] }, material: { color: 'red' } } + ] + }); + expect(g?.levels[1].offset).toEqual({ pos: [0, 0.1, 0] }); + expect(g?.levels[1].material).toEqual({ color: '#ff0000', roughness: 1 }); + expect(g?.levels[2].offset).toBeUndefined(); + expect(g?.levels[2].material).toBeUndefined(); + }); + it('caps the level count', () => { + const levels = [{ source: 'self' }, ...Array.from({ length: 12 }, (_, i) => ({ source: 'generated', ratio: 0.9 - i * 0.05 }))]; + expect(normalizeLodGroup({ levels })?.levels.length).toBe(MAX_LEVELS); + }); + it('is idempotent', () => { + const g = generatedGroup(); + expect(normalizeLodGroup(g)).toEqual(g); + expect(sameGroup(g, JSON.parse(JSON.stringify(g)))).toBe(true); + }); +}); + +describe('screenSizeOf', () => { + it('is r / (d·tan(fov/2)) for a perspective camera', () => { + const s = screenSizeOf(1, 10, { fov: 90 }); + expect(s).toBeCloseTo(0.1, 6); + }); + it('reads inside the sphere as filling the screen', () => { + expect(screenSizeOf(2, 1, { fov: 50 })).toBeGreaterThan(1); + }); + it('ortho uses the frustum height and zoom', () => { + expect(screenSizeOf(1, 999, { isOrthographicCamera: true, top: 5, bottom: -5, zoom: 1 })).toBeCloseTo(0.2, 6); + expect(screenSizeOf(1, 999, { isOrthographicCamera: true, top: 5, bottom: -5, zoom: 2 })).toBeCloseTo(0.4, 6); + }); +}); + +describe('pickGroupLevel', () => { + const t = [0.3, 0.12, 0.04]; + it('LOD0 while big, each level under its threshold, the last level stays without cull', () => { + expect(pickGroupLevel(0.5, t, 0)).toBe(0); + expect(pickGroupLevel(0.2, t, 0)).toBe(1); + expect(pickGroupLevel(0.05, t, 0)).toBe(2); + expect(pickGroupLevel(0.001, t, 0)).toBe(2); + }); + it('culls below the last threshold when cull is on', () => { + expect(pickGroupLevel(0.001, t, 0, { cull: true })).toBe(-1); + expect(pickGroupLevel(0.05, t, 0, { cull: true })).toBe(2); + }); + it('HYSTERESIS: an object bobbing on an edge does not flip every frame', () => { + // just under the LOD0 edge -> LOD1; back just over it (inside the band) -> stays LOD1 + const down = pickGroupLevel(0.299, t, 0); + expect(down).toBe(1); + expect(pickGroupLevel(0.31, t, down)).toBe(1); + // clearly above the band -> back to LOD0 + expect(pickGroupLevel(0.36, t, down)).toBe(0); + // COUNTERFACTUAL: with no band it would have popped back at 0.31 + expect(pickGroupLevel(0.31, t, down, { hysteresis: 0 })).toBe(0); + // a frame-by-frame bob 0.295 <-> 0.305 for 100 frames: ONE switch, not 100 + let cur = 0; + let switches = 0; + for (let i = 0; i < 100; i++) { + const next = pickGroupLevel(i % 2 ? 0.305 : 0.295, t, cur); + if (next !== cur) switches++; + cur = next; + } + expect(switches).toBe(1); + }); + it('scale > 1 (low quality) steps down sooner; < 1 (group bias) keeps detail', () => { + expect(pickGroupLevel(0.4, t, 0, { scale: 2 })).toBe(1); + expect(pickGroupLevel(0.2, t, 0, { scale: 0.5 })).toBe(0); + }); + it('a culled object comes back only above the band', () => { + expect(pickGroupLevel(0.041, t, -1, { cull: true })).toBe(-1); + expect(pickGroupLevel(0.05, t, -1, { cull: true })).toBe(2); + }); +}); + +describe('thresholdsOf', () => { + it('folds the quality bias (coarser sooner) and the group bias (detail longer)', () => { + const g = /** @type {any} */ ({ ...generatedGroup(), bias: 2 }); + expect(thresholdsOf(g, 1).scale).toBeCloseTo(0.5, 6); + expect(thresholdsOf(generatedGroup(), 0.5).scale).toBeCloseTo(2, 6); + }); +}); + +describe('the pack row (contract P1)', () => { + it('reads lods finest first and refuses paths that leave the item folder', () => { + const row = { lods: [{ file: 'b.lod2.glb', ratio: 0.2 }, { file: 'b.lod1.glb', ratio: 0.5 }, { file: '../x.glb', ratio: 0.1 }, { file: 'https://x/y.glb' }] }; + expect(packLodsOf(row)).toEqual([ + { file: 'b.lod1.glb', ratio: 0.5 }, + { file: 'b.lod2.glb', ratio: 0.2 } + ]); + expect(packLodsOf({})).toEqual([]); + }); + it('a row with lods becomes a group: self + one pack level per file', () => { + const g = groupFromPackLods({ lods: [{ file: 'b.lod1.glb', ratio: 0.5 }, { file: 'b.lod2.glb', ratio: 0.2 }] }); + expect(g?.levels.map((l) => l.source)).toEqual(['self', 'pack', 'pack']); + expect(g?.levels[2]).toMatchObject({ ref: 'b.lod2.glb', ratio: 0.2 }); + expect(groupFromPackLods({ variants: {} })).toBe(null); + }); +}); + +describe('withThreshold (the bar drag)', () => { + it('moves one edge and never past its neighbours', () => { + const g = generatedGroup([0.5, 0.2]); + const moved = withThreshold(g, 1, 0.2); + expect(moved.levels[1].screenSize).toBe(0.2); + const crossed = withThreshold(g, 1, 0.9); + expect(crossed.levels[1].screenSize).toBeLessThan(g.levels[0].screenSize); + const under = withThreshold(g, 1, -1); + expect(under.levels[1].screenSize).toBeGreaterThan(g.levels[2].screenSize); + }); +}); + +describe('defaults + colours', () => { + it('default thresholds descend', () => { + const d = defaultScreenSizes(4); + for (let i = 1; i < d.length; i++) expect(d[i]).toBeLessThan(d[i - 1]); + }); + it('a colour per level, grey when culled', () => { + expect(levelColor(0)).not.toBe(levelColor(1)); + expect(levelColor(-1)).toBe('#6b7280'); + }); +}); From 78d42bc6556952bec2716fb23ceb85d2bc628b79 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 21:00:20 +0300 Subject: [PATCH 06/25] [feat] 33 P2: animated, functional pack items (door/toggle/oneshot/loop), no autoplay - behaviorCore.js (pure, vitest 18): normalizeBehavior, the pose as a pure function of {on, at, from, n} + the session clock, nextState, frameSlabs/passageThrough (the doorway cut out of a one-piece frame, through the wall's depth) - packBehavior.js: per-frame pose driver (rest pose in Edit whatever the shared state), a core click handler in moduleSDK's one dispatch (desktop Interact click, Play tap, VR laser/poke), proximity (1.5 m) and knock (VR hand >= 0.6 m/s) triggers, the `behavior` wire message (latest-wins on sessionNow), a kinematic box per moving node that follows the swing while a sim runs + the frame as custom slab colliders, a local Animation-panel preview (never sent/saved), the per-item sound - animatedImports: NOTHING AUTOPLAYS on placement any more (the first clip used to start and loop); behavior roots skip the transport; spec (+ live state on the wire, + the lod-editor's userData.lod) ride objectfile, the save snapshot and the undo entry - fileHandler: animated imports land at the drop point; opts.behavior from the pack row - packs/Explorer/explorerDrop: the row's spec travels to placement; an "animated" badge on cards and list rows - gameSfx: + door gate slide lever lid; wireValidate `behavior` shape; ROOM_SCOPED; vrGameInput treats behavior roots as clickable; api.behavior {list, state, trigger} - suites: pack-behavior (new; test-door.glb built with gltf-transform), animated-models and animation-persist press play first now, game-audio expects 25 sounds WIP at the budget checkpoint: pack-behavior ran 41 pass / 8 fail before the priming fix in this commit. 7.2 (the first walk resolved on the plane tier: charController's lazy physics import; primed now, not re-run). 7.6 (the reopen click on the frame post did not toggle) cascades into 8.2/9.1/11.2/13.1/13.2 - next window's first job. Counterfactuals already green: 7.7 (uncut frame = a wall when open), 2.3/12.1 (no autoplay), 14.1 (slow hand). svelte-check 333/47 (= base), vitest 396/396, check-storage clean. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 7 +- src/components/Scene.svelte | 3 + src/components/editors/AnimationWindow.svelte | 18 +- src/components/editors/Explorer.svelte | 46 +- src/lib/animatedImports.js | 128 +++- src/lib/behaviorCore.js | 263 ++++++++ src/lib/explorerDrop.js | 5 +- src/lib/fileHandler.svelte.js | 22 +- src/lib/gameSfx.js | 42 +- src/lib/moduleSDK.js | 32 + src/lib/packBehavior.js | 600 ++++++++++++++++++ src/lib/packs.js | 6 +- src/lib/peerHandler.svelte.js | 5 + src/lib/peerScenes.js | 2 +- src/lib/vrGameInput.js | 3 +- src/lib/wireValidate.js | 9 + tests/e2e/animated-models.test.cjs | 13 +- tests/e2e/animation-persist.test.cjs | 14 +- tests/e2e/fixtures/build-test-door.mjs | 91 +++ tests/e2e/fixtures/test-door.glb | Bin 0 -> 5440 bytes tests/e2e/game-audio.test.cjs | 4 +- tests/e2e/pack-behavior.test.cjs | 372 +++++++++++ tests/unit/behaviorCore.test.js | 141 ++++ 23 files changed, 1791 insertions(+), 35 deletions(-) create mode 100644 src/lib/behaviorCore.js create mode 100644 src/lib/packBehavior.js create mode 100644 tests/e2e/fixtures/build-test-door.mjs create mode 100644 tests/e2e/fixtures/test-door.glb create mode 100644 tests/e2e/pack-behavior.test.cjs create mode 100644 tests/unit/behaviorCore.test.js diff --git a/src/App.svelte b/src/App.svelte index 4d640087..ec2cbb33 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -494,9 +494,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/moduleWorld'), import('./lib/playSpawn'), import('./lib/packRefs'), - import('./lib/lod') - ]).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, knockLib, 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, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib]) => { - 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, knock: knockLib, 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, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib } + import('./lib/lod'), + import('./lib/packBehavior') + ]).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, knockLib, 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, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib, packBehaviorLib]) => { + 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, knock: knockLib, 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, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib, packBehavior: packBehaviorLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index b47e1714..b225a63a 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -40,6 +40,7 @@ import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; import { tickAnimatedMixers } from '$lib/animatedImports'; + import { startPackBehaviors, tickPackBehaviors } from '$lib/packBehavior'; import { tickAnimationPreview, captureAutoKey, playheadOf } from '$lib/animationPreview'; import { drawMode, drawTool, strokePointFromRay, endStroke, setDrawScene } from '$lib/drawMode'; import { splinePlaceFromRay, splineToolActive, finishSpline } from '$lib/splineTool'; @@ -388,6 +389,7 @@ updateVRControls(); // also manages ray/hover visibility outside sessions updateSpatialAudio(camera.current, scene); // voices follow avatars (throttled) tickAnimatedMixers(); // imported clips run on the synced clock + tickPackBehaviors(); // 33 P2: doors/lids/levers pose from their shared state (rest in Edit) tickAnimationPreview(); // Animation window: local transform preview (not synced) tickMeshEdit(); // vertex handles follow the object if it moves (119) tickEditWireframe(); // ...and the edit wireframe stays parented to it (faceEdit) @@ -1435,6 +1437,7 @@ // A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock }); startClap({ hands: handSnapshot }); // 31: the same hand seam + startPackBehaviors({ hands: handSnapshot }); // 33 P2: functional pack items (the knock reads hands) // 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries) startVrGameInput(); diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index f7829e7c..9087370b 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -32,7 +32,7 @@ // posed from the synced clock) — the window used to ignore them entirely, so // a rigged model showed "no movements yet" and its own animations were // reachable only from the Inspector. - import { animatedObjects, setAnimationState, clipInfo } from '$lib/animatedImports'; + import { animatedObjects, setAnimationState, clipInfo, behaviorOf } from '$lib/animatedImports'; import { SkipBack, SkipForward, StepBack, StepForward, Play, Pause, Square, Rewind, ZoomIn, ZoomOut, Maximize2, SquareDashed, Lasso, Ghost @@ -142,6 +142,13 @@ $animatedObjects; return target ? clipInfo(target.uuid) : []; }); + // 33 P2: a FUNCTIONAL item (a door, a lid) has no transport: its clip buttons PREVIEW + // locally (never sent, never saved, back to rest at the end) — it plays for real only + // on its trigger, in Interact or Play + const behavior = $derived.by(() => { + $animatedObjects; + return target ? behaviorOf(target.uuid) : null; + }); // transport is per object now (N objects can play at once); the playhead time // comes from the per-frame `playheads` store, the transport state does not. @@ -1863,7 +1870,7 @@ {#each clips as clip (clip.name)} {/each} + {#if behavior} +

+ {behavior.type === 'loop' && behavior.autoplay ? 'Ambient loop' : 'Functional item'} — runs in + Interact or Play{behavior.type === 'loop' && behavior.autoplay ? '' : ' on its ' + behavior.trigger}. + Here ▶ previews it once; nothing is sent or saved. +

+ {/if}