diff --git a/MODULES.md b/MODULES.md index 3c527c4d..cda520d6 100644 --- a/MODULES.md +++ b/MODULES.md @@ -502,10 +502,21 @@ if (api.quality) { if (api.lod) { const lod = api.lod(enemyFigure, { ratios: [0.5, 0.2], distances: [6, 18] }); lod.ready.then((n) => console.log(n, "meshes have levels")); // lod.remove() undoes it + lod.force?.(2); // 1.19: draw level 2 on THIS screen whatever the distance (null = auto) + lod.levels?.(); // 1.19: the level each mesh drew last (0 = full) } mesh.userData.lod = false; // keep one mesh out of auto LOD ``` +**1.19 — LOD groups on scene objects.** A replicated object may carry a LOD GROUP +(`userData.lod`, edited in its Properties ▸ LOD section, or written by a pack item's `lods`): +`{mode: 'auto'|'forced', forced?, bias?, cull?, levels: [{source: 'self'|'pack'|'generated'| +'explorer'|'object', ref?, ratio?, screenSize, offset?, material?}]}` with thresholds by SCREEN +SIZE (the share of the viewport height the object covers). It is scene data (saved, replicated, +undone) and drawn at render time like `api.lod` — the tree never holds a level. A module that +builds scene objects can write one through the same path the panel uses +(`objectParameters {parameter: 'lod'}` is the wire shape); module-only geometry keeps `api.lod`. + Both are LOCAL (a fact about this machine) — never let them change replicated state, or two peers on different hardware disagree about the game. Skinned meshes and morph targets get no levels (the simplifier cannot carry weights): pre-decimate those offline. diff --git a/PACKS.md b/PACKS.md index 7152b904..5530b55f 100644 --- a/PACKS.md +++ b/PACKS.md @@ -52,6 +52,35 @@ CC0, OpenGameArt CC0). Keep each file under the 5 MB share cap so it round-trips peers. Installed audio items appear in the Explorer library and can be assigned to a **Sound** node (spatial) or the **Scene music** channel (global). +### Levels of detail: the `lods` field (contract P1, 1.19) + +A model item in an item list (the `default.json` row beside `variants`) MAY list offline +LOD files: + +```jsonc +{ "name": "WallStone", "label": "Wall — sandstone (2 × 3 m)", + "variants": { "glTF-Binary": "wall-stone.glb" }, + "lods": [ { "file": "wall-stone.lod1.glb", "ratio": 0.5 }, + { "file": "wall-stone.lod2.glb", "ratio": 0.2 } ] } +``` + +- `file` is relative to the item's `glTF-Binary/` folder (no `..`, no absolute URLs); + `ratio` is the level's triangle count as a share of LOD0 (informational — the app sorts + levels finest first by it and shows it in the LOD panel). +- **Keep the node names and hierarchy of LOD0.** The app matches each level mesh to the + placed object's mesh BY NODE NAME (falling back to traverse order when both have the same + mesh count) and draws the level's GEOMETRY with the object's OWN material — so a level + file's materials and textures are not used at all (ship them shared or tiny), and an + animated item keeps animating, because the mixer still drives the same nodes. +- Bake the same node transforms as LOD0 where you can; a level mesh placed differently is + re-expressed in LOD0's frame on load, which costs a geometry copy per placement. +- Placing an item writes its group into the object (`userData.lod`, refs as + PACKS_BASE-relative paths), so it saves, replicates and undoes with the object. A piece + placed BEFORE its pack gained `lods` picks them up from the pack row at runtime. +- Levels are fetched LAZILY — only when an object is small enough on screen to need one — + and parsed once per file for every placement. Items without `lods` keep the automatic + runtime LOD (meshoptimizer, for meshes of 3000+ triangles). + ## Repo / .zip structure ``` diff --git a/src/App.svelte b/src/App.svelte index 4d640087..17298a33 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -34,6 +34,7 @@ import StatsOverlay from './components/menu/StatsOverlay.svelte' import { startSceneMetrics, budgetSummary } from './lib/sceneBudget' import { startLod } from './lib/lod' + import { startLodGroups } from './lib/lodGroup' import PlayReticle from './components/play/PlayReticle.svelte' import TouchPlayControls from './components/play/TouchPlayControls.svelte' import DrawToolbar from './components/menu/DrawToolbar.svelte' @@ -141,6 +142,8 @@ import { startMusicToolbox } from './lib/musicToolbox' startSceneMetrics() // 31-perf P2: automatic levels of detail (a render-time swap; see lod.js) startLod() + // 33: per-object LOD GROUPS (userData.lod / a pack item's `lods`) ride the same render pass + startLodGroups() // …and the numbers ride the bundle, so a report carries them (audit H4 + roadmap // 26 section 3's last row). registerDiagnosticsSection('scene-budget', () => budgetSummary()) @@ -494,9 +497,12 @@ 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/lodGroup'), + import('./lib/lodGroupActions'), + import('./lib/lodLevelEdit') + ]).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, lodGroupLib, lodGroupActionsLib, lodLevelEditLib]) => { + 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, lodGroup: lodGroupLib, lodGroupActions: lodGroupActionsLib, lodLevelEdit: lodLevelEditLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index b47e1714..e14c16cd 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -44,6 +44,7 @@ import { drawMode, drawTool, strokePointFromRay, endStroke, setDrawScene } from '$lib/drawMode'; import { splinePlaceFromRay, splineToolActive, finishSpline } from '$lib/splineTool'; import { flattenPicking, flattenPickClick } from '$lib/flattenActions'; + import { onLodProxyMoved, onLodProxyDragChanged } from '$lib/lodLevelEdit'; import { splineEditObject, splineEditClick, splineEditRightClick, exitSplineEdit, beginRadiusDrag, radiusDragMove, endRadiusDrag, radiusDragActive, onSplineProxyMoved, onSplineProxyDragChanged, tickSplineEdit } from '$lib/splineEdit'; import { capturePathClick } from '$lib/pathCapture'; import { surfaceSnap, dropToSurface } from '$lib/snapping'; @@ -424,7 +425,7 @@ // multi-select pivot excludes EVERY member (P3: its drags snap too). if (!event.value) endSnapDrag(); else if (object.userData?.isMultiPivot) beginSnapDrag([...$selectedObjects]); - else if (!object.userData?.isVertexProxy && !object.userData?.isFaceProxy) + else if (!object.userData?.isVertexProxy && !object.userData?.isFaceProxy && !object.userData?.isLodLevelProxy) beginSnapDrag([object.uuid]); // vertex handles record their own history entries if (object.userData?.isVertexProxy) { @@ -441,6 +442,11 @@ onSplineProxyDragChanged(event.value); return; } + // 33: one LOD level's offset — ONE lod write per drag, never a move + if (object.userData?.isLodLevelProxy) { + onLodProxyDragChanged(event.value); + return; + } // the multi-select pivot records per-member entries (multiTransform) if (object.userData?.isMultiPivot) return; if (event.value) { @@ -1492,6 +1498,12 @@ onSplineProxyMoved(); return; } + // 33: a LOD level's proxy — the level follows locally; the release commits + if ($TControls.object?.userData?.isLodLevelProxy) { + $TControls.visible = true; + onLodProxyMoved(); + return; + } // multi-select pivot: multiTransform drives + broadcasts the members, // the pivot itself is local-only (its uuid means nothing to peers) if ($TControls.object?.userData?.isMultiPivot) { diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 1b2b2135..d196f005 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -5292,7 +5292,9 @@ kind: item.kind, name: item.name, prefabId: item.prefabId ?? null, - url: item.glbUrl ?? null + url: item.glbUrl ?? null, + // 33: a pack item's LOD files travel with the drag so the drop places the group + ...(item.lods ? { lods: item.lods } : {}) }; } /** @@ -5702,8 +5704,10 @@ return showToast('Could not fetch the pack item'); } // 30c: the placed piece carries its pack reference (packRefs.js) + const { placementGroupFor } = await import('$lib/lodGroup'); await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb', undefined, undefined, { - packRef: packRefFromUrl(item.glbUrl, { pack: item.packName, item: item.name }) + packRef: packRefFromUrl(item.glbUrl, { pack: item.packName, item: item.name }), + lod: placementGroupFor(item.glbUrl, item.lods) }); dismiss(); } catch { diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 505fc8e4..eb3d354d 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -10,6 +10,7 @@ import ThemedSelect from '../ui/ThemedSelect.svelte'; import PanelHeader from '../ui/PanelHeader.svelte'; import Section from '../ui/Section.svelte'; + import LodGroupPanel from './LodGroupPanel.svelte'; import SliderRow from '../ui/SliderRow.svelte'; import DragRow from '../ui/DragRow.svelte'; import ColorPicker, { ChromeVariant } from 'svelte-awesome-color-picker'; @@ -3202,6 +3203,13 @@ {/if} + {#if !isLight && !multiCount && $selectedObject?.uuid} + +
+ +
+ {/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..76991cfd --- /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, paired: s?.kind === 'swap' ? s.pairs.length / 3 : null, error: s?.error ?? 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..df73f7fa --- /dev/null +++ b/tests/e2e/lod-panel.test.cjs @@ -0,0 +1,282 @@ +// 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 -------------------------------------------------------------------------------- + const msgs7 = await lodMsgs(); + 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()) === msgs7 && !(await block(uuid)).overlay, '7.3 the overlay is LOCAL: toggling it on and off sent no lod message and wrote nothing into the block'); + + // ---- 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/lod-real-packs.test.cjs b/tests/e2e/lod-real-packs.test.cjs new file mode 100644 index 00000000..e27098f4 --- /dev/null +++ b/tests/e2e/lod-real-packs.test.cjs @@ -0,0 +1,134 @@ +// 33 (K2) — REAL pack LOD files (33-pack-fix-lod's offline levels) through the loader. The +// pack files are served from a local checkout of that branch: +// LOD_PACKS_DIR= +// (absent = SKIP, never fail). For each item: placed through the real list + import path, every +// level builds as a name-matched SWAP covering every mesh, far away the frame draws the level's +// triangles (measured from renderer.info with the piece shown vs hidden), and it has no page +// errors. Near/far shots (plain + "Show LOD level") go to LOD_SHOTS when set. +// Run: LOD_PACKS_DIR=... APP_URL=https://theprototype.app:5283/ npm run e2e -- lod-real-packs +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const DIR = process.env.LOD_PACKS_DIR; +const SHOTS = process.env.LOD_SHOTS; +const ITEMS = [ + ['architecture-kit', 'Gate'], + ['architecture-kit', 'WallStone'], + ['default', 'Duck'], + ['cube_diorama', 'Cat'] +]; + +h.run(async () => { + if (!DIR || !fs.existsSync(DIR)) { + console.log('SKIP: LOD_PACKS_DIR not set / missing — nothing to check'); + console.log('ALL PASS'); + process.exit(0); + } + 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 base = (await page.evaluate(() => window.__stores.packs.PACKS_BASE)).replace(/\/+$/, ''); + await page.route(base + '/**', (route) => { + const rel = decodeURIComponent(route.request().url().slice(base.length + 1).split('?')[0]); + const file = path.join(DIR, rel); + if (!file.startsWith(DIR) || !fs.existsSync(file)) return route.fulfill({ status: 404, body: '' }); + const type = file.endsWith('.json') ? 'application/json' : file.endsWith('.glb') ? 'model/gltf-binary' : 'application/octet-stream'; + route.fulfill({ status: 200, contentType: type, body: fs.readFileSync(file) }); + }); + if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true }); + + let x = -12; + for (const [pack, name] of ITEMS) { + x += 6; + const res = await page.evaluate( + async ({ base, pack, name, x }) => { + const s = window.__stores; + const read = (st) => { + let v; + st.subscribe((q) => (v = q))(); + return v; + }; + const items = await s.packs.loadPackItems({ name: pack, base: base + '/' + pack, listUrl: base + '/' + pack + '/default.json', source: 'default' }); + const item = items.find((i) => i.name === name); + if (!item) return { error: 'no row' }; + const r = await fetch(item.glbUrl); + const uuid = await s.fileHandler.importFile(new File([await r.blob()], name + '.glb'), name, 'glb', [x, 0, 0], undefined, { + packRef: s.packRefs.packRefFromUrl(item.glbUrl, { pack, item: name }), + lod: s.lodGroup.placementGroupFor(item.glbUrl, item.lods) + }); + const root = read(s.objectsGroup).getObjectByProperty('uuid', uuid); + s.lodGroup.scanLodGroups(); + s.lodGroup.buildAllLevels(uuid); + const end = performance.now() + 20000; + let st; + while (performance.now() < end) { + st = s.lodGroup.lodGroupStats().find((g) => g.uuid === uuid); + if (st && st.levels.every((l) => l.status === 'ready' || l.status === 'failed')) break; + await new Promise((q) => setTimeout(q, 150)); + } + // triangles the piece adds to ONE render from `d` m (shown minus hidden) + const box = new s.THREE.Box3().setFromObject(root); + const c = box.getCenter(new s.THREE.Vector3()); + const renderer = read(s.globalRenderer); + const scene = read(s.globalScene); + const drawn = (d) => { + const cam = new s.THREE.PerspectiveCamera(50, 16 / 9, 0.1, 5000); + cam.position.set(c.x, c.y, c.z + d); + cam.lookAt(c); + cam.updateMatrixWorld(true); + const auto = renderer.info.autoReset; + renderer.info.autoReset = true; + renderer.render(scene, cam); + const on = renderer.info.render.triangles; + root.visible = false; + renderer.render(scene, cam); + const off = renderer.info.render.triangles; + root.visible = true; + renderer.info.autoReset = auto; + return on - off; + }; + const radius = st.radius; + const near = drawn(radius * 2.5); + const far = drawn(radius * 60); + let meshes = 0; + root.traverse((o) => o.isMesh && !o.isSkinnedMesh && meshes++); + return { uuid, lods: item.lods, levels: st.levels, meshes, near, far, radius }; + }, + { base, pack, name, x } + ); + if (res.error) { + h.check(false, pack + '/' + name + ': ' + res.error); + continue; + } + const tag = pack + '/' + name; + const lv = res.levels.slice(1); + h.check(lv.length === res.lods.length && lv.every((l) => l.status === 'ready' && l.kind === 'swap'), tag + ': every level file builds as a SWAP (' + JSON.stringify(lv.map((l) => [l.status, l.kind, l.error])) + ')'); + h.check(lv.every((l) => l.paired === res.meshes), tag + ': each level matched ALL ' + res.meshes + ' meshes by name (' + lv.map((l) => l.paired) + ')'); + const coarse = lv[lv.length - 1]; + h.check(res.far > 0 && res.far < res.near * 0.75, tag + ': far away the frame draws fewer triangles (' + res.near + ' near -> ' + res.far + ' far; coarsest level ' + coarse.tris + ', LOD0 ' + res.levels[0].tris + ')'); + if (SHOTS) { + for (const [label, dist, overlay] of [['near', 2.5, false], ['far', 12, false], ['far-overlay', 12, true]]) { + await page.evaluate( + ({ uuid, dist, overlay }) => { + const s = window.__stores; + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const root = g.getObjectByProperty('uuid', uuid); + const box = new s.THREE.Box3().setFromObject(root); + const c = box.getCenter(new s.THREE.Vector3()); + const r = s.lodGroup.lodGroupStats().find((x) => x.uuid === uuid).radius; + s.lod.lodShowLevels.set(overlay); + s.objectActions.flyTo([c.x, c.y + r * 0.6, c.z + r * dist], [c.x, c.y, c.z], 0); + }, + { uuid: res.uuid, dist, overlay } + ); + await page.waitForTimeout(700); + await page.screenshot({ path: path.join(SHOTS, name + '-' + label + '.png') }); + } + await page.evaluate(() => window.__stores.lod.lodShowLevels.set(false)); + } + } + h.check(h.pageErrors(A).length === 0, 'no page errors (' + JSON.stringify(h.pageErrors(A)) + ')'); + 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'); + }); +});