From 1fbd43bb0772e96a6a5cb72c032a9e923e534c24 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 19:54:24 +0300 Subject: [PATCH 01/44] [fix] vr grabs are per hand: two controllers hold two things (33 G4) - vrControls kept ONE grab slot. A second hand's grab overwrote it, so the first hand's piece stayed in its kinematic physics hold with nothing updating or releasing it: it hung in mid-air until a hand took it again (Towers report). - grabs[] is indexed by controller slot; each hand updates and releases its own, a released body is dynamic at once. Two hands on the SAME object still scale it in Edit (and still do nothing extra in Interact). - vrGrabbedHand reads 'both' while both hands hold; the two stick gates honour it. - vrGrabbedUuids(): the knock probe skips everything the hands hold. - an input-source rebind now RELEASES what the hands held instead of dropping the grab with the body still kinematic. - new suite vr-two-hand-grab (19, fake XR + a running rapier sim). CF: clearing the other hand's slot on grab (the old single-slot shape) -> 6 red (1.1, 1.2, 2.2, 2.3, 2.5 hold user/bodyType 2 = the frozen crate, 2.12). - vr-grab 7, vr-grips-by-mode 18, vr-source-rebind 5, knock-physics 77, vr-sleeve 29 green. svelte-check 333/47 unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 4 +- src/components/play/VRControls.svelte | 2 +- src/lib/vrControls.js | 78 ++++++++--- tests/e2e/vr-two-hand-grab.test.cjs | 192 ++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/vr-two-hand-grab.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index b47e1714..3d355368 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -56,7 +56,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuids, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; import { gameFeelActive } from '$lib/gameFeel'; @@ -1433,7 +1433,7 @@ // this seam rather than an import (knock.js stays off vrControls' 3500 lines), // and the two "what am I holding" reads keep a probe off its own carried object. // A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses - startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock }); + startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), ...vrGrabbedUuids()], haptic: hapticKnock }); startClap({ hands: handSnapshot }); // 31: the same hand seam // 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries) startVrGameInput(); diff --git a/src/components/play/VRControls.svelte b/src/components/play/VRControls.svelte index 4a190adb..915867f0 100644 --- a/src/components/play/VRControls.svelte +++ b/src/components/play/VRControls.svelte @@ -38,7 +38,7 @@ // 30b P3: INTERACT walks like a game (collision, gravity, step-up; no fly or teleport // unless the play block allows them) — a held object does not stop your feet if (tickVRInteractLocomotion(delta, session)) return; - if ($vrGrabbedHand === 'left') return; // a left-hand grab owns its stick (100) + if ($vrGrabbedHand === 'left' || $vrGrabbedHand === 'both') return; // a left-hand grab owns its stick (100) if (twoGripStretchActive()) return; // 186: both grips + sticks stretch, not move // D9: world pan/grab write reference-space offsets themselves, and the // mesh-edit gestures read the sticks for reel/scale — never also move diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ac5ae97c..01fe6877 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -262,14 +262,27 @@ const raycaster = new THREE.Raycaster(); const tempMatrix = new THREE.Matrix4(); const tempVector = new THREE.Vector3(); -/** @type {any} single-hand grab: { object, index, prevPos, prevQuat, before } */ -let grab = null; +/** @type {any[]} per-hand grabs, indexed by controller SLOT: each is { object, index, prevPos, + * prevQuat, before, ... }. 33 G4: one per hand, so two hands hold two things at once — a single + * shared slot let the second hand's grab overwrite the first, and the first hand's piece sat + * in its kinematic hold in mid-air until it was grabbed again (the Towers report). */ +const grabs = [null, null]; /** @type {any} two-hand scale: { object, startDistance, startScale, before } */ let scaleGrab = null; /** 24-A A1: the object a VR hand is holding right now, or null — the knock probe * skips it (a hand knocking the crate it is carrying would fight its own hold). */ export function vrGrabbedUuid() { - return grab?.object?.uuid ?? scaleGrab?.object?.uuid ?? null; + return grabs[0]?.object?.uuid ?? grabs[1]?.object?.uuid ?? scaleGrab?.object?.uuid ?? null; +} +/** 33 G4: EVERY object a VR hand holds right now (both hands may each hold one) */ +export function vrGrabbedUuids() { + return [grabs[0]?.object?.uuid, grabs[1]?.object?.uuid, scaleGrab?.object?.uuid].filter(Boolean); +} +/** 33 G4: vrGrabbedHand = the hand holding something, or 'both' — the stick gates read it */ +function syncGrabbedHand() { + /** @type {any[]} */ + const hands = grabs.filter(Boolean).map((g) => renderer?.xr?.getController(g.index)?.userData?.handedness ?? null); + vrGrabbedHand.set(hands.length === 0 ? null : hands.length === 1 ? hands[0] : 'both'); } let lastMoveSent = 0; @@ -1687,7 +1700,12 @@ export function onInputSourcesChange() { vrEndHandleDrag(); vertexTriggerGrab = null; } - grab = null; + // a dropped grab RELEASES its body (it used to leave it in its kinematic hold) + for (let i = 0; i < grabs.length; i++) { + const held = grabs[i]; + grabs[i] = null; + if (held) endGrab(held.object, held.interact ? null : held.before); + } scaleGrab = null; worldGrab = null; worldPan = null; @@ -2188,8 +2206,10 @@ let worldPan = null; /** 30b P2: test/debug view of what the grips are doing right now */ export function vrGripDebug() { return { - grab: grab?.object?.uuid ?? null, - grabInteract: !!grab?.interact, + grab: (grabs[0] ?? grabs[1])?.object?.uuid ?? null, + grabInteract: !!(grabs[0] ?? grabs[1])?.interact, + grabs: grabs.map((g) => g?.object?.uuid ?? null), + scaleGrab: scaleGrab?.object?.uuid ?? null, worldGrab: !!worldGrab, worldPan: !!worldPan, emptyAir: [...emptyAirSqueeze] @@ -2862,7 +2882,8 @@ function onSqueezeStart(index) { } if (get(lockedObjects).find((lock) => lock[1] === object.uuid)) return; - if (grab && grab.object === object && grab.index !== index) { + const other = grabs[1 - index]; + if (other && other.object === object) { // 30b P2: a player's second hand does not resize the thing it is holding if (mode === 'interact') return; // second hand on the same object -> two-hand scale @@ -2871,12 +2892,18 @@ function onSqueezeStart(index) { object, startDistance: Math.max(distance, 0.05), startScale: object.scale.clone(), - before: grab.before + before: other.before }; - grab = null; - vrGrabbedHand.set(null); + grabs[1 - index] = null; + syncGrabbedHand(); return; } + // 33 G4: the OTHER hand keeps whatever it holds; only this hand's slot changes + const previous = grabs[index]; + if (previous) { + grabs[index] = null; + endGrab(previous.object, previous.interact ? null : previous.before); + } const interact = mode === 'interact'; suspendAnimation(object.uuid); // animated objects park at their base while held @@ -2894,7 +2921,7 @@ function onSqueezeStart(index) { const parentInv = object.parent.matrixWorld.clone().invert(); const pPos = cPos.clone().applyMatrix4(parentInv); const pQuat = parentQuat.clone().invert().multiply(cQuat); - grab = { + grabs[index] = { object, index, // 30b P2: a player's hand is RIGID (no gizmo-style move/rotate), and `interact` @@ -2909,7 +2936,7 @@ function onSqueezeStart(index) { prevQuat: cQuat, before: transformStateOf(object) }; - vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null); + syncGrabbedHand(); // 30b (C4): a grab lands with a `hit` (a gated no-op in Edit, like every pulse) hapticPattern('hit', renderer.xr.getController(index)?.userData?.handedness ?? undefined); // 30b P2: a player picking something up is not SELECTING it — no lock broadcast, no @@ -3040,7 +3067,8 @@ function onSqueezeEnd(index) { scaleGrab = null; return; } - if (grab && grab.index === index) { + const grab = grabs[index]; + if (grab) { // K2: a hook may consume the release (drop onto the sleeve = capture a // slot; the hook restores the object's pose + animation itself, so no // move commits — but the physics hold must still release) @@ -3055,14 +3083,14 @@ function onSqueezeEnd(index) { }); if (consumed) { import('./physics').then((m) => m.releaseBody(object.uuid)); - grab = null; - vrGrabbedHand.set(null); + grabs[index] = null; + syncGrabbedHand(); hapticPulse(0.4, 60); return; } + grabs[index] = null; endGrab(object, grab.interact ? null : grab.before); - grab = null; - vrGrabbedHand.set(null); + syncGrabbedHand(); hapticPulse(0.18, 24); } } @@ -3105,7 +3133,8 @@ export function grabStickAdjust({ length, scale, x, y }) { }; } -function updateGrab() { +/** @param {any} grab one hand's grab (33 G4: each hand updates its own) */ +function updateGrab(grab) { const controller = renderer.xr.getController(grab.index); const position = controller.getWorldPosition(new THREE.Vector3()); const quaternion = controller.getWorldQuaternion(new THREE.Quaternion()); @@ -3839,6 +3868,7 @@ export function updateVRControls() { !get(vrChatPanelOpen) && !get(vrKeyboardTarget) && get(vrGrabbedHand) !== 'right' && + get(vrGrabbedHand) !== 'both' && !vrNavigationSuppressed({ grips: true }) ) { updateTeleport(session); @@ -3997,7 +4027,7 @@ export function updateVRControls() { }); if (scaleGrab) updateScaleGrab(); - else if (grab) updateGrab(); + else for (const held of grabs) if (held) updateGrab(held); // window grab (111): the hold timer arms, then the grip drives the window if (windowGrabPending && Date.now() - windowGrabPending.startedAt >= HOLD_MS) @@ -4308,11 +4338,13 @@ function enterInteractVR() { worldGrab = null; worldPan = null; emptyAirSqueeze[0] = emptyAirSqueeze[1] = false; - if (grab && !grab.interact) { - endGrab(grab.object, grab.before); - grab = null; - vrGrabbedHand.set(null); + for (let i = 0; i < grabs.length; i++) { + const held = grabs[i]; + if (!held || held.interact) continue; + grabs[i] = null; + endGrab(held.object, held.before); } + syncGrabbedHand(); resetWorldRig(); // at sessionstart no XR frame exists yet (so no viewer pose to move FROM): the spawn // waits for the first frame that has one (updateVRControls) diff --git a/tests/e2e/vr-two-hand-grab.test.cjs b/tests/e2e/vr-two-hand-grab.test.cjs new file mode 100644 index 00000000..2c181e8b --- /dev/null +++ b/tests/e2e/vr-two-hand-grab.test.cjs @@ -0,0 +1,192 @@ +// 33 G4: TWO HANDS, TWO THINGS. The Quest report (Towers): "when I grab another box for some +// reason the other one stuck in space until I take it again (only then physics applied to +// it)". vrControls kept ONE grab slot: the second hand's grab overwrote the first, the first +// piece stayed in its kinematic physics hold, nothing ever updated or released it, and it hung +// in mid-air until a hand took it again. Grabs are per HAND now. +// +// Driven through the REAL per-frame path (Scene's useTask -> updateVRControls) with a fake XR +// session (fakeXR.cjs) and a running rapier simulation: two emulated controllers each hold a +// different dynamic crate, carry both, and release them one at a time. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const grips = (page) => page.evaluate(() => window.__stores.vrControls.vrGripDebug()); +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + return g.getObjectByProperty('uuid', uuid).position.toArray(); + }, uuid); +const bodyOf = (page, uuid) => + page.evaluate((uuid) => { + const b = window.__stores.physics.physicsDebug().find((e) => e.uuid === uuid); + return b ? { hold: b.hold, bodyType: b.bodyType } : null; + }, uuid); +const grip = async (page, hand, down) => { + await xr.button(page, hand, 1, down); + await page.waitForTimeout(250); +}; + +h.run(async () => { + // GPU: software GL runs a few fps and the fixed-step accumulator then lags real time + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await A.page.waitForTimeout(2000); + + // ---- the fixture: a floor and two dynamic crates side by side --------------------------- + const ids = await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 3; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 1200)); + const [floor, a, b] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + floor.scale.set(20, 0.5, 20); + floor.position.set(0, -0.25, 0); + floor.userData.physics = { mode: 'static' }; + for (const [box, x] of [[a, 0.7], [b, -0.7]]) { + box.scale.setScalar(0.4); + box.position.set(x, 0.2, -1.5); + box.rotation.set(0, 0, 0); + box.userData.physics = { mode: 'dynamic', mass: 1 }; + } + for (const o of [floor, a, b]) o.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.isVRMode.set(true); + return { floor: floor.uuid, a: a.uuid, b: b.uuid }; + }); + await A.page.waitForTimeout(600); + await xr.install(A.page); + // park both hands high and aimed at nothing (straight up) + const park = async () => { + await xr.pose(A.page, 'left', [-0.3, 1.6, 0], { pitch: Math.PI / 2 }); + await xr.pose(A.page, 'right', [0.3, 1.6, 0], { pitch: Math.PI / 2 }); + }; + await park(); + + // ================================================================ 1. EDIT, no simulation + console.log('\n=== 1. Edit: each hand holds its own crate ==='); + { + const a0 = await posOf(A.page, ids.a); + const b0 = await posOf(A.page, ids.b); + await xr.pose(A.page, 'right', [a0[0], a0[1], 0]); // aims -Z through crate A + await grip(A.page, 'right', true); + await xr.pose(A.page, 'left', [b0[0], b0[1], 0]); // aims -Z through crate B + await grip(A.page, 'left', true); + const g = await grips(A.page); + h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `1.1 the right hand holds A, the left holds B (${JSON.stringify(g.grabs)})`); + // lift both hands half a metre: BOTH crates follow their own hand + await xr.pose(A.page, 'right', [a0[0] + 0.3, a0[1] + 0.5, 0]); + await xr.pose(A.page, 'left', [b0[0] - 0.3, b0[1] + 0.5, 0]); + await A.page.waitForTimeout(300); + const a1 = await posOf(A.page, ids.a); + const b1 = await posOf(A.page, ids.b); + h.check(Math.abs(a1[1] - (a0[1] + 0.5)) < 0.05 && Math.abs(a1[0] - (a0[0] + 0.3)) < 0.05, `1.2 crate A follows the right hand (y ${a0[1].toFixed(2)} -> ${a1[1].toFixed(2)})`); + h.check(Math.abs(b1[1] - (b0[1] + 0.5)) < 0.05 && Math.abs(b1[0] - (b0[0] - 0.3)) < 0.05, `1.3 crate B follows the left hand (y ${b0[1].toFixed(2)} -> ${b1[1].toFixed(2)})`); + await grip(A.page, 'right', false); + await grip(A.page, 'left', false); + const g2 = await grips(A.page); + h.check(g2.grabs.every((u) => u === null), `1.4 both releases end both grabs (${JSON.stringify(g2.grabs)})`); + // the editor's two-hand SCALE still happens when both hands take the SAME object + const a2 = await posOf(A.page, ids.a); + await xr.pose(A.page, 'right', [a2[0], a2[1], 0]); + await grip(A.page, 'right', true); + await xr.pose(A.page, 'left', [a2[0] - 0.05, a2[1], 0]); + await grip(A.page, 'left', true); + const g3 = await grips(A.page); + h.check(g3.scaleGrab === ids.a && g3.grabs.every((u) => u === null), `1.5 two hands on ONE object still scale it in Edit (${g3.scaleGrab === ids.a})`); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + // put the crates back on the floor for the simulation + await A.page.evaluate((ids) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + for (const [u, x] of [[ids.a, 0.7], [ids.b, -0.7]]) { + const o = g.getObjectByProperty('uuid', u); + o.position.set(x, 0.2, -1.5); + o.rotation.set(0, 0, 0); + o.scale.setScalar(0.4); + o.updateMatrixWorld(true); + } + window.__stores.objectActions.deselectObject(); + }, ids); + await park(); + } + + // ================================================================ 2. INTERACT + physics + console.log('\n=== 2. Interact + a running simulation: two crates, two hands ==='); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await A.page.waitForTimeout(1500); + const running = await A.page.evaluate(() => window.__stores.physics.physicsDebug().length); + h.check(running >= 2, `2.0 (premise) the simulation is running (${running} dynamic bodies)`); + const a0 = await posOf(A.page, ids.a); + const b0 = await posOf(A.page, ids.b); + h.check(a0[1] < 0.4 && b0[1] < 0.4, `2.0b (premise) both crates rest on the floor (y ${a0[1].toFixed(2)}, ${b0[1].toFixed(2)})`); + + await xr.pose(A.page, 'right', [a0[0], a0[1], 0]); + await grip(A.page, 'right', true); + let ba = await bodyOf(A.page, ids.a); + h.check(ba?.hold === 'user', `2.1 the right hand holds crate A (hold ${ba?.hold})`); + await xr.pose(A.page, 'left', [b0[0], b0[1], 0]); + await grip(A.page, 'left', true); + let g = await grips(A.page); + h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `2.2 the second hand takes B and the first keeps A (${JSON.stringify(g.grabs)})`); + + // carry both up to 1.3 m: each crate rides ITS hand (the bug: A hung where B's grab began) + await xr.pose(A.page, 'right', [a0[0], 1.3, 0]); + await xr.pose(A.page, 'left', [b0[0], 1.3, 0]); + await A.page.waitForTimeout(400); + const a1 = await posOf(A.page, ids.a); + const b1 = await posOf(A.page, ids.b); + h.check(Math.abs(a1[1] - 1.3) < 0.08, `2.3 crate A rises with the right hand while the left holds B (y ${a1[1].toFixed(2)})`); + h.check(Math.abs(b1[1] - 1.3) < 0.08, `2.4 crate B rises with the left hand (y ${b1[1].toFixed(2)})`); + + // release A only: it is a dynamic body AT ONCE and falls; B stays in the left hand + await xr.button(A.page, 'right', 1, false); + await A.page.waitForTimeout(120); + ba = await bodyOf(A.page, ids.a); + let bb = await bodyOf(A.page, ids.b); + h.check(ba?.hold === null && ba?.bodyType === 0, `2.5 released crate A gets physics immediately (hold ${ba?.hold}, bodyType ${ba?.bodyType})`); + h.check(bb?.hold === 'user', `2.6 crate B is still held by the left hand (hold ${bb?.hold})`); + await A.page.waitForTimeout(900); + const a2 = await posOf(A.page, ids.a); + const b2 = await posOf(A.page, ids.b); + h.check(a2[1] < 0.6, `2.7 crate A fell (y ${a1[1].toFixed(2)} -> ${a2[1].toFixed(2)})`); + h.check(Math.abs(b2[1] - 1.3) < 0.08, `2.8 crate B did not fall while held (y ${b2[1].toFixed(2)})`); + // the left hand still carries B + await xr.pose(A.page, 'left', [b0[0] - 0.4, 1.3, 0]); + await A.page.waitForTimeout(300); + const b3 = await posOf(A.page, ids.b); + h.check(Math.abs(b3[0] - (b0[0] - 0.4)) < 0.08, `2.9 crate B still follows the left hand (x ${b3[0].toFixed(2)})`); + + // release B: it falls too + await xr.button(A.page, 'left', 1, false); + await A.page.waitForTimeout(1000); + bb = await bodyOf(A.page, ids.b); + const b4 = await posOf(A.page, ids.b); + h.check(bb?.hold === null && b4[1] < 0.6, `2.10 released crate B falls (hold ${bb?.hold}, y ${b4[1].toFixed(2)})`); + g = await grips(A.page); + h.check(g.grabs.every((u) => u === null), `2.11 no hand holds anything (${JSON.stringify(g.grabs)})`); + + // a re-grab in the SAME hand of another crate releases the first (a hand holds one thing) + const a5 = await posOf(A.page, ids.a); + const b5 = await posOf(A.page, ids.b); + await xr.pose(A.page, 'right', [a5[0], a5[1], 0]); + await grip(A.page, 'right', true); + await xr.pose(A.page, 'right', [a5[0], 1.2, 0]); + await A.page.waitForTimeout(300); + // the knock probe skips EVERYTHING the hands hold (both of them) + await xr.pose(A.page, 'left', [b5[0], b5[1], 0]); + await grip(A.page, 'left', true); + const held = await A.page.evaluate(() => window.__stores.vrControls.vrGrabbedUuids()); + h.check(held.includes(ids.a) && held.includes(ids.b), `2.12 vrGrabbedUuids names both held crates (${held.length})`); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await h.finish(browser); +}); From f6296f598faceb5c5c3a33ce74bf4978dcc984af Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:11:22 +0300 Subject: [PATCH 02/44] [feat] scene scope: a module a scene switch left behind stops counting (33 L4) The user: "opened waves game, then towers - music from waves stays and the gun, but only objects from towers load". A game module is a SCENE SCRIPT; when its scene goes away, whatever it registered for its own game must not reach the next. - sceneScope.js (a LEAF): the modules the scene was USING when it was cleared (noteSceneLeaving, called first thing in clearSceneLocal) and the next scene does not use are LEFT BEHIND; ownerInScope(owner). A fresh install in a blank scene is in scope (nothing was left behind), so every existing flow is unchanged. - gameShell: levels / How to play are kept PER OWNER and the newest in-scope registration shows - a kept Waves' levels do not appear in Towers' menu, and come back with no re-registration when Waves is open again. Restart hooks carry an owner and a left-behind one does not run. - gameSettings: a left-behind module's settings rows leave the Settings page. - gameMusic: the track is OWNED (api.music passes the module id). A left-behind owner's track stops and it cannot start one; api.music.stop only stops the module's own track; the module teardown journal now stops its music (it never did - "music from waves stays" after an unload too). - playSettings: a left-behind module's runtime spawn is ignored. - towers: onSceneClear unregistered nothing (it reset wasActive, so the frame edge saw false -> false) - Towers' twelve levels stayed in the next game. - api.inScene(): false once a switch left the module behind, so a game module can stand down its own drawing (the gun) - feature-detected. vitest sceneScope 12/12; counterfactual (ownerInScope -> true): 8 red. vitest 390/390, svelte-check 333/47 (baseline). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/commandsHandler.svelte.js | 5 ++ src/lib/gameFeelActions.js | 3 +- src/lib/gameMusic.js | 36 +++++--- src/lib/gameSettings.js | 50 ++++++++--- src/lib/gameShell.js | 78 ++++++++++++++---- src/lib/moduleSDK.js | 30 ++++++- src/lib/playSettings.js | 5 +- src/lib/sceneScope.js | 131 +++++++++++++++++++++++++++++ src/modules/towers/module.js | 12 +++ tests/unit/sceneScope.test.js | 133 ++++++++++++++++++++++++++++++ 10 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 src/lib/sceneScope.js create mode 100644 tests/unit/sceneScope.test.js diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index a40bac20..4b5c7f67 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -33,6 +33,8 @@ import { safeStorage } from './safeStorage'; // reach — it REGISTERS rather than importing us, the registerDiagnosticsSection shape. import { registerMetricSource, ingestVerdict, profileFor } from './sceneBudget'; import { globalRenderer } from '../stores/sceneStore.js'; +// 33 (L4): the scene going away is when its modules may be left behind (a leaf) +import { noteSceneLeaving } from './sceneScope'; //Access scene Store let scene = $state(); @@ -265,6 +267,9 @@ function sceneRoot() { } export function clearSceneLocal() { + // 33 (L4): what the scene uses is read BEFORE anything is wiped — those are the + // modules a replacement may leave behind (sceneScope) + noteSceneLeaving(); controls?.detach(); // 26-B: anything still parked in the ingest queue belongs to the scene being wiped dropIngestQueue(); diff --git a/src/lib/gameFeelActions.js b/src/lib/gameFeelActions.js index 2a0ca459..5c4e6c99 100644 --- a/src/lib/gameFeelActions.js +++ b/src/lib/gameFeelActions.js @@ -212,7 +212,8 @@ export function updateGameMusicNodes(nodes, resolve, gameStateName) { } if (debug.musicOwner !== null) { if (playing) { - stopGameMusic(); + // 33 (L4): only the track a node started (owner '' = core) + stopGameMusic(''); debug.musicStops++; } debug.musicOwner = null; diff --git a/src/lib/gameMusic.js b/src/lib/gameMusic.js index 40fdbde4..aae0c9f2 100644 --- a/src/lib/gameMusic.js +++ b/src/lib/gameMusic.js @@ -24,6 +24,9 @@ import { safeStorage } from './safeStorage'; import { gameFeelOn, gameFeelActive } from './gameFeel'; import { sfxTone, sfxNoise } from './gameSfx'; import { musicPreset, stepSeconds, stepEvents, MUSIC_PRESET_IDS } from './gameMusicPresets'; +// 33 (L4): the track belongs to whoever started it — a module left behind by a scene switch +// neither keeps its music playing nor starts a new track (a leaf) +import { leftBehindModules, ownerInScope } from './sceneScope'; export { MUSIC_PRESET_IDS }; @@ -42,13 +45,14 @@ function readVolume(raw, fallback) { /** "Music" volume, 0..1, LOCAL per device (Settings ▸ Interface ▸ Sound). */ export const gameMusicVolume = writable(readVolume(safeStorage.getItem(VOLUME_KEY), 0.6)); -/** what is playing now: `{preset, volume}` or null @type {import('svelte/store').Writable<{preset: string, volume: number} | null>} */ +/** what is playing now: `{preset, volume, owner}` or null — `owner` is the module that + * started it ('' = core: a flow node, the shell) @type {import('svelte/store').Writable<{preset: string, volume: number, owner?: string} | null>} */ export const gameMusicState = writable(null); /** * The running session. `gain` is PER PLAY: stopping fades and disconnects it, so any note * already scheduled into it goes silent with it — no voice bookkeeping needed. - * @type {{preset: any, volume: number, gain: GainNode, timer: any, lastStep: number, scheduled: number} | null} + * @type {{preset: any, volume: number, gain: GainNode, timer: any, lastStep: number, scheduled: number, owner: string} | null} */ let current = null; const debug = { steps: 0, notes: 0, refused: 0, stops: 0 }; @@ -67,6 +71,10 @@ gameMusicVolume.subscribe((v) => { gameFeelOn.subscribe((on) => { if (!on && current) stopGameMusic(); }); +// 33 (L4): "music from waves stays" — a scene switch that leaves the owner behind ends its track +leftBehindModules.subscribe(() => { + if (current && !ownerInScope(current.owner)) stopGameMusic(); +}); /** @param {number} midi */ const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12); @@ -141,12 +149,15 @@ function tick() { /** * Start (or switch to) a preset. Refused — false — outside Interact/Play, and for an * unknown preset name. The same preset already playing only takes the new volume. - * @param {string} presetId @param {{volume?: number}} [options] + * 33 (L4): `owner` is the module asking ('' = core); a module left behind by a scene switch + * is refused, and the track remembers who started it. + * @param {string} presetId @param {{volume?: number}} [options] @param {string} [owner] * @returns {boolean} */ -export function playGameMusic(presetId, options = {}) { +export function playGameMusic(presetId, options = {}, owner = '') { const preset = musicPreset(presetId); - if (!preset || !gameFeelActive()) { + const who = String(owner || ''); + if (!preset || !gameFeelActive() || !ownerInScope(who)) { debug.refused++; return false; } @@ -154,8 +165,9 @@ export function playGameMusic(presetId, options = {}) { const volume = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; if (current && current.preset.id === preset.id) { current.volume = volume; + current.owner = who; current.gain.gain.value = levelFor(volume); - gameMusicState.set({ preset: preset.id, volume }); + gameMusicState.set({ preset: preset.id, volume, owner: who }); return true; } if (current) stopGameMusic(); @@ -170,15 +182,19 @@ export function playGameMusic(presetId, options = {}) { const gain = ctx.createGain(); gain.gain.value = levelFor(volume); gain.connect(bus('music')); - current = { preset, volume, gain, timer: setInterval(tick, TICK_MS), lastStep: -1, scheduled: 0 }; + current = { preset, volume, gain, timer: setInterval(tick, TICK_MS), lastStep: -1, scheduled: 0, owner: who }; tick(); - gameMusicState.set({ preset: preset.id, volume }); + gameMusicState.set({ preset: preset.id, volume, owner: who }); return true; } -/** Stop whatever is playing (a short fade — a hard cut clicks). Safe to call anytime. */ -export function stopGameMusic() { +/** Stop whatever is playing (a short fade — a hard cut clicks). Safe to call anytime. + * 33 (L4): with `owner`, only a track that owner started stops — a kept module's + * "my game is not on, stop my music" must not silence the game that IS on. + * @param {string} [owner] */ +export function stopGameMusic(owner = undefined) { if (!current) return; + if (owner !== undefined && current.owner !== String(owner || '')) return; const { gain, timer } = current; current = null; clearInterval(timer); diff --git a/src/lib/gameSettings.js b/src/lib/gameSettings.js index 6fdb36f5..9db48877 100644 --- a/src/lib/gameSettings.js +++ b/src/lib/gameSettings.js @@ -24,6 +24,8 @@ // leaves, vrControls and the HUD all read it, from every side of the history-cycle family. import { writable, get } from 'svelte/store'; import { safeStorage } from './safeStorage'; +// 33 (L4): a row counts only while its owner module belongs to the scene on screen (a leaf) +import { leftBehindModules, ownerInScope } from './sceneScope'; /** * @typedef {{id: string, label: string, type: 'toggle' | 'choice' | 'range', options?: string[], @@ -207,9 +209,27 @@ export function currentGameId() { /* ------------------------------------------------------------- rows and values ---- */ -/** The game rows declared right now (api.game.addSetting, flow nodes). @type {import('svelte/store').Writable} */ +/** The game rows declared right now (api.game.addSetting, flow nodes) — 33 (L4): only the + * ones whose owner is in scope, so a kept module's rows stay out of another game's Settings. + * @type {import('svelte/store').Writable} */ export const gameSettingRows = writable([]); +/** 33 (L4): every declared row, in or out of scope @type {SettingRow[]} */ +let allRows = []; +/** publish the in-scope rows (and drop the values of rows that left) */ +function publishRows() { + const shown = allRows.filter((r) => ownerInScope(r.owner)); + const before = get(gameSettingRows); + if (shown.length === before.length && shown.every((r, i) => r === before[i])) return; + gameSettingRows.set(shown); + gameSettingValues.update((values) => { + const next = { ...values }; + for (const r of before) if (!shown.some((x) => x.id === r.id)) delete next[r.id]; + for (const r of shown) if (!(r.id in next)) next[r.id] = readRow(r); + return next; + }); +} + /** Every value for the current game, core + game rows. A store a $derived can depend on. * @type {import('svelte/store').Writable>} */ export const gameSettingValues = writable(coreValuesFrom(null)); @@ -299,23 +319,24 @@ export function registerGameSetting(row, owner = '') { const clean = normalizeSettingRow(row); if (!clean || RESERVED_SETTING_IDS.has(clean.id)) return null; clean.owner = String(owner || ''); - gameSettingRows.update((rows) => [...rows.filter((r) => r.id !== clean.id), clean]); - gameSettingValues.update((values) => ({ ...values, [clean.id]: readRow(clean) })); + allRows = [...allRows.filter((r) => r.id !== clean.id), clean]; + // a row re-declared with the same id takes its CURRENT stored value + gameSettingValues.update((values) => { + const { [clean.id]: _old, ...rest } = values; + return rest; + }); + publishRows(); return () => { - gameSettingRows.update((rows) => rows.filter((r) => r !== clean)); - gameSettingValues.update((values) => { - if (get(gameSettingRows).some((r) => r.id === clean.id)) return values; - const { [clean.id]: _gone, ...rest } = values; - return rest; - }); + allRows = allRows.filter((r) => r !== clean); + publishRows(); }; } /** Drop every row an owner declared (a module disabled, a graph replaced). @param {string} owner */ export function unregisterGameSettingsOf(owner) { - const gone = get(gameSettingRows).filter((r) => r.owner === owner).map((r) => r.id); - if (!gone.length) return; - gameSettingRows.update((rows) => rows.filter((r) => r.owner !== owner)); + if (!allRows.some((r) => r.owner === owner)) return; + allRows = allRows.filter((r) => r.owner !== owner); + publishRows(); reload(); } @@ -357,7 +378,12 @@ export function resolveTurning(values, deviceAngle) { /** Test seam: forget every row and source, back to 'untitled' defaults. */ export function debugResetGameSettings() { idSources.file = idSources.level = idSources.forced = null; + allRows = []; gameSettingRows.set([]); gameId.set('untitled'); reload(); } + +// 33 (L4): a scene switch moves module rows in or out of scope. At the END of the module +// (a module-level subscribe runs synchronously at evaluation — the TDZ rule). +leftBehindModules.subscribe(() => publishRows()); diff --git a/src/lib/gameShell.js b/src/lib/gameShell.js index 3dd642b6..6bfaa78f 100644 --- a/src/lib/gameShell.js +++ b/src/lib/gameShell.js @@ -30,6 +30,9 @@ import { globalScene, isLocked, editorMode } from '../stores/sceneStore'; import { hudDocs, isGameHud, hudScreenOverride } from './hudDocs'; import { resolvePlaySettings, playPublishers } from './playSettings'; import { CORE_SETTINGS, gameSettingRows, gameSettingValues, setGameSetting, settingRow, currentGameId } from './gameSettings'; +// 33 (L4): a registration belongs to its owner module, and counts only while that module +// belongs to the scene on screen (a leaf) +import { leftBehindModules, ownerInScope } from './sceneScope'; /** @typedef {'main' | 'levels' | 'settings' | 'help'} ShellPage */ @@ -103,9 +106,19 @@ export function markShellGame(on) { * @typedef {{list: ShellLevel[], current: string | null, onPick: ((id: string) => void) | null, owner: string, reg: object}} ShellLevels */ -/** What `api.game.levels` registered, or null. @type {import('svelte/store').Writable} */ +/** What `api.game.levels` registered, or null — the newest registration whose owner is IN + * SCOPE (33 L4: a kept module's levels do not appear in another game's menu). + * @type {import('svelte/store').Writable} */ export const gameLevels = writable(null); +/** 33 (L4): every live levels registration, oldest first (a re-register moves to the end). + * `gameLevels` is the newest one in scope. @type {ShellLevels[]} */ +let levelRegs = []; +function publishLevels() { + const shown = [...levelRegs].reverse().find((r) => ownerInScope(r.owner)) ?? null; + if (get(gameLevels) !== shown) gameLevels.set(shown); +} + /** * Normalise a levels spec: every entry an id + label, `locked` a boolean, `stars` 0..5 * (integers), at most 60 levels. Pure; exported for the suites. @@ -141,17 +154,23 @@ export function normalizeLevels(spec) { */ export function setGameLevels(spec, owner = '') { const clean = normalizeLevels(spec); + const who = String(owner || ''); if (!clean) { - if (get(gameLevels)?.owner === owner) gameLevels.set(null); + // an empty list withdraws this owner's levels + levelRegs = levelRegs.filter((r) => r.owner !== who); + publishLevels(); return null; } // the registration TOKEN, not the object: a pick re-publishes a copy with the new current const reg = {}; /** @type {ShellLevels} */ - const next = { ...clean, onPick: typeof spec.onPick === 'function' ? spec.onPick : null, owner: String(owner || ''), reg }; - gameLevels.set(next); + const next = { ...clean, onPick: typeof spec.onPick === 'function' ? spec.onPick : null, owner: who, reg }; + // one entry per owner: a re-call UPDATES (the newest), it never stacks + levelRegs = [...levelRegs.filter((r) => r.owner !== who), next]; + publishLevels(); return () => { - if (get(gameLevels)?.reg === reg) gameLevels.set(null); + levelRegs = levelRegs.filter((r) => r.reg !== reg); + publishLevels(); }; } @@ -169,7 +188,9 @@ export function pickGameLevel(id) { } catch { /* a game's callback throwing must not strand the player in the menu */ } - gameLevels.set({ ...levels, current: level.id }); + const picked = { ...levels, current: level.id }; + levelRegs = levelRegs.map((r) => (r.reg === levels.reg ? picked : r)); + gameLevels.set(picked); debug.picks.push(level.id); closeShellMenu(); return true; @@ -177,9 +198,17 @@ export function pickGameLevel(id) { /* ------------------------------------------------------------------- how to play --- */ -/** What a game says about itself (`api.game.setHelp`), keyed by owner. @type {import('svelte/store').Writable<{owner: string, lines: string[]} | null>} */ +/** What a game says about itself (`api.game.setHelp`) — the newest registration in scope. + * @type {import('svelte/store').Writable<{owner: string, lines: string[]} | null>} */ export const gameHelp = writable(null); +/** 33 (L4): every live help registration, oldest first @type {{owner: string, lines: string[]}[]} */ +let helpRegs = []; +function publishHelp() { + const shown = [...helpRegs].reverse().find((r) => ownerInScope(r.owner)) ?? null; + if (get(gameHelp) !== shown) gameHelp.set(shown); +} + /** @param {any} text a string (lines split on \n) or an array of lines @param {string} [owner] */ export function setGameHelp(text, owner = '') { const lines = (Array.isArray(text) ? text : String(text ?? '').split('\n')) @@ -187,10 +216,14 @@ export function setGameHelp(text, owner = '') { .filter(Boolean) .slice(0, 16) .map((l) => l.slice(0, 160)); - const next = lines.length ? { owner: String(owner || ''), lines } : null; - gameHelp.set(next); + const who = String(owner || ''); + const next = lines.length ? { owner: who, lines } : null; + helpRegs = helpRegs.filter((r) => r.owner !== who); + if (next) helpRegs.push(next); + publishHelp(); return () => { - if (get(gameHelp) === next) gameHelp.set(null); + helpRegs = helpRegs.filter((r) => r !== next); + publishHelp(); }; } @@ -202,11 +235,13 @@ export const COMMON_CONTROLS = { /* --------------------------------------------------------------------- restart --- */ -/** @type {Set<() => void>} */ -const restartHooks = new Set(); -/** A game's own restart (a module resetting its board). @param {() => void} fn @returns {() => void} */ -export function onGameRestart(fn) { - restartHooks.add(fn); +/** @type {Map<() => void, string>} hook -> owner module ('' = core / anonymous) */ +const restartHooks = new Map(); +/** A game's own restart (a module resetting its board). 33 (L4): a hook whose owner was + * left behind by a scene switch does not run — a kept Waves must not reset itself when + * Towers restarts. @param {() => void} fn @param {string} [owner] @returns {() => void} */ +export function onGameRestart(fn, owner = '') { + restartHooks.set(fn, String(owner || '')); return () => restartHooks.delete(fn); } @@ -228,7 +263,8 @@ export function registerShellSeams(fns) { export function restartGame() { const verdict = seams.reset ? seams.reset() : { ok: true }; if (!verdict.ok) seams.toast?.((verdict.reason ?? 'The game cannot be restarted.') + ' Carrying on with the game as it is.'); - for (const fn of restartHooks) { + for (const [fn, owner] of restartHooks) { + if (!ownerInScope(owner)) continue; try { fn(); } catch { @@ -441,6 +477,8 @@ export function gameShellDebug() { /** Test seam. */ export function resetGameShell() { closeShellMenu(); + levelRegs = []; + helpRegs = []; gameLevels.set(null); gameHelp.set(null); restartHooks.clear(); @@ -449,3 +487,11 @@ export function resetGameShell() { debug.items = []; debug.picks = []; } + +// 33 (L4): a scene switch moves modules in or out of scope — re-pick what the menu shows. +// Declared at the END: the publishers read `levelRegs`/`helpRegs`, and a module-level +// subscribe runs its callback synchronously at evaluation (the TDZ rule). +leftBehindModules.subscribe(() => { + publishLevels(); + publishHelp(); +}); diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 6dde8928..0fe870c4 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -65,6 +65,7 @@ import { announce as announceBanner, clearAnnouncement } from './gameAnnounce'; /** the ping chimes `api.playSound` still reaches (pingAudio's PING_SOUNDS ids) */ const PING_NAMES = new Set(['ding', 'chime', 'pluck', 'bell']); import { runtimeSpawn, setRuntimeSpawn } from './playSettings'; // 30b P4 (a leaf) +import { ownerInScope } from './sceneScope'; // 33 (L4): api.inScene (a leaf) import { spawnDesktopPlayer, currentSpawn, desktopSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf) /** modules already told they hit the storage cap this session (ONE toast each, never @@ -414,6 +415,8 @@ function makeApi(moduleId, moduleName = moduleId) { const onDispose = (fn) => disposals.push(fn); /** 30b P4: setSpawn journals its clear once per module */ let spawnDisposeHooked = false; + /** 33 (L4): api.music journals its stop once per module */ + let musicDisposeHooked = false; /** A value frozen for the undo stack, so a module mutating its patch object later * cannot rewrite history. @param {any} v */ const frozen = (v) => { @@ -703,6 +706,18 @@ function makeApi(moduleId, moduleName = moduleId) { sceneClearHandlers.push(fn); onDispose(() => arrayRemove(sceneClearHandlers, fn)); }, + /** + * 33 (L4): does the scene on screen still count this module? False once a scene switch + * LEFT IT BEHIND — the person kept it loaded, but the scene now open does not use it + * (Waves kept while Towers is open). Core already keeps such a module's levels, help, + * settings rows, Restart, music and spawn out of the new game; what core cannot stop is + * the module's OWN drawing and listening (a gun in the hand), so a game module stands + * down while this reads false. True for a module no switch has left behind (a fresh + * install in a blank scene is in scope). LOCAL, read-only. @returns {boolean} + */ + inScene() { + return ownerInScope(moduleId); + }, /** * 30 integrate (modules DEVX #35): the editor's click mode on THIS screen — * 'edit' | 'interact'. LOCAL and read-only; `isPlaying()` says whether Play is on @@ -1190,10 +1205,19 @@ function makeApi(moduleId, moduleName = moduleId) { announce(text, options = {}) { return announceBanner(text, options ?? {}); }, + // 33 (L4): the track is OWNED by this module — `stop` only stops ours, the module's + // teardown stops it ("music from waves stays" after Waves was unloaded), and a module a + // scene switch left behind cannot start one (gameMusic + sceneScope) music: { /** @param {string} preset @param {{volume?: number}=} options 0..1 @returns {boolean} */ - play: (preset, options = {}) => playGameMusic(preset, options ?? {}), - stop: () => stopGameMusic(), + play: (preset, options = {}) => { + if (!musicDisposeHooked) { + musicDisposeHooked = true; + onDispose(() => stopGameMusic(moduleId)); + } + return playGameMusic(preset, options ?? {}, moduleId); + }, + stop: () => stopGameMusic(moduleId), /** the preset playing now, or null @returns {string | null} */ current: () => get(gameMusicState)?.preset ?? null, presets: () => [...MUSIC_PRESET_IDS] @@ -1378,7 +1402,7 @@ function makeApi(moduleId, moduleName = moduleId) { /** 31 K3: the pause menu's Restart also runs `fn` (reset your board, respawn your * enemies). @param {() => void} fn @returns {() => void} off */ onRestart(fn) { - const off = onGameRestart(fn); + const off = onGameRestart(fn, moduleId); onDispose(off); return off; }, diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index bcd3ec7a..660d1640 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -5,6 +5,8 @@ import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; import { normalizeBounds } from './teleportRules'; import { moduleWorldChildren } from './moduleWorld'; import { normalizeReach } from './playReach'; +// 33 (L4): a module's runtime spawn counts only while the module belongs to the scene (a leaf) +import { ownerInScope } from './sceneScope'; /** * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a @@ -132,7 +134,8 @@ export function resolvePlaySettings(scene) { if (reach != null) out.reach = reach; } const runtime = get(runtimeSpawn); - if (runtime) out.spawn = { position: runtime.position, yaw: runtime.yaw }; + // 33 (L4): Waves' spawn at its crystal must not put the Towers player there + if (runtime && ownerInScope(runtime.owner)) out.spawn = { position: runtime.position, yaw: runtime.yaw }; return out; } diff --git a/src/lib/sceneScope.js b/src/lib/sceneScope.js new file mode 100644 index 00000000..2532307a --- /dev/null +++ b/src/lib/sceneScope.js @@ -0,0 +1,131 @@ +// 33 (L4) — WHICH MODULES BELONG TO THE SCENE ON SCREEN. A LEAF: svelte/store only, so +// gameShell, gameSettings, gameMusic, playSettings and moduleSDK can all read it without an +// edge into history (the TDZ-cycle family). +// +// The user: "when opened one game then another the new one stops working (opened waves +// game, then towers — music from waves stays and the gun, but only objects from towers +// load)". A game module is a SCENE SCRIPT: it came with a scene, and the scene going away +// is the end of its say in the world. Unloading it (the scene-switch ask, sceneSwitch.js) +// runs its teardown journal; but a module the person chose to KEEP is still loaded, and +// what it registered for its own game — a level list, How to play, settings rows, a +// Restart hook, a music track, a spawn — must not leak into the next game's menu or +// position the next game's player. +// +// So core tracks the modules the scene was USING when it went away and the next scene does +// not use: LEFT BEHIND. A registration whose owner is left behind is kept (coming back to +// that game brings it back with no re-registration — modules register once at boot) but +// does not COUNT: the shell hides it, the music it owns stops and further plays are +// refused, its spawn is ignored. Everything else (core, an anonymous registration, a flow +// node's row, a module the scene uses, a tool module no scene ever used) is in scope. +// +// Deliberately "left behind" rather than "in the scene": a freshly installed module is in +// scope until a scene switch leaves it behind, so a module tried out in a blank scene, and +// every suite that installs one and pokes it, behaves exactly as before this file existed. +// +// The derivation (what a scene uses = its declared `modules` ∪ what its nodes and devices +// derive, moduleRequirements) needs modules a leaf may not import, so sceneSwitch.js +// registers it here (`registerSceneUsage`) and drives the recompute. +import { writable, get } from 'svelte/store'; + +/** module ids left behind by the last scene switch (see the header) + * @type {import('svelte/store').Writable>} */ +export const leftBehindModules = writable(new Set()); + +/** what the scene USES right now, registered by sceneSwitch @type {() => string[]} */ +let usageFn = () => []; +/** @param {() => string[]} fn */ +export function registerSceneUsage(fn) { + usageFn = typeof fn === 'function' ? fn : () => []; +} +/** @returns {string[]} the module ids the scene on screen uses */ +export function sceneUsedModules() { + try { + return usageFn() ?? []; + } catch { + return []; + } +} + +/** + * Is a registration's owner in scope? Owners are module ids; '' (core, anonymous) and the + * flow runtime's `node:` rows are always in scope. + * @param {string | null | undefined} owner @returns {boolean} + */ +export function ownerInScope(owner) { + if (!owner || String(owner).startsWith('node:')) return true; + return !get(leftBehindModules).has(String(owner)); +} + +/** modules the scene used just before the most recent clear, still waiting to be judged + * @type {Set} */ +let candidates = new Set(); + +/** + * The clear path calls this BEFORE it wipes anything: whatever the scene uses now is what a + * replacement may leave behind. An objects-only clear keeps the graphs, so the very next + * recompute finds them still used and leaves nothing behind. + */ +export function noteSceneLeaving() { + for (const id of sceneUsedModules()) candidates.add(id); + recomputeScope(); +} + +/** + * Judge the candidates against what the scene uses NOW: unused ones are left behind, used + * ones (and any left-behind module the scene uses again) come back into scope. + * @param {string[]} [extraUsed] ids a caller knows the incoming scene uses (its declared list) + * @returns {Set} the left-behind set after the recompute + */ +export function recomputeScope(extraUsed = []) { + const used = new Set([...sceneUsedModules(), ...(extraUsed ?? [])]); + const before = get(leftBehindModules); + const next = new Set([...before].filter((id) => !used.has(id))); + for (const id of candidates) if (!used.has(id)) next.add(id); + // a candidate the scene still uses stays a candidate — the next recompute (the replacement + // scene's graphs landing a beat later) may still find it unused + if (!sameSet(next, before)) leftBehindModules.set(next); + return next; +} + +/** + * A scene finished arriving (applySession's end): the judgement is final for this switch, + * the candidates are spent. @param {string[]} [declared] the payload's own module list + */ +export function settleScope(declared = []) { + const next = recomputeScope(declared); + candidates = new Set(); + return next; +} + +/** A module (re)activated or unloaded: it starts from scratch, in scope. @param {string} id */ +export function forgetScopeOf(id) { + candidates.delete(id); + const before = get(leftBehindModules); + if (!before.has(id)) return; + const next = new Set(before); + next.delete(id); + leftBehindModules.set(next); +} + +/** are there modules from a cleared scene still waiting to be judged? */ +export function scopePending() { + return candidates.size > 0; +} + +/** @param {Set} a @param {Set} b */ +function sameSet(a, b) { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +/** the suites' view */ +export function sceneScopeDebug() { + return { leftBehind: [...get(leftBehindModules)], candidates: [...candidates], used: sceneUsedModules() }; +} + +/** test seam */ +export function resetSceneScope() { + candidates = new Set(); + leftBehindModules.set(new Set()); +} diff --git a/src/modules/towers/module.js b/src/modules/towers/module.js index f32854c9..d549ebc7 100644 --- a/src/modules/towers/module.js +++ b/src/modules/towers/module.js @@ -710,6 +710,18 @@ export default { moments(time); }); api.onSceneClear(() => { + // 33 (L4): the scene that was a Towers game is going — its levels and How to play + // leave the shell WITH it. Resetting `wasActive` alone left both registered (the + // frame's edge saw false -> false), so the next game's pause menu showed Towers' + // twelve levels. + if (helpOff) { + helpOff(); + helpOff = null; + } + if (typeof levelsOff === 'function') { + levelsOff(); + levelsOff = null; + } seenStamps.clear(); lastPose.clear(); outsideSince.clear(); diff --git a/tests/unit/sceneScope.test.js b/tests/unit/sceneScope.test.js new file mode 100644 index 00000000..af15eddf --- /dev/null +++ b/tests/unit/sceneScope.test.js @@ -0,0 +1,133 @@ +// 33 (L4): a module the scene being LEFT used and the new one does not is LEFT BEHIND, and +// what it registered for its own game — levels, How to play, settings rows, Restart, music, +// spawn — stops counting until a scene uses it again. Pure parts, no browser. +import { describe, it, expect, beforeEach } from 'vitest'; +import { get } from 'svelte/store'; +import { + registerSceneUsage, + noteSceneLeaving, + settleScope, + recomputeScope, + forgetScopeOf, + ownerInScope, + leftBehindModules, + resetSceneScope, + scopePending +} from '../../src/lib/sceneScope.js'; +import { setGameLevels, setGameHelp, gameLevels, gameHelp, resetGameShell, onGameRestart, restartGame } from '../../src/lib/gameShell.js'; +import { registerGameSetting, gameSettingRows, gameSettingValue, debugResetGameSettings } from '../../src/lib/gameSettings.js'; +import { resolvePlaySettings, setRuntimeSpawn } from '../../src/lib/playSettings.js'; +import { safeStorage, debugResetStorage } from '../../src/lib/safeStorage.js'; + +/** what the "scene on screen" uses, set by each test */ +let used = /** @type {string[]} */ ([]); + +beforeEach(() => { + for (const key of safeStorage.keys()) safeStorage.removeItem(key); + debugResetStorage(); + debugResetGameSettings(); + resetGameShell(); + resetSceneScope(); + setRuntimeSpawn(null); + used = []; + registerSceneUsage(() => used); +}); + +/** a switch from a scene using `from` to one using `to` (clear, then the new scene arrives) */ +function switchScene(/** @type {string[]} */ from, /** @type {string[]} */ to, declared = to) { + used = from; + noteSceneLeaving(); + used = to; + settleScope(declared); +} + +describe('left behind', () => { + it('a module the old scene used and the new one does not is left behind', () => { + switchScene(['waves', 'health'], []); + expect([...get(leftBehindModules)].sort()).toEqual(['health', 'waves']); + expect(ownerInScope('waves')).toBe(false); + expect(ownerInScope('')).toBe(true); + expect(ownerInScope('node:abc')).toBe(true); + expect(ownerInScope('towers')).toBe(true); // never used by the scene: never judged + }); + it('a module the new scene also uses stays in scope (Dungeon level 1 -> level 2)', () => { + switchScene(['dungeon', 'dungeon-realms'], ['dungeon', 'dungeon-realms']); + expect(get(leftBehindModules).size).toBe(0); + }); + it('a module the new scene DECLARES counts as used even before its nodes land', () => { + switchScene(['waves'], [], ['waves']); + expect(get(leftBehindModules).size).toBe(0); + }); + it('an objects-only clear keeps the graphs, so nothing is left behind and the judgement waits', () => { + used = ['waves']; + noteSceneLeaving(); + expect(get(leftBehindModules).size).toBe(0); + expect(scopePending()).toBe(true); + // a peer's replacement arrives later as a nodes snapshot: the recompute judges it + used = []; + recomputeScope(); + expect(get(leftBehindModules).has('waves')).toBe(true); + }); + it('coming back to the game brings the module back into scope', () => { + switchScene(['waves'], []); + expect(ownerInScope('waves')).toBe(false); + switchScene([], ['waves']); + expect(ownerInScope('waves')).toBe(true); + }); + it('a module that is (re)activated starts in scope', () => { + switchScene(['waves'], []); + forgetScopeOf('waves'); + expect(ownerInScope('waves')).toBe(true); + }); +}); + +describe('what a left-behind module registered stops counting', () => { + it('levels: the newest IN-SCOPE registration shows, and returns with its game', () => { + setGameLevels({ list: [{ id: '1' }, { id: '2' }] }, 'towers'); + setGameLevels({ list: [{ id: 'w1' }] }, 'waves'); + expect(get(gameLevels)?.owner).toBe('waves'); + switchScene(['waves'], []); + expect(get(gameLevels)?.owner).toBe('towers'); // Waves kept, Towers on screen + switchScene([], ['waves']); + expect(get(gameLevels)?.owner).toBe('waves'); // back, with no re-registration + }); + it('levels: a left-behind module alone shows NO levels (counterfactual of the reported leak)', () => { + setGameLevels({ list: [{ id: 'w1' }] }, 'waves'); + switchScene(['waves'], []); + expect(get(gameLevels)).toBe(null); + }); + it('help follows the same rule, and off() removes only its own', () => { + const offW = setGameHelp('Hold the crystal', 'waves'); + switchScene(['waves'], []); + expect(get(gameHelp)).toBe(null); + switchScene([], ['waves']); + expect(get(gameHelp)?.lines).toEqual(['Hold the crystal']); + offW(); + expect(get(gameHelp)).toBe(null); + }); + it('settings rows: a left-behind module row leaves the Settings page and its value', () => { + registerGameSetting({ id: 'board', label: 'Board', type: 'choice', options: ['globe', '2d'], default: 'globe' }, 'untangle'); + registerGameSetting({ id: 'stars-point-grab', label: 'Point', type: 'toggle', default: true }, 'node:n1'); + expect(get(gameSettingRows).map((r) => r.id).sort()).toEqual(['board', 'stars-point-grab']); + switchScene(['untangle'], []); + expect(get(gameSettingRows).map((r) => r.id)).toEqual(['stars-point-grab']); + expect(gameSettingValue('board')).toBe(undefined); + switchScene([], ['untangle']); + expect(gameSettingValue('board')).toBe('globe'); + }); + it('restart: a left-behind module hook does not run', () => { + /** @type {string[]} */ + const ran = []; + onGameRestart(() => ran.push('waves'), 'waves'); + onGameRestart(() => ran.push('towers'), 'towers'); + switchScene(['waves'], []); + restartGame(); + expect(ran).toEqual(['towers']); + }); + it('spawn: a left-behind module spawn is ignored', () => { + setRuntimeSpawn([5, 0, 5], 0, 'waves'); + expect(resolvePlaySettings(null).spawn?.position).toEqual([5, 0, 5]); + switchScene(['waves'], []); + expect(resolvePlaySettings(null).spawn ?? null).toBe(null); + }); +}); From 186fbadb7fd73576e1f41bb0076f42b10ed6dc3c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:12:13 +0300 Subject: [PATCH 03/44] [fix] scene scope: a kept module's play contract and follow cam (33 L4 audit) The teardown audit of every SDK registration (music, game.*, effects, input, spawn, locomotion): - playPublishers: a KEPT module's scene-root group still carries its userData.play, so Untangle's free cursor + world grab became the next game's. A group whose owner module a scene switch left behind publishes nothing. - api.followCam journals its stop: a module unloaded mid-follow left the camera chasing its object. (Already journalled and unchanged: input claims + bindings, possess, setSpawn, hud rows, toolboxes, transport events, frame/click/drop handlers, scene-root groups; effects bursts and the announce banner clear with the scene.) vitest sceneScope 13/13; counterfactual (filter removed): the play-contract test red. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/moduleSDK.js | 17 ++++++++++++++++- src/lib/playSettings.js | 7 ++++++- tests/unit/sceneScope.test.js | 13 +++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 0fe870c4..f05abea6 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -226,6 +226,9 @@ const messageHandlers = {}; /** @type {Record any, applyState: (state: any) => void}>} */ const stateSyncs = {}; +/** 33 (L4): which module last started the follow camera (api.followCam) @type {string | null} */ +let followingFor = null; + /** A2: per-module teardown journal — every api.register* records an undo thunk * here so deactivateModule() can genuinely dispose a module (the dev-mode live * reload tears down and re-registers with fresh code, no page reload). @@ -417,6 +420,8 @@ function makeApi(moduleId, moduleName = moduleId) { let spawnDisposeHooked = false; /** 33 (L4): api.music journals its stop once per module */ let musicDisposeHooked = false; + /** 33 (L4): api.followCam journals its stop once per module */ + let followDisposeHooked = false; /** A value frozen for the undo stack, so a module mutating its patch object later * cannot rewrite history. @param {any} v */ const frozen = (v) => { @@ -1225,9 +1230,19 @@ function makeApi(moduleId, moduleName = moduleId) { /** Park the editor camera behind an object and follow it (the car's chase * cam) — LOCAL, no selection, no undo. @param {string} uuid */ followCam(uuid) { - return possessRef?.startFollowCam(uuid) ?? false; + const ok = possessRef?.startFollowCam(uuid) ?? false; + // 33 (L4): a module unloaded mid-follow must not leave the camera chasing its car + if (ok && !followDisposeHooked) { + followDisposeHooked = true; + onDispose(() => { + if (followingFor === moduleId) possessRef?.stopFollowCam(); + }); + } + if (ok) followingFor = moduleId; + return ok; }, stopFollowCam() { + if (followingFor === moduleId) followingFor = null; possessRef?.stopFollowCam(); }, /** diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 660d1640..12814390 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -7,6 +7,7 @@ import { moduleWorldChildren } from './moduleWorld'; import { normalizeReach } from './playReach'; // 33 (L4): a module's runtime spawn counts only while the module belongs to the scene (a leaf) import { ownerInScope } from './sceneScope'; +import { moduleGroupOf } from './moduleContent'; /** * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a @@ -57,7 +58,11 @@ export function playPublishers(scene) { if (!scene?.children) return []; // 30b P5: registered module groups live under the world rig's module root now const found = [...scene.children, ...moduleWorldChildren()].filter( - (/** @type {any} */ child) => child?.userData?.play + (/** @type {any} */ child) => + // 33 (L4): a KEPT module's group is still in the scene, but a scene switch that left + // the module behind takes its play contract out of the next game (Untangle's free + // cursor and world grab must not become Stars Room's) + child?.userData?.play && ownerInScope(moduleGroupOf(child.name)?.moduleId) ); found.sort((/** @type {any} */ a, /** @type {any} */ b) => { if (a.name === 'dungeon-module') return -1; diff --git a/tests/unit/sceneScope.test.js b/tests/unit/sceneScope.test.js index af15eddf..765cc313 100644 --- a/tests/unit/sceneScope.test.js +++ b/tests/unit/sceneScope.test.js @@ -18,6 +18,7 @@ import { setGameLevels, setGameHelp, gameLevels, gameHelp, resetGameShell, onGam import { registerGameSetting, gameSettingRows, gameSettingValue, debugResetGameSettings } from '../../src/lib/gameSettings.js'; import { resolvePlaySettings, setRuntimeSpawn } from '../../src/lib/playSettings.js'; import { safeStorage, debugResetStorage } from '../../src/lib/safeStorage.js'; +import { noteModuleGroup, forgetModuleGroup } from '../../src/lib/moduleContent.js'; /** what the "scene on screen" uses, set by each test */ let used = /** @type {string[]} */ ([]); @@ -130,4 +131,16 @@ describe('what a left-behind module registered stops counting', () => { switchScene(['waves'], []); expect(resolvePlaySettings(null).spawn ?? null).toBe(null); }); + it('play contract: a kept module group publishing userData.play stops overriding the scene', () => { + const group = { name: 'untangle-module', userData: { play: { cursor: 'free', locomotion: { worldGrab: true } } } }; + const scene = { children: [group] }; + noteModuleGroup('untangle-module', { id: 'untangle' }, 'interactive'); + expect(resolvePlaySettings(scene).cursor).toBe('free'); + switchScene(['untangle'], []); + expect(resolvePlaySettings(scene).cursor).toBe('locked'); + expect(resolvePlaySettings(scene).locomotion.worldGrab).toBe(false); + switchScene([], ['untangle']); + expect(resolvePlaySettings(scene).cursor).toBe('free'); + forgetModuleGroup('untangle-module', 'interactive'); + }); }); From 7a811459b720f9818df9dd9fb430655f484c541e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:03 +0300 Subject: [PATCH 04/44] [feat] 33 L1 P0: scene-load trace probe (long tasks, frame gaps, profile per long task) - scripts/scene-load-trace.cjs: a fresh phone context per scene (412x915 isMobile @2.6, real GPU, CPU throttle 6), the Templates open path and autosave -> reload -> Restore, each reporting wall time, PerformanceObserver long tasks, the longest rAF gap, the CPU profile's top self time, app call chains, and the three longest tasks broken down. - Baseline (c7018be): Castle Courtyard Restore = ONE 89 s task (91 s frozen), open 525 ms tasks / 2.1 s frozen; Tavern Restore 84 s; Forest gets NO Restore at all (its autosave is over the 50 MB cap). Table in the 33-scene-load handover. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/scene-load-trace.cjs | 262 +++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 scripts/scene-load-trace.cjs diff --git a/scripts/scene-load-trace.cjs b/scripts/scene-load-trace.cjs new file mode 100644 index 00000000..24a42fa2 --- /dev/null +++ b/scripts/scene-load-trace.cjs @@ -0,0 +1,262 @@ +// 33 L1 P0 — WHERE A SCENE LOAD SPENDS ITS MAIN THREAD, on a throttled "phone". +// +// For each scene: a fresh context with a mobile viewport (isMobile + touch, 412x915 @2.6), +// CPU throttled (CPU=6 by default), then two loads measured the same way: +// open the Templates path (fetch .tpscene -> importSessionZip -> requestLoadSession) +// restore autosave -> reload -> the Restore button's call (autosave.restoreSnapshot) +// Each one reports: wall time until every kit piece refilled, the long tasks (> 50 ms, +// PerformanceObserver 'longtask'), the LONGEST gap between two animation frames (what a user +// calls "the window froze"), and the CPU profile's top self-time functions INSIDE long tasks. +// +// APP_URL=https://theprototype.app:5275/ node scripts/scene-load-trace.cjs +// SCENES_DIR= PACKS_DIR= SLUGS=castle-courtyard,... CPU=6 +// OUT= PHASES=open,restore +const fs = require('fs'); +const path = require('path'); +const h = require('../tests/e2e/helpers.cjs'); + +const SCENES_DIR = process.env.SCENES_DIR || '/home/deck/.code/theprototype-app/scenes'; +const PACKS_DIR = process.env.PACKS_DIR || '/home/deck/.code/lanes-30/levels-packs'; +const SLUGS = (process.env.SLUGS || 'castle-courtyard,forest-clearing,tavern-interior').split(',').filter(Boolean); +const CPU = Number(process.env.CPU || 6); +const OUT = process.env.OUT || ''; +const PHASES = (process.env.PHASES || 'open,restore').split(','); + +const MOBILE = { viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2.6 }; + +/** the in-page probe: long tasks + rAF gaps, reset per phase */ +const PROBE = () => { + const w = /** @type {any} */ (window); + if (w.__loadProbe) return; + const p = { longtasks: /** @type {any[]} */ ([]), maxGap: 0, frames: 0, last: 0, on: false }; + w.__loadProbe = p; + try { + new PerformanceObserver((list) => { + if (!p.on) return; + for (const e of list.getEntries()) p.longtasks.push({ start: Math.round(e.startTime), dur: Math.round(e.duration) }); + }).observe({ type: 'longtask', buffered: false }); + } catch {} + const tick = (/** @type {number} */ t) => { + if (p.on) { + if (p.last) p.maxGap = Math.max(p.maxGap, t - p.last); + p.frames++; + } + p.last = t; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +}; + +const settled = (page, marker) => + page.evaluate((marker) => { + const s = window.__stores; + /** @type {any} */ let g; + s.objectsGroup.subscribe((v) => (g = v))(); + /** @type {any} */ let pending; + s.packRefs.packRefsPending.subscribe((v) => (pending = v))(); + let hollow = 0; + let meshes = 0; + g?.traverse((/** @type {any} */ n) => { + if (n.userData?.packStub) hollow++; + if (n.isMesh) meshes++; + }); + return { ok: !!g && g.children.length > 0 && hollow === 0 && pending === 0 && (!marker || !!g.getObjectByName(marker) || !!g.getObjectByName(marker.replace(/ /g, '_'))), top: g?.children.length ?? 0, meshes, hollow, pending }; + }, marker); + +async function measure(page, cdp, label, start, marker) { + await page.evaluate(() => { + const p = /** @type {any} */ (window).__loadProbe; + p.longtasks = []; + p.maxGap = 0; + p.frames = 0; + p.last = 0; + p.on = true; + }); + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { interval: 500 }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU }); + const perfAtStart = await page.evaluate(() => performance.now()); + await cdp.send('Profiler.start'); + const t0 = Date.now(); + await start(); + let state = null; + const deadline = Date.now() + 240000; + while (Date.now() < deadline) { + state = await settled(page, marker).catch(() => null); + if (state?.ok) break; + await page.waitForTimeout(250); + } + const wall = Date.now() - t0; + // a beat for trailing work (scan, fingerprints, shader compiles on the next frames) + await page.waitForTimeout(2500); + const { profile } = await cdp.send('Profiler.stop'); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + const probe = await page.evaluate(() => { + const p = /** @type {any} */ (window).__loadProbe; + p.on = false; + return { longtasks: p.longtasks, maxGap: Math.round(p.maxGap), frames: p.frames, origin: performance.timeOrigin }; + }); + // attribute profile samples to functions (self time over the whole load) + const nodes = new Map(profile.nodes.map((n) => [n.id, n])); + const self = new Map(); + for (let i = 0; i < profile.samples.length; i++) { + const cf = nodes.get(profile.samples[i]).callFrame; + if (cf.functionName === '(idle)' || cf.functionName === '(program)') continue; + const key = (cf.functionName || '(anon)') + ' ' + (cf.url.split('/').slice(-1)[0].split('?')[0] || '') + ':' + (cf.lineNumber + 1); + self.set(key, (self.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + const top = [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25).map(([k, ms]) => ({ fn: k, ms: Math.round(ms) })); + // the caller chain of the heaviest frames: walk parents for the top 8 + const parentOf = new Map(); + for (const n of profile.nodes) for (const c of n.children ?? []) parentOf.set(c, n.id); + const chains = new Map(); + for (let i = 0; i < profile.samples.length; i++) { + const id = profile.samples[i]; + const node = nodes.get(id); + const cf = node.callFrame; + if (cf.functionName === '(idle)' || cf.functionName === '(program)' || cf.functionName === '(garbage collector)') continue; + const frames = []; + let cur = id; + while (cur && frames.length < 40) { + const n = nodes.get(cur); + const f = n.callFrame; + const file = f.url.split('/').slice(-1)[0].split('?')[0]; + if (/src\/(lib|components|stores)|\.svelte/.test(f.url)) frames.push((f.functionName || '(anon)') + '@' + file + ':' + (f.lineNumber + 1)); + cur = parentOf.get(cur); + } + const key = frames.slice(0, 3).join(' < ') || '(no app frame)'; + chains.set(key, (chains.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + // the three longest tasks, each broken down by app call chain (profile clock -> performance.now + // via the moment the profiler started; a few ms of slack either side) + const longest = probe.longtasks.slice().sort((a, b) => b.dur - a.dur).slice(0, 3); + const perTask = longest.map((task) => { + const by = new Map(); + let at = 0; + for (let i = 0; i < profile.samples.length; i++) { + at += profile.timeDeltas[i]; + const when = perfAtStart + at / 1000; + if (when < task.start - 5 || when > task.start + task.dur + 5) continue; + const id = profile.samples[i]; + const frames = []; + let cur = id; + while (cur && frames.length < 60) { + const f = nodes.get(cur).callFrame; + const file = f.url.split('/').slice(-1)[0].split('?')[0]; + if (f.functionName || file) frames.push((f.functionName || '(anon)') + '@' + file + ':' + (f.lineNumber + 1)); + cur = parentOf.get(cur); + } + const key = frames.slice(0, 6).join(' < '); + by.set(key, (by.get(key) || 0) + profile.timeDeltas[i] / 1000); + } + return { dur: task.dur, start: task.start, top: [...by.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, ms]) => ({ ms: Math.round(ms), chain: k })) }; + }); + const appChains = [...chains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([k, ms]) => ({ chain: k, ms: Math.round(ms) })); + const long = probe.longtasks.slice().sort((a, b) => b.dur - a.dur); + const total = probe.longtasks.reduce((s, e) => s + e.dur, 0); + const result = { + label, + cpu: CPU, + wallMs: wall, + settled: state, + longtasks: probe.longtasks.length, + longtaskTotalMs: total, + maxLongtaskMs: long[0]?.dur ?? 0, + top5Longtasks: long.slice(0, 5).map((e) => e.dur), + maxFrameGapMs: probe.maxGap, + frames: probe.frames, + topSelf: top, + appChains, + perTask + }; + console.log(JSON.stringify({ label, wall, longtasks: result.longtasks, max: result.maxLongtaskMs, total, maxGap: probe.maxGap, frames: probe.frames, settled: state })); + return result; +} + +const MARKERS = { 'castle-courtyard': 'Castle gate', 'forest-clearing': 'Footbridge', 'tavern-interior': 'Balcony stairs' }; + +h.run(async () => { + const index = JSON.parse(fs.readFileSync(path.join(SCENES_DIR, 'index.json'), 'utf8')); + const browser = await h.launch({ args: h.GPU_ARGS }); + const results = []; + for (const slug of SLUGS) { + const entry = [...(index.templates ?? []), ...(index.examples ?? []), ...(index.games ?? [])].find((t) => t.slug === slug); + if (!entry) { + console.log('no entry for ' + slug); + continue; + } + const A = await h.setupPage(browser, slug, { context: MOBILE }); + const page = A.page; + await page.route('**/cdn.jsdelivr.net/**', (route) => { + const url = route.request().url(); + const scenes = url.match(/\/theprototype-app\/scenes@[^/]+\/(.*)$/); + if (scenes) { + const file = path.join(SCENES_DIR, decodeURIComponent(scenes[1])); + if (scenes[1] === 'index.json') return route.fulfill({ json: index }); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + return route.fulfill({ status: 404 }); + } + const packs = url.match(/\/theprototype-app\/packs@[^/]+\/(.*)$/); + if (packs) { + const file = path.join(PACKS_DIR, decodeURIComponent(packs[1])); + if (fs.existsSync(file)) return route.fulfill({ body: fs.readFileSync(file) }); + } + return route.continue(); + }); + await page.evaluate(PROBE); + const cdp = await page.context().newCDPSession(page); + const sceneUrl = await page.evaluate((p) => window.__stores.sceneTemplates.resolveUrl(p, window.__stores.sceneTemplates.SCENES_BASE), entry.scene); + const marker = MARKERS[slug]; + if (PHASES.includes('open')) + results.push({ + slug, + ...(await measure(page, cdp, slug + ' open', () => + page.evaluate((url) => { + // not awaited: the probe measures the page while it works + void window.__stores.sceneTemplates.loadRemoteScene({ slug: 'x', title: 'x', sceneUrl: url }); + }, sceneUrl), marker)) + }); + if (PHASES.includes('restore')) { + if (!PHASES.includes('open')) { + await page.evaluate((url) => window.__stores.sceneTemplates.loadRemoteScene({ slug: 'x', title: 'x', sceneUrl: url }), sceneUrl); + await h.eventually(() => settled(page, marker), (r) => r?.ok, slug + ' loaded for the restore', 120000); + } + await page.waitForTimeout(1500); + await page.evaluate(() => window.__stores.autosave.saveNow()); + const snap = await page.evaluate(async () => { + const s = await window.__stores.idb.idbGet('latest'); + return { bytes: s?.scene?.byteLength ?? (s?.scene ? JSON.stringify(s.scene).length : 0), objects: s?.objects }; + }); + console.log('autosave snapshot', JSON.stringify(snap)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, { timeout: 60000 }); + await page.evaluate(PROBE); + await h.eventually( + () => page.evaluate(() => { let v; window.__stores.autosave.restoreAvailable.subscribe((x) => (v = x))(); return !!v; }), + (v) => v, + slug + ' the Restore offer is up after the reload', + 30000 + ); + const cdp2 = await page.context().newCDPSession(page); + results.push({ + slug, + snapshot: snap, + ...(await measure(page, cdp2, slug + ' restore', () => page.evaluate(() => void window.__stores.autosave.restoreSnapshot()), marker)) + }); + } + await A.ctx.close(); + } + if (OUT) { + fs.mkdirSync(OUT, { recursive: true }); + const stamp = process.env.TAG || 'run'; + fs.writeFileSync(path.join(OUT, 'scene-load-' + stamp + '.json'), JSON.stringify(results, null, 1)); + const md = [ + '| load | wall ms | long tasks | total long ms | max long ms | max frame gap ms | top 5 |', + '|---|---:|---:|---:|---:|---:|---|', + ...results.map((r) => `| ${r.label} | ${r.wallMs} | ${r.longtasks} | ${r.longtaskTotalMs} | ${r.maxLongtaskMs} | ${r.maxFrameGapMs} | ${r.top5Longtasks.join(', ')} |`) + ]; + fs.writeFileSync(path.join(OUT, 'scene-load-' + stamp + '.md'), md.join('\n') + '\n'); + console.log(md.join('\n')); + } + await browser.close(); +}); From 6333b431b2f2f42e7b9f93cf0bbad11ba8c8fdc3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:18 +0300 Subject: [PATCH 05/44] [feat] 33 L1 P1: progressive scene loads - time-sliced build, kit pieces warmed and placeheld - sceneLoader.js (leaf): slice(job) yields when the 10 ms budget is spent and throws LoadCancelled at a clean point; schedule(fn) runs a burst of continuations a slice at a time; ONE load job (sceneLoad store) - a new load supersedes the running one; cancel hooks; throttledPoke (250 ms) for the build loop. - sessions.applySession: the object loop is time-sliced with progress, kit stubs refill at once (counted when they land), every await re-checks the job, Cancel clears what the load added (replicated like the load), a load interrupting one mid-build writes no "Backup before" of the half scene. - packRefs: a refill's clone/attach runs through schedule() (every copy of a piece used to attach in the one task that resolved its template); warmTemplate uploads textures one per slice and compileAsync's the programs against the live scene before any copy draws; refills poke at most every 200 ms; fingerprint checksums cached per geometry by buffer versions; packRef.box (root-frame bounds, additive) drawn as ONE scene-root InstancedMesh of grey placeholder blocks while a piece is on its way. - App.svelte debug hook: + sceneLoader (220/220/220). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 7 +- src/lib/packRefs.js | 299 ++++++++++++++++++++++++++++++++++++----- src/lib/sceneLoader.js | 294 ++++++++++++++++++++++++++++++++++++++++ src/lib/sessions.js | 98 +++++++++++--- 4 files changed, 642 insertions(+), 56 deletions(-) create mode 100644 src/lib/sceneLoader.js diff --git a/src/App.svelte b/src/App.svelte index 4d640087..a41e4e15 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -494,9 +494,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/moduleWorld'), import('./lib/playSpawn'), import('./lib/packRefs'), - import('./lib/lod') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib } + import('./lib/lod'), + import('./lib/sceneLoader') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib, importBudgetLib, importGateLib, decimateLib, playCursorLib, gameStorageLib, gameKitLib, moduleWorldLib, playSpawnLib, packRefsLib, lodLib, sceneLoaderLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib, importBudget: importBudgetLib, importGate: importGateLib, decimate: decimateLib, playCursor: playCursorLib, gameStorage: gameStorageLib, gameKit: gameKitLib, moduleWorld: moduleWorldLib, playSpawn: playSpawnLib, packRefs: packRefsLib, lod: lodLib, sceneLoader: sceneLoaderLib } }) } }) diff --git a/src/lib/packRefs.js b/src/lib/packRefs.js index a9a8f7d7..59aaeeae 100644 --- a/src/lib/packRefs.js +++ b/src/lib/packRefs.js @@ -7,10 +7,11 @@ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; // @ts-ignore import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; import { writable, get } from 'svelte/store'; -import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { objectsGroup, pokeScene, globalRenderer, globalCamera, globalScene } from '../stores/sceneStore'; import { showToast } from '../stores/appStore'; import { PACKS_BASE } from './packs'; import { hashBytes } from './explorer'; +import { schedule } from './sceneLoader'; // 30c — KIT REFERENCES: a pack piece in a scene is a REFERENCE, not a copy. // @@ -48,7 +49,7 @@ import { hashBytes } from './explorer'; // commandsHandler import this; nothing here reaches back. /** - * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[]}} PackRef + * @typedef {{pack: string, item: string, path: string, hash?: string, kids?: string[], box?: number[]}} PackRef */ /** How many refills are in flight — the author script and the suite wait on it. @@ -67,6 +68,9 @@ const sharedTextures = new Map(); /** root uuid -> its refill, so two scans never fill one root twice * @type {Map>} */ const filling = new Map(); +/** roots hollowed for an autosave export this very moment — never refilled meanwhile + * @type {WeakSet} */ +const parkedRoots = new WeakSet(); /** urls already reported as unreachable (one toast per piece, not per copy) */ const reported = new Set(); @@ -189,6 +193,7 @@ export function loadPackTemplate(url) { const scene = gltf.scene; scene.updateMatrixWorld(true); fingerprints.set(hash, fingerprintOf(scene)); + await warmTemplate(scene); return { hash, scene }; })(); templates.set(url, job); @@ -197,6 +202,34 @@ export function loadPackTemplate(url) { return job; } +/** + * 33 L1: upload a piece's textures and compile its programs BEFORE its copies are on screen. + * Otherwise the first frame that draws a new piece does both inside the render call — on a + * phone that one frame was the longest task of a whole load. `initTexture` is one upload per + * scheduled slice; `compileAsync` (KHR_parallel_shader_compile where the driver has it) is + * given the live scene as the target so the programs match its lights and fog. Best effort: + * a failure here only means the first frame pays as it always did. + * @param {any} scene + */ +async function warmTemplate(scene) { + /** @type {any} */ + const renderer = get(globalRenderer); + if (!renderer || renderer.xr?.isPresenting) return; + const textures = new Set(); + scene.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + for (const material of Array.isArray(node.material) ? node.material : [node.material]) + for (const slot of MAP_SLOTS) if (material?.[slot]) textures.add(material[slot]); + }); + try { + for (const texture of textures) await schedule(() => renderer.initTexture(texture)); + const camera = get(globalCamera); + if (camera && typeof renderer.compileAsync === 'function') await renderer.compileAsync(scene, camera, get(globalScene) ?? null); + } catch { + /* the first frame compiles instead */ + } +} + // ---- the fingerprint --------------------------------------------------------- /** Seven significant digits, and anything under 1e-6 is zero — a GLTF round trip turns a @@ -218,17 +251,30 @@ function checksum(array) { return num(a) + '/' + num(b); } +/** 33 L1: the checksums of a geometry, kept until one of its buffers changes. A save asks + * "is this piece still pristine?" of every kit piece, and summing every vertex of a 180-piece + * castle on each autosave and each backup was a long task of its own. Keyed by the buffers' + * `version`s, which every edit path bumps (needsUpdate) — an edit no renderer would see is + * the only one this could miss. + * @type {WeakMap} */ +const geometryKeys = new WeakMap(); + /** @param {any} geometry */ function geometryKey(geometry) { if (!geometry?.attributes) return '-'; const names = Object.keys(geometry.attributes).sort(); + const ver = names.map((name) => name + geometry.attributes[name].version + ':' + geometry.attributes[name].array?.length).join(',') + '|' + (geometry.index ? geometry.index.version + ':' + geometry.index.array?.length : '-'); + const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); + const cached = geometryKeys.get(geometry); + if (cached && cached.ver === ver) return cached.key + '|' + groups; const parts = names.map((name) => { const attr = geometry.attributes[name]; return name + ':' + attr.itemSize + ':' + attr.count + ':' + (attr.normalized ? 1 : 0) + ':' + checksum(attr.array); }); const index = geometry.index ? 'i' + geometry.index.count + ':' + checksum(geometry.index.array) : 'noindex'; - const groups = (geometry.groups ?? []).map((/** @type {any} */ g) => g.start + '+' + g.count + '@' + (g.materialIndex ?? 0)).join(';'); - return parts.join(',') + '|' + index + '|' + groups; + const key = parts.join(',') + '|' + index; + geometryKeys.set(geometry, { ver, key }); + return key + '|' + groups; } /** 8×8 RGBA of a texture's image, cached per image. Null when it cannot be read (no @@ -361,6 +407,30 @@ function descendantUuids(root) { return out; } +/** + * 33 L1: a piece's bounds in its ROOT's own frame, [minX, minY, minZ, maxX, maxY, maxZ] to + * the millimetre — carried on the reference (`packRef.box`) so a stub can be drawn as a grey + * block while its pack file is still on its way. Additive: a reference without one simply + * shows nothing until it fills, which is how every file written before 1.19 behaves. + * @param {any} root @returns {number[] | undefined} + */ +function boxOf(root) { + const box = new THREE.Box3(); + const part = new THREE.Box3(); + root.updateWorldMatrix(true, true); + const toRoot = new THREE.Matrix4().copy(root.matrixWorld).invert(); + const m = new THREE.Matrix4(); + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh || !node.geometry) return; + if (!node.geometry.boundingBox) node.geometry.computeBoundingBox(); + part.copy(node.geometry.boundingBox).applyMatrix4(m.multiplyMatrices(toRoot, node.matrixWorld)); + box.union(part); + }); + if (box.isEmpty()) return undefined; + const r = (/** @type {number} */ v) => Math.round(v * 1000) / 1000; + return [r(box.min.x), r(box.min.y), r(box.min.z), r(box.max.x), r(box.max.y), r(box.max.z)]; +} + /** * Stamp a freshly imported pack piece BEFORE it replicates: its reference, its children's * uuids, and — since it IS the file right now — the pristine fingerprint for its hash. @@ -369,7 +439,7 @@ function descendantUuids(root) { export function stampPackRef(root, ref, hash) { if (!root || !ref) return; root.updateMatrixWorld(true); - root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root) } }; + root.userData = { ...(root.userData ?? {}), packRef: { pack: ref.pack, item: ref.item, path: ref.path, hash, kids: descendantUuids(root), box: boxOf(root) } }; if (hash && !fingerprints.has(hash)) fingerprints.set(hash, fingerprintOf(root)); } @@ -406,11 +476,62 @@ export function stubElementOf(root) { hollow.renderOrder = root.renderOrder; hollow.layers.mask = root.layers.mask; const kids = root.userData.packStub ? ref.kids ?? [] : descendantUuids(root); - hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids }, packStub: true })); + const box = ref.box ?? (root.userData.packStub ? undefined : boxOf(root)); + hollow.userData = JSON.parse(JSON.stringify({ ...root.userData, packRef: { ...ref, kids, box }, packStub: true })); hollow.updateMatrix(); return hollow.toJSON(); } +/** + * 33 L1 — THE AUTOSAVE WRITES KIT PIECES AS STUBS TOO. 30c kept the autosave's GLTF FULL, + * and that is what made Restore hang: Castle Courtyard's snapshot measured 51 MB (every + * piece's geometry, its textures as PNG data URIs), right at the 50 MB cap — a bigger level + * would silently get no crash recovery at all — and restoring it parsed all of that, then + * serialized every child again for the wire. The export has no per-child filter (the + * transient-objects ritual says the same), so a pristine piece is HOLLOWED for the export: + * its children detached, `packStub` set and its kids recorded, exactly the shape + * `stubElementOf` writes for a save, and put back afterwards. The restore refills it from + * the pack like any other stub (the browser has the file cached from the session it + * came from); an unreachable pack keeps the stub and says so, never dropping the piece. + * @param {any} group the tree about to be exported + * @returns {() => void} the unpark, to run once the export has read the tree + */ +export function parkPackPieces(group) { + /** @type {{root: any, children: any[], userData: any}[]} */ + const parked = []; + if (!group) return () => {}; + /** @type {any[]} */ + const roots = []; + group.traverse((/** @type {any} */ node) => { + if (node !== group && packRefOf(node) && !node.userData.packStub && node.children.length) roots.push(node); + }); + for (const root of roots) { + // a piece nested inside another piece is parked with its ancestor + if (parked.some((p) => p.children.some((c) => c === root || isAncestor(c, root)))) continue; + if (!isPristinePackRef(root)) continue; + const userData = root.userData; + const ref = /** @type {PackRef} */ (packRefOf(root)); + const children = [...root.children]; + root.userData = { ...userData, packRef: { ...ref, kids: descendantUuids(root), box: ref.box ?? boxOf(root) }, packStub: true }; + for (const child of children) root.remove(child); + parkedRoots.add(root); + parked.push({ root, children, userData }); + } + return () => { + for (const { root, children, userData } of parked) { + parkedRoots.delete(root); + for (const child of children) root.add(child); + root.userData = userData; + } + }; +} + +/** @param {any} ancestor @param {any} node */ +function isAncestor(ancestor, node) { + for (let up = node.parent; up; up = up.parent) if (up === ancestor) return true; + return false; +} + /** How many nodes a stub stands for (the root + what it refills to), for object budgets. * @param {any} node a serialized node (element.object) */ export function stubNodeCount(node) { @@ -437,6 +558,59 @@ function instanceOf(scene) { return copy; } +/** + * The synchronous half of a refill: clone the parsed piece and hang its children under the + * stub. Returns false when the stub was replaced (a clear, a reload of the same file) or + * filled while the template was on its way. + * @param {any} root @param {PackRef} ref @param {string} hash @param {any} scene + */ +function attachCopy(root, ref, hash, scene) { + if (!root.userData.packStub || root.children.length || parkedRoots.has(root)) return false; + // still in the scene? (an ancestor walk, not a tree search per piece) + const group = get(objectsGroup); + let up = root.parent; + while (up && up !== group) up = up.parent; + if (!group || up !== group) return false; + const copy = instanceOf(scene); + /** @type {any[]} */ + const nodes = []; + copy.traverse((/** @type {any} */ node) => { + if (node !== copy) nodes.push(node); + }); + const kids = Array.isArray(ref.kids) ? ref.kids : []; + // a stub that lost its kids (a merge import re-uuids the tree) still has to give + // every peer the SAME child uuids: derive them from the root's + nodes.forEach((node, i) => { + node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); + }); + const hideShadow = root.userData.shadow === false; + for (const child of [...copy.children]) root.add(child); + if (hideShadow) + root.traverse((/** @type {any} */ node) => { + if (!node.isMesh) return; + node.castShadow = false; + node.receiveShadow = false; + node.userData.shadow = false; + }); + delete root.userData.packStub; + root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid), box: ref.box ?? boxOf(root) }; + pokeSoon(); + return true; +} + +/** @type {any} */ +let pokeTimer = null; +/** 33 L1: refills poke the scene at most ~5 times a second — a castle refills 180 pieces, + * and every poke re-runs every objectsGroup subscriber (the object list re-renders whole). + * A timer, so the last refill of a burst is always announced. */ +function pokeSoon() { + if (pokeTimer) return; + pokeTimer = setTimeout(() => { + pokeTimer = null; + pokeScene(); + }, 200); +} + /** * Refill a stub from its pack. Resolves true when it filled, false when it could not (or * is no longer in the scene, or was filled meanwhile). The children take the recorded @@ -444,7 +618,7 @@ function instanceOf(scene) { * @param {any} root @returns {Promise} */ export function fillPackRef(root) { - if (!root?.userData?.packStub) return Promise.resolve(false); + if (!root?.userData?.packStub || parkedRoots.has(root)) return Promise.resolve(false); const ref = packRefOf(root); if (!ref) return Promise.resolve(false); const inflight = filling.get(root.uuid); @@ -454,34 +628,10 @@ export function fillPackRef(root) { const job = (async () => { try { const { hash, scene } = await loadPackTemplate(url); - // replaced (a clear, a reload of the same file) or filled while we fetched - if (!root.userData.packStub || root.children.length) return false; - if (get(objectsGroup)?.getObjectByProperty('uuid', root.uuid) !== root) return false; - const copy = instanceOf(scene); - /** @type {any[]} */ - const nodes = []; - copy.traverse((/** @type {any} */ node) => { - if (node !== copy) nodes.push(node); - }); - const kids = Array.isArray(ref.kids) ? ref.kids : []; - // a stub that lost its kids (a merge import re-uuids the tree) still has to give - // every peer the SAME child uuids: derive them from the root's - nodes.forEach((node, i) => { - node.uuid = typeof kids[i] === 'string' && kids[i] ? kids[i] : derivedUuid(root.uuid, i); - }); - const hideShadow = root.userData.shadow === false; - for (const child of [...copy.children]) root.add(child); - if (hideShadow) - root.traverse((/** @type {any} */ node) => { - if (!node.isMesh) return; - node.castShadow = false; - node.receiveShadow = false; - node.userData.shadow = false; - }); - delete root.userData.packStub; - root.userData.packRef = { ...ref, hash, kids: nodes.map((node) => node.uuid) }; - pokeScene(); - return true; + // 33 L1: every copy of one piece waits on the SAME template promise, so without + // this all of them clone and attach inside the one task that resolved it (a + // castle's walls were one 500 ms block). The scheduler runs them a slice at a time. + return await schedule(() => attachCopy(root, ref, hash, scene)); } catch (error) { if (!reported.has(url)) { reported.add(url); @@ -517,15 +667,92 @@ export function packRefsSettled() { function scan() { const group = get(objectsGroup); if (!group) return; + /** @type {any[]} */ + const hollow = []; group.traverse((/** @type {any} */ node) => { const ref = packRefOf(node); if (!ref) return; if (node.userData.packStub) { - if (!node.children.length) fillPackRef(node); + if (!node.children.length) { + fillPackRef(node); + if (Array.isArray(ref.box) && ref.box.length === 6 && !parkedRoots.has(node)) hollow.push(node); + } } else if (ref.hash && !fingerprints.has(ref.hash)) { loadPackTemplate(packRefUrl(ref)).catch(() => {}); } }); + drawPlaceholders(hollow); +} + +// ---- placeholders (33 L1) ---------------------------------------------------------------- + +const PLACEHOLDER_NAME = 'kit-placeholders'; +/** @type {any} */ +let placeholders = null; + +/** + * A grey block where each kit piece will be, while its pack file is still on its way: a load + * shows the level's SHAPE at once and the real pieces replace the blocks as they arrive. ONE + * InstancedMesh at the SCENE ROOT (golden rule 5 — never inside objectsGroup, so never saved, + * sent or undone), not pickable, rebuilt on each scan (which every refill's poke triggers). + * @param {any[]} stubs hollow stubs carrying a `packRef.box` + */ +function drawPlaceholders(stubs) { + /** @type {any} */ + const scene = get(globalScene); + if (!scene) return; + if (!stubs.length) { + if (placeholders) { + placeholders.parent?.remove(placeholders); + placeholders.geometry.dispose(); + placeholders.material.dispose(); + placeholders = null; + } + return; + } + if (!placeholders || placeholders.userData.capacity < stubs.length) { + if (placeholders) { + placeholders.parent?.remove(placeholders); + placeholders.geometry.dispose(); + placeholders.material.dispose(); + } + const capacity = Math.max(32, stubs.length); + placeholders = new THREE.InstancedMesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 1, metalness: 0 }), + capacity + ); + placeholders.name = PLACEHOLDER_NAME; + placeholders.frustumCulled = false; + placeholders.raycast = () => {}; + placeholders.userData.capacity = capacity; + } + // beside objectsGroup (in its parent — the world rig a VR world-grab moves), never in it + /** @type {any} */ + const host = get(objectsGroup)?.parent ?? scene; + if (placeholders.parent !== host) host.add(placeholders); + host.updateWorldMatrix(true, false); + const toHost = new THREE.Matrix4().copy(host.matrixWorld).invert(); + const m = new THREE.Matrix4(); + const local = new THREE.Matrix4(); + const pos = new THREE.Vector3(); + const size = new THREE.Vector3(); + const q = new THREE.Quaternion(); + stubs.forEach((stub, i) => { + const [x0, y0, z0, x1, y1, z1] = stub.userData.packRef.box; + stub.updateWorldMatrix(true, false); + pos.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2); + size.set(Math.max(0.01, x1 - x0), Math.max(0.01, y1 - y0), Math.max(0.01, z1 - z0)); + local.compose(pos, q, size); + placeholders.setMatrixAt(i, m.multiplyMatrices(toHost, stub.matrixWorld).multiply(local)); + }); + placeholders.count = stubs.length; + placeholders.instanceMatrix.needsUpdate = true; +} + +/** How many grey blocks are drawn (the suite reads it). */ +export function placeholderCount() { + return placeholders?.parent ? placeholders.count : 0; } let started = false; diff --git a/src/lib/sceneLoader.js b/src/lib/sceneLoader.js new file mode 100644 index 00000000..3b1cd950 --- /dev/null +++ b/src/lib/sceneLoader.js @@ -0,0 +1,294 @@ +import { writable, get } from 'svelte/store'; + +// 33 L1 — A SCENE LOAD MAY TAKE TIME; IT MAY NOT TAKE THE WINDOW. +// +// THE REPORT: on a phone, Restore after a reload with Castle Courtyard open hung the whole +// window, and so did opening other scenes. Every load path ran its object loop in ONE task +// (parse every element, add it, broadcast its toJSON), and every kit piece refilled in one +// more burst when its pack file resolved — all copies of a wall continue in the same task. +// The measurement is in the 33-scene-load handover; this leaf is the answer to it: +// +// * `slice()` — a cooperative yield for a LOOP: returns at once while the current slice is +// under budget, otherwise gives the event loop a turn (input, paint, the progress bar) +// before the loop continues. A load is therefore many ~10 ms tasks instead of one long one. +// * `schedule(fn)` — the same budget for work that arrives as MANY independent promise +// continuations (twelve walls waiting on one pack file): each becomes a queue item and one +// pump runs them a slice at a time, because N continuations that each check a clock all +// run inside the one task that resolved their promise. +// * `sceneLoad` — the job being loaded ({name, total, done, phase}), rendered by +// SceneLoadBar.svelte as a non-modal bar with Cancel. ONE job at a time: starting a load +// supersedes the previous one, whose loop sees `cancelled` at its next slice and stops. +// +// A LEAF: svelte/store only. sessions, autosave and packRefs import it; it imports nothing +// back, so it can sit under any of the documented cycles. + +/** Work budget per slice. 10 ms leaves a 60 Hz frame room to paint and take input. */ +export const SLICE_MS = 10; + +/** + * @typedef {{id: number, name: string, verb: string, total: number, done: number, phase: string, + * cancellable: boolean, startedAt: number, cancelled: boolean, interrupted: boolean}} LoadJob + * `interrupted`: this load superseded one that was still BUILDING its scene — what is on + * screen is half of somebody else's load, not the user's work (no backup is owed for it). + */ + +/** The load in progress, or null. LOCAL — never replicated, saved or undone. + * @type {import('svelte/store').Writable} */ +export const sceneLoad = writable(/** @type {LoadJob | null} */ (null)); + +/** Thrown out of `slice(job)` when that job was cancelled or superseded. */ +export class LoadCancelled extends Error { + constructor() { + super('scene load cancelled'); + this.name = 'LoadCancelled'; + } +} + +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +/** @type {MessageChannel | null} */ +let channel = null; +/** @type {(() => void)[]} */ +const waiting = []; + +/** + * Give the event loop one turn. `scheduler.yield()` where it exists (it keeps our + * continuation ahead of other queued work); otherwise a MessageChannel message, which — + * unlike setTimeout — carries no 4 ms clamp and is not stretched to 1 s in a background tab. + * @returns {Promise} + */ +export function yieldToEventLoop() { + const g = /** @type {any} */ (globalThis); + if (g.scheduler?.yield) return g.scheduler.yield(); + if (typeof MessageChannel === 'undefined') return new Promise((r) => setTimeout(r, 0)); + if (!channel) { + channel = new MessageChannel(); + channel.port1.onmessage = () => waiting.shift()?.(); + } + return new Promise((resolve) => { + waiting.push(resolve); + /** @type {MessageChannel} */ (channel).port2.postMessage(0); + }); +} + +let sliceStart = now(); + +/** + * Inside a load loop: return immediately while the slice has budget left, else yield and + * start a new slice. With a job, a cancelled/superseded job throws `LoadCancelled` here, so + * a loop stops at a clean point between two objects. + * @param {LoadJob | null} [job] + */ +export async function slice(job = null) { + if (job?.cancelled) throw new LoadCancelled(); + if (now() - sliceStart < SLICE_MS) return; + await yieldToEventLoop(); + sliceStart = now(); + if (job?.cancelled) throw new LoadCancelled(); +} + +/** Start a fresh slice clock (call before a loop that must not inherit a spent budget). */ +export function resetSlice() { + sliceStart = now(); +} + +/** @type {{fn: () => any, resolve: (v: any) => void, reject: (e: any) => void}[]} */ +const queue = []; +let pumping = false; + +async function pump() { + if (pumping) return; + pumping = true; + try { + while (queue.length) { + const start = now(); + while (queue.length && now() - start < SLICE_MS) { + const item = /** @type {any} */ (queue.shift()); + try { + item.resolve(item.fn()); + } catch (error) { + item.reject(error); + } + } + if (queue.length) await yieldToEventLoop(); + } + } finally { + pumping = false; + } +} + +/** + * Run a piece of SYNCHRONOUS work inside the shared slice budget. Items run in arrival + * order, as many per task as fit in `SLICE_MS` (at least one), so a burst of continuations + * spreads over several tasks instead of one. + * @template T @param {() => T} fn @returns {Promise} + */ +export function schedule(fn) { + return new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + // a microtask, so a burst that queues in one task pumps once + queueMicrotask(pump); + }); +} + +/** How many scheduled items wait (the suite and the bar read it). */ +export function scheduledCount() { + return queue.length; +} + +/** Pokes the scene at most every this many ms while a load builds it. Every poke re-runs + * every objectsGroup subscriber (the object list re-renders the whole tree), so a poke per + * object turned the list into the load's biggest cost; four a second still shows it grow. */ +export const LOAD_POKE_MS = 250; + +/** + * A throttle for a load loop's scene pokes: call it per object, it calls `poke` at most + * every LOAD_POKE_MS. The loop pokes once more when it ends. + * @param {() => void} poke @returns {() => void} + */ +export function throttledPoke(poke) { + let last = 0; + return () => { + const t = now(); + if (t - last < LOAD_POKE_MS) return; + last = t; + poke(); + }; +} + +let nextId = 0; +/** @type {LoadJob | null} */ +let current = null; +/** @type {Set<() => void>} */ +const cancelHooks = new Set(); + +function publish() { + sceneLoad.set(current ? { ...current } : null); +} + +/** + * Begin a load. A load already running is SUPERSEDED (cancelled) — starting another scene + * cancels the first one cleanly, at its next slice. + * @param {string} name what the bar says ("Loading ") + * @param {number} total objects this load will bring + * @param {{cancellable?: boolean, phase?: string, verb?: string}} [opts] + * @returns {LoadJob} + */ +export function beginLoad(name, total, opts = {}) { + // only the BUILDING phase leaves a half scene behind ('preparing'/'reading' have not + // touched the scene yet; 'models' means it is whole) + const interrupted = !!current && current.phase === 'objects'; + if (current) cancelLoad({ superseded: true }); + current = { + interrupted, + id: ++nextId, + name: String(name || 'scene'), + verb: opts.verb ?? 'Loading', + total: Math.max(0, total | 0), + done: 0, + phase: opts.phase ?? 'objects', + cancellable: opts.cancellable !== false, + startedAt: Date.now(), + cancelled: false + }; + resetSlice(); + publish(); + return current; +} + +/** Throttled progress publication: the bar is DOM, so ~10 updates a second is plenty. */ +let publishTimer = /** @type {any} */ (null); +function publishSoon() { + if (publishTimer) return; + publishTimer = setTimeout(() => { + publishTimer = null; + publish(); + }, 100); +} + +/** @param {LoadJob} job @param {number} [n] */ +export function progress(job, n = 1) { + if (job !== current) return; + job.done = Math.min(job.total, job.done + n); + publishSoon(); +} + +/** @param {LoadJob} job @param {{total?: number, phase?: string, cancellable?: boolean}} patch */ +export function updateLoad(job, patch) { + if (job !== current) return; + if (patch.total != null) job.total = Math.max(job.done, patch.total | 0); + if (patch.phase != null) job.phase = patch.phase; + if (patch.cancellable != null) job.cancellable = patch.cancellable; + publish(); +} + +/** @param {LoadJob | null} job */ +export function endLoad(job) { + if (!job || job !== current) return; + current = null; + cancelHooks.clear(); + clearTimeout(publishTimer); + publishTimer = null; + publish(); +} + +/** Is THIS job still the one loading (not cancelled, not superseded)? @param {LoadJob} job */ +export function isLive(job) { + return job === current && !job.cancelled; +} + +/** Is any load running? Autosave and the bar read it. */ +export function loading() { + return current !== null; +} + +/** + * Register what Cancel must undo for the running load (the caller's own cleanup — a + * session load clears what it had added). Hooks run once, on cancel only. + * @param {() => void} fn + */ +export function onCancel(fn) { + cancelHooks.add(fn); + return () => cancelHooks.delete(fn); +} + +/** + * Cancel the running load: the bar's Cancel, or a newer load SUPERSEDING it. Only a user + * cancel runs the load's undo hooks — a superseding load clears the scene itself. + * @param {{superseded?: boolean}} [opts] + */ +export function cancelLoad(opts = {}) { + const job = current; + if (!job) return; + job.cancelled = true; + const hooks = opts.superseded ? [] : [...cancelHooks]; + current = null; + cancelHooks.clear(); + clearTimeout(publishTimer); + publishTimer = null; + publish(); + for (const hook of hooks) { + try { + hook(); + } catch {} + } +} + +/** For the suite: the job object itself (not the published copy). */ +export function currentJob() { + return current; +} + +/** Resolve when no load is running. */ +export function loadSettled() { + return new Promise((resolve) => { + if (!get(sceneLoad)) return resolve(true); + /** @type {any} */ + let off = null; + off = sceneLoad.subscribe((v) => { + if (v) return; + queueMicrotask(() => off?.()); + resolve(true); + }); + }); +} diff --git a/src/lib/sessions.js b/src/lib/sessions.js index b43a8fad..5b10e251 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -1,12 +1,13 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene } from '../stores/sceneStore'; +import { objectsGroup, globalCamera, globalScene, globalRenderer, orbitControls, TControls, pokeScene, beginSceneBatch, endSceneBatch } from '../stores/sceneStore'; import { restoreGraphs, clearGraphs, SCENE_GRAPH, allNodes } from '../stores/flowStore'; import { serializeGraphs, copyGraphFrom } from './flowGraphs'; import { serializeNode, serializeEdge, sendNodes } from './nodesHandler'; import { parkAnimatedAtBase } from './flowRuntime'; import { stripEditOverlays } from './editOverlays'; -import { isPristinePackRef, stubElementOf, stubNodeCount } from './packRefs'; +import { isPristinePackRef, stubElementOf, stubNodeCount, fillPackRef } from './packRefs'; +import { beginLoad, endLoad, progress, slice, updateLoad, onCancel, isLive, LoadCancelled, throttledPoke } from './sceneLoader'; // B7: a spawner's copies exist only while the world runs — never in a scene file import { isTransient } from './transientObjects'; import { @@ -1297,12 +1298,31 @@ function reportUnknownNodes(payload) { * @param {{backup?: boolean, replicate?: boolean, game?: boolean, workspace?: boolean}} [opts] */ export async function applySession(payload, opts = {}) { + // 33 L1: ONE load at a time, and it says what it is doing. Starting this one supersedes + // a load still running (its loop stops at its next slice) — opening scene B while A is + // still arriving is how a user cancels A, and it must not leave A's objects in B. + const job = beginLoad(payload?.name ?? 'scene', (payload?.objects ?? []).length, { phase: 'preparing' }); + try { + await applySessionNow(payload, opts, job); + } catch (error) { + // a load that FAILED must not leave its bar up (endLoad ignores a superseded job) + endLoad(job); + throw error; + } +} + +/** @param {any} payload @param {{backup?: boolean, replicate?: boolean, game?: boolean, workspace?: boolean}} opts + * @param {import('./sceneLoader').LoadJob} job */ +async function applySessionNow(payload, opts, job) { const { backup = true, replicate = true, game = true, workspace = true } = opts; // 31 K3: the file's own name is the game's identity for its per-game settings (a // Games-tab load is unnamed afterwards, but its session name is the game's title) noteSceneFileName(payload?.name); const group = get(objectsGroup); - if (backup && group?.children.length) await saveSession('Backup before "' + payload.name + '"'); + // 33 L1: a load that interrupted another one still building finds HALF of that scene on + // screen — nothing anybody made, so no "Backup before" of it + const hadContent = !!group?.children.length && !job.interrupted; + if (backup && hadContent) await saveSession('Backup before "' + payload.name + '"'); // R22-R8: a session saved by "Save into session" carries the whole Explorer library // beside the scene, because that gesture EMPTIES the library and the save is the only // thing standing between the user and losing it. Restoring it is hash-deduped, so a @@ -1324,30 +1344,68 @@ export async function applySession(payload, opts = {}) { } catch { /* physics failing to load must never block a scene load */ } + // a load superseded while the backup or the library restore awaited stops here + if (!isLive(job)) return; if (replicate) sceneCommand('/clear all'); // replicated clear (objects + module content) else clearSceneLocal(); + updateLoad(job, { phase: 'objects' }); /** @type {any} */ const peer = get(peers); - for (const element of payload.objects ?? []) { - let object; - try { - object = new THREE.ObjectLoader().parse(element); - } catch { - continue; + // 33 L1: Cancel (the load bar) takes back what this load had added — clearing for the + // room as the load itself replicated. A load SUPERSEDED by another one is not cleared + // here: the newer load's own clear does that. + onCancel(() => { + if (replicate) sceneCommand('/clear all'); + else clearSceneLocal(); + showToast( + 'Stopped loading "' + (payload?.name ?? 'scene') + '"' + + (backup && hadContent ? ' — your previous scene is in Sessions as "Backup before ' + payload.name + '"' : '') + ); + }); + /** @type {Promise[]} kit pieces still refilling from their pack */ + const refills = []; + const poke = throttledPoke(pokeScene); + beginSceneBatch(); + try { + // 33 L1: TIME-SLICED. This loop used to build every object in one task — a castle of + // 180 pieces parsed, attached and broadcast before the window could paint again. + // `slice` yields whenever the ~10 ms budget is spent, and stops at a clean point + // between two objects when the load was cancelled or superseded. + for (const element of payload.objects ?? []) { + await slice(job); + let object; + try { + object = new THREE.ObjectLoader().parse(element); + } catch { + progress(job); + continue; + } + // A scene saved while a mesh-edit session was open carries the edit + // wireframe as a real child object — it comes back as a permanent, + // un-updatable wireframe nobody can switch off, and it accumulates on + // every save/load round trip (the reported "wireframe glitch"). Drop it + // on the way in; the peers do the same in `createObject`. + stripEditOverlays(object); + group.add(object); // keep original uuids — every peer converges on them + if (replicate && peer) peer.send({ type: 'object', element }); + // a kit piece is not loaded until its pack refilled it: counted when it lands, + // and asked for NOW rather than on the scan's next debounce + if (object.userData?.packStub) refills.push(fillPackRef(object).finally(() => progress(job))); + else progress(job); + poke(); } - // A scene saved while a mesh-edit session was open carries the edit - // wireframe as a real child object — it comes back as a permanent, - // un-updatable wireframe nobody can switch off, and it accumulates on - // every save/load round trip (the reported "wireframe glitch"). Drop it - // on the way in; the peers do the same in `createObject`. - stripEditOverlays(object); - group.add(object); // keep original uuids — every peer converges on them - if (replicate && peer) peer.send({ type: 'object', element }); + } catch (error) { + if (error instanceof LoadCancelled) return; + throw error; + } finally { + endSceneBatch(); } pokeScene(); // animated imports come back from their original bytes (mixers rebuilt, peers // reparse the same file) and authored tracks from the payload await animatedImportsRestore(payload.animated ?? [], replicate); + // superseded while the rigs parsed: the newer load owns the scene now + if (!isLive(job)) return; // replicate: a loaded scene's movements reach the peers already in the room, // the way each restored joint is re-broadcast below animationsRestore(payload.animations ?? {}, replicate); @@ -1414,6 +1472,12 @@ export async function applySession(payload, opts = {}) { // flow editor's badge is invisible when the dock is closed — which it is for most // players loading a game. Runs after restoreGraphs, so the count is the real one. reportUnknownNodes(payload); + // 33 L1: the bar stays up while kit pieces are still arriving from their pack, and it + // is no longer cancellable then — the scene is whole, only the models are on their way + if (refills.length) { + updateLoad(job, { phase: 'models', cancellable: false }); + void Promise.allSettled(refills).then(() => endLoad(job)); + } else endLoad(job); showToast('Session loaded: ' + payload.name + ' (' + (payload.count ?? 0) + ' objects)'); } From a7d4ceec2c8fc535c47ac334ba450cb515bfb073 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:44:18 +0300 Subject: [PATCH 06/44] [feat] 33 L1 P2: the scene-load bar - "Loading - n / N objects", Cancel - SceneLoadBar.svelte in the toast stack's first slot (the spectator banner's spot, so arriving toasts never shove it): name, count, a progress bar (indeterminate while preparing/reading), Cancel while objects are being BUILT; once only kit models are still arriving it keeps counting without Cancel. Non-modal, shown after 250 ms so a small scene does not flash it. Ids #scene-load-bar (data-done/total/phase), #scene-load-cancel. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/SceneLoadBar.svelte | 180 ++++++++++++++++++++++++ src/components/menu/Toasts.svelte | 3 + 2 files changed, 183 insertions(+) create mode 100644 src/components/menu/SceneLoadBar.svelte diff --git a/src/components/menu/SceneLoadBar.svelte b/src/components/menu/SceneLoadBar.svelte new file mode 100644 index 00000000..d2d3d4f0 --- /dev/null +++ b/src/components/menu/SceneLoadBar.svelte @@ -0,0 +1,180 @@ + + +{#if visible && $sceneLoad} +
+
+
+

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

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

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

+ {#if level.error}

{level.error}

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

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

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

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

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

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

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

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

+ {/if}
-