From dce39a15d267010483fd510232d5307138f7ab71 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 15:05:03 +0300 Subject: [PATCH 01/20] [feat] shadows on by default + shadow catcher (roadmap 12 V-1) - shadowDefaults.js: one objectsGroup-keyed sweep sets castShadow/ receiveShadow on every mesh (WeakSet-guarded, no userData writes) so all creation paths + loaded scenes get shadows without touching each call site; opt-out rides userData.shadow=false - environment.js: rig sun castShadow=true with tuned bias, shadow-camera frustum fit to sceneRadius each apply, and an env-shadow-catcher ShadowMaterial disc under ENV_ROOT (the infinite Grid shader can't receive shadows); catcher hidden when no sun / shadows off / passthrough - createLight: new Directional/Spot cast by default (Point opt-in) - lightParams: shadowQuality gains 'off' (disables renderer.shadowMap); applyShadowQualityCap now walks the env rig too and toggles the renderer - Inspector Cast toggle stamps userData.shadow so opt-out survives GLTF - e2e shadows.test.cjs (10 checks), build green, svelte-check 502/77 Co-Authored-By: Claude Opus 4.8 --- src/App.svelte | 7 ++- src/components/menu/Inspector.svelte | 9 +++- src/components/menu/Settings.svelte | 3 +- src/lib/drawMode.js | 1 + src/lib/environment.js | 56 ++++++++++++++++++++++ src/lib/geometries.svelte.js | 18 +++++++ src/lib/lightParams.js | 32 +++++++++---- src/lib/shadowDefaults.js | 39 +++++++++++++++ tests/e2e/shadows.test.cjs | 72 ++++++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 src/lib/shadowDefaults.js create mode 100644 tests/e2e/shadows.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 5db8e2f1..593747ac 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -23,6 +23,7 @@ import { startSnapping } from '$lib/snapping' import { startMultiTransform } from '$lib/multiTransform' import { startLightParams } from '$lib/lightParams' + import { startShadowDefaults } from '$lib/shadowDefaults' import { startAutosave } from '$lib/autosave' import { startSceneAssets } from '$lib/sceneAssets' import { startNetworkQuality } from '$lib/networkQuality' @@ -44,6 +45,7 @@ startLockSweep() startMultiTransform() startLightParams() + startShadowDefaults() loadUserModules() startEnvironment() startSceneBounds() @@ -85,6 +87,7 @@ import('./lib/sessions'), import('./lib/geometryEdit'), import('./lib/lightParams'), + import('./lib/shadowDefaults'), import('./lib/themes'), import('./lib/vrRadialMenu'), import('./lib/vrPalette'), @@ -114,8 +117,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } }) } }) diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index d743a5b4..e397f47c 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -234,6 +234,13 @@ }); } + // Cast toggle also stamps userData.shadow so the opt-out survives GLTF sync + // (the bare castShadow flag does not round-trip through GLTFExporter) — V-1 + function setCastShadow() { + $selectedObject.userData.shadow = $selectedObject.castShadow ? undefined : false; + sendParam('castShadow'); + } + function sendName() { objectsGroup.update((value) => value); // refresh the object list $peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid }); @@ -1200,7 +1207,7 @@
- sendParam('castShadow')}> + setCastShadow()}> Cast

-

Shadow quality — caps every light's shadow map size on THIS machine (per-light sizes still replicate)

+

Shadow quality — caps every light's shadow map size on THIS machine (Off disables shadows entirely; per-light sizes still replicate)

diff --git a/src/lib/drawMode.js b/src/lib/drawMode.js index 925d104f..2bad282f 100644 --- a/src/lib/drawMode.js +++ b/src/lib/drawMode.js @@ -189,6 +189,7 @@ export function endStroke() { new THREE.MeshBasicMaterial({ color: get(drawColor) }) ); mesh.name = 'Stroke'; + mesh.userData.shadow = false; // draw strokes don't cast (basic-material lines) group.add(mesh); objectsGroup.update((value) => value); diff --git a/src/lib/environment.js b/src/lib/environment.js index 3f5e3484..7f1d0210 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -5,6 +5,7 @@ import { peers } from '../stores/appStore'; import { sceneRadius } from './sceneBounds'; import { registerSystemGroup } from './moduleSDK'; import { createLight } from './geometries.svelte'; +import { cappedShadowSize, shadowQuality } from './lightParams'; import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; // Environment v2 (phase 70). Everything environmental lives under ONE group at @@ -83,6 +84,7 @@ export const peerEnvPresets = writable(/** @type {Record} */ ({}) export const ENV_ROOT = 'environment-root'; const RIG_HEMI = 'env-rig-hemi'; const RIG_SUN = 'env-rig-sun'; +const CATCHER = 'env-shadow-catcher'; const EXTRA_PREFIX = 'env-extra-'; let userLightFactor = 1; @@ -117,11 +119,38 @@ function rigLights(scene, create) { if (!sun && create) { sun = new THREE.DirectionalLight(0xffffff, 1); sun.name = RIG_SUN; + // the rig sun is the default shadow caster (V-1); bias values tuned to + // avoid acne/peter-panning across the presets, map size under the cap + sun.castShadow = true; + sun.shadow.bias = -0.0002; + sun.shadow.normalBias = 0.02; + const size = cappedShadowSize(2048); + sun.shadow.mapSize.set(size, size); root.add(sun); } return { hemi, sun }; } +/** The flat ShadowMaterial disc that catches the rig sun's shadows — the + * infinite Grid is a shader and can't receive shadows. Lives in ENV_ROOT + * (scene root) so it never enters GLTF sync. @param {any} scene @param {boolean} create */ +function shadowCatcher(scene, create) { + const root = envRoot(scene); + let disc = scene.getObjectByName(CATCHER); + if (!disc && create) { + disc = new THREE.Mesh( + new THREE.CircleGeometry(1, 48), + new THREE.ShadowMaterial({ opacity: 0.32, transparent: true }) + ); + disc.name = CATCHER; + disc.rotation.x = -Math.PI / 2; + disc.position.y = -0.001; + disc.receiveShadow = true; + root.add(disc); + } + return disc; +} + /** Create/update/remove `env-extra-*` lights to mirror state.lights @param {any} scene @param {any[]} defs */ function reconcileExtraLights(scene, defs) { const root = envRoot(scene); @@ -175,6 +204,10 @@ export function applyEnvironment() { if (renderer) { renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = (preset.exposure ?? 1) * (state.exposure ?? 1); + // honor a persisted 'off' shadow pref here too: the renderer arrives + // after lightParams' first subscribe fires (which would no-op on a null + // renderer), so re-assert it on every apply + if (renderer.shadowMap) renderer.shadowMap.enabled = get(shadowQuality) !== 'off'; } const { hemi, sun } = rigLights(scene, !!preset.hemi); @@ -192,9 +225,32 @@ export function applyEnvironment() { sun.color.set(preset.sun.color); sun.intensity = preset.sun.intensity * userLightFactor; sun.position.fromArray(preset.sun.position); + // fit the ortho shadow frustum to the scene: sceneBounds re-calls + // applyEnvironment when the radius changes by >1, so the frustum + // tracks scene growth for free + if (sun.castShadow && sun.shadow) { + const r = Math.min(Math.max(sceneRadius() * 1.2, 15), 120); + const cam = sun.shadow.camera; + cam.left = -r; + cam.right = r; + cam.top = r; + cam.bottom = -r; + cam.near = 0.5; + cam.far = r * 4; + cam.updateProjectionMatrix(); + } } else sun.visible = false; } + // shadow catcher: visible only when the sun casts and shadows aren't off + const shadowsOff = get(shadowQuality) === 'off'; + const catcher = shadowCatcher(scene, !!(preset.sun && !shadowsOff)); + if (catcher) { + catcher.visible = !!(preset.sun && !shadowsOff) && !get(passthroughActive); + const span = Math.max(60, sceneRadius() * 2); + catcher.scale.set(span, span, span); + } + reconcileExtraLights(scene, state.lights ?? []); } diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js index 4106f43d..398919bb 100644 --- a/src/lib/geometries.svelte.js +++ b/src/lib/geometries.svelte.js @@ -104,6 +104,24 @@ export function createLight(command, uuid) { } if (light){ fixLight.set(false); + // Directional/Spot cast shadows by default (V-1); Point stays opt-in + // (6-face cube-map cost). Deterministic: the same /light command runs + // on every peer, so shadow flags match without extra sync. + if (light.isDirectionalLight || light.isSpotLight) { + light.castShadow = true; + if (light.isDirectionalLight && light.shadow) { + light.shadow.camera.left = -15; + light.shadow.camera.right = 15; + light.shadow.camera.top = 15; + light.shadow.camera.bottom = -15; + light.shadow.camera.far = 80; + light.shadow.camera.updateProjectionMatrix(); + } + if (light.shadow) { + light.shadow.bias = -0.0002; + light.shadow.normalBias = 0.02; + } + } if (uuid) light.uuid = uuid sceneObjects.add(light); //Trigger reactivity for UI list of objects diff --git a/src/lib/lightParams.js b/src/lib/lightParams.js index d1e9ace7..0ba6c42c 100644 --- a/src/lib/lightParams.js +++ b/src/lib/lightParams.js @@ -1,5 +1,5 @@ import { writable, get } from 'svelte/store'; -import { objectsGroup } from '../stores/sceneStore'; +import { objectsGroup, globalScene, globalRenderer } from '../stores/sceneStore'; // Light parameter registry (phase 79): type-specific settings the Inspector // renders (color/intensity/visible are common rows it already has). Values @@ -30,8 +30,9 @@ export const LIGHT_PARAMS = { export const SHADOW_TYPES = ['DirectionalLight', 'SpotLight', 'PointLight']; export const SHADOW_SIZES = [512, 1024, 2048]; -// global shadow quality (quiz: low/med/high cap) — a LOCAL render preference -const QUALITY_CAPS = { low: 512, medium: 1024, high: 2048 }; +// global shadow quality (quiz: off/low/med/high cap) — a LOCAL render preference. +// 'off' disables the renderer shadow map entirely (V-1 perf escape hatch). +const QUALITY_CAPS = { off: 512, low: 512, medium: 1024, high: 2048 }; export const shadowQuality = writable( typeof localStorage !== 'undefined' ? localStorage.getItem('shadowQuality') ?? 'high' @@ -41,14 +42,24 @@ export const shadowQuality = writable( /** the size a light's shadow map actually uses under the global cap * @param {number} wanted */ export function cappedShadowSize(wanted) { - const cap = QUALITY_CAPS[/** @type {'low'|'medium'|'high'} */ (get(shadowQuality))] ?? 2048; + const cap = QUALITY_CAPS[/** @type {'off'|'low'|'medium'|'high'} */ (get(shadowQuality))] ?? 2048; return Math.min(wanted || 1024, cap); } -/** re-apply the cap to every shadow-casting light (on quality change) */ +/** re-apply the cap to every shadow-casting light (on quality change) — + * walks both objectsGroup and the scene-root environment rig, and toggles the + * renderer's shadow map on the 'off' setting */ export function applyShadowQualityCap() { - const group = get(objectsGroup); - group?.traverse((/** @type {any} */ node) => { + const off = get(shadowQuality) === 'off'; + /** @type {any} */ + const renderer = get(globalRenderer); + if (renderer?.shadowMap) { + if (renderer.shadowMap.enabled === off) { + renderer.shadowMap.enabled = !off; + renderer.shadowMap.needsUpdate = true; + } + } + const apply = (/** @type {any} */ node) => { if (!node.isLight || !node.shadow) return; const wanted = node.userData.shadowMapSize ?? node.shadow.mapSize.x; const size = cappedShadowSize(wanted); @@ -57,7 +68,12 @@ export function applyShadowQualityCap() { node.shadow.map?.dispose(); node.shadow.map = null; } - }); + }; + get(objectsGroup)?.traverse(apply); + get(globalScene)?.getObjectByName('environment-root')?.traverse(apply); + // the shadow catcher's visibility depends on this pref — dynamic import + // keeps the module graph acyclic (environment imports lightParams) + import('./environment').then((m) => m.applyEnvironment()); } /** set a light's WANTED shadow map size (the cap may reduce it locally) diff --git a/src/lib/shadowDefaults.js b/src/lib/shadowDefaults.js new file mode 100644 index 00000000..70e26630 --- /dev/null +++ b/src/lib/shadowDefaults.js @@ -0,0 +1,39 @@ +import { get } from 'svelte/store'; +import { objectsGroup } from '../stores/sceneStore'; + +// Shadows on by default (V-1). threlte's already enables the shadow map +// (PCFSoft), and castShadow/receiveShadow are already replicated objectParameters +// + Inspector checkboxes — so "shadows by default" is purely about turning on the +// per-mesh FLAGS. Rather than touch every creation site (createGeometry, GLTF +// receive, explorerDrop, prefabs, sessions, animatedImports, history re-adds) we +// run ONE sweep keyed off the objectsGroup store: every already-fired +// `objectsGroup.update(v => v)` at those sites drives it, and it retrofits loaded +// scenes too. A module-level WeakSet marks meshes already defaulted so we never +// stomp a user's later opt-out — and nothing is written into userData, so the +// sweep leaves serialization untouched. + +/** meshes we've already applied defaults to @type {WeakSet} */ +const seen = new WeakSet(); + +/** @param {any} group */ +function sweep(group) { + if (!group) return; + group.traverse((/** @type {any} */ node) => { + if (!node.isMesh || seen.has(node)) return; + seen.add(node); + // opt-out rides userData.shadow=false (survives toJSON + GLTF extras; + // the bare castShadow flag does NOT survive GLTFExporter, which is why + // the Inspector writes the userData flag alongside the bare flag) + node.castShadow = node.userData?.shadow !== false; + node.receiveShadow = true; + }); +} + +let started = false; + +export function startShadowDefaults() { + if (started || typeof window === 'undefined') return; + started = true; + objectsGroup.subscribe((group) => sweep(group)); + sweep(get(objectsGroup)); +} diff --git a/tests/e2e/shadows.test.cjs b/tests/e2e/shadows.test.cjs new file mode 100644 index 00000000..e7bcc4c1 --- /dev/null +++ b/tests/e2e/shadows.test.cjs @@ -0,0 +1,72 @@ +// V-1: shadows on by default — renderer shadow map enabled, the env sun casts, +// created meshes cast+receive, a shadow catcher lives under ENV_ROOT, and the +// 'off' quality disables the renderer shadow map. Single page (nothing new +// replicates: the flags are derived locally on every peer). +const h = require('./helpers.cjs'); + +const snapshot = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.__stores.globalScene.subscribe((scene) => { + window.__stores.globalRenderer.subscribe((renderer) => { + const sun = scene?.getObjectByName('env-rig-sun'); + const catcher = scene?.getObjectByName('env-shadow-catcher'); + let box = null; + scene?.traverse((o) => { + if (o.name === 'Box' && o.isMesh) box = o; + }); + resolve({ + shadowMapEnabled: !!renderer?.shadowMap?.enabled, + sunCasts: !!sun?.castShadow, + catcherPresent: !!catcher, + catcherReceives: !!catcher?.receiveShadow, + boxCasts: box ? box.castShadow : null, + boxReceives: box ? box.receiveShadow : null + }); + })(); + })(); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); + await A.page.waitForTimeout(500); + + let s = await snapshot(A.page); + h.check(s.shadowMapEnabled === true, 'renderer shadow map enabled by default'); + h.check(s.sunCasts === true, 'env rig sun casts shadows'); + h.check(s.catcherPresent === true, 'shadow catcher present under the scene root'); + h.check(s.catcherReceives === true, 'shadow catcher receives shadows'); + h.check(s.boxCasts === true, 'created box casts shadows by default'); + h.check(s.boxReceives === true, 'created box receives shadows by default'); + + // catcher lives at the scene root, never in the synced objects + const inObjects = await A.page.evaluate( + () => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => + r(g?.children.some((c) => c.name === 'env-shadow-catcher') ?? false) + )() + ) + ); + h.check(inObjects === false, 'catcher is not part of the replicated objects'); + + // shadowQuality 'off' disables the renderer shadow map + await A.page.evaluate(() => window.__stores.lightParams.shadowQuality.set('off')); + await A.page.waitForTimeout(300); + s = await snapshot(A.page); + h.check(s.shadowMapEnabled === false, "shadowQuality 'off' disables the shadow map"); + h.check(s.catcherPresent === true && s.catcherReceives === true, 'catcher still exists when off'); + + // back to high re-enables + await A.page.evaluate(() => window.__stores.lightParams.shadowQuality.set('high')); + await A.page.waitForTimeout(300); + s = await snapshot(A.page); + h.check(s.shadowMapEnabled === true, "shadowQuality back to 'high' re-enables the shadow map"); + + await h.finish(browser); +}); From c2005ce270cc6f647d9fe71454572e85aa4e71eb Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 15:12:59 +0300 Subject: [PATCH 02/20] [feat] curated default palette + look tune (roadmap 12 V-3) - palette.js: 8-color muted palette + paletteColorFor(uuid) deterministic char-sum hash; creator and every receiver compute the SAME color from the create message's uuid, so the look is shared with zero wire bytes and objects cycle instead of all being bright green - geometries.createGeometry: material color from paletteColorFor(uuid) (set after uuid assignment) + roughness 0.85, replacing 0x00ff00 - look tune: studio bg #363b43, hemi ground #4c525c; grid fadeDistance 100 + lighter section color; removed the inert ignoreOverrideMaterial userData from the Grid (no per-object override opt-out exists in three) - e2e palette.test.cjs (two-peer, deterministic color match), build green, svelte-check 502/77 Co-Authored-By: Claude Opus 4.8 --- src/App.svelte | 5 ++-- src/extensions/Grid.svelte | 5 ++-- src/lib/environment.js | 4 +-- src/lib/geometries.svelte.js | 7 +++-- src/lib/palette.js | 30 +++++++++++++++++++++ tests/e2e/palette.test.cjs | 51 ++++++++++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 src/lib/palette.js create mode 100644 tests/e2e/palette.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 593747ac..71708952 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -88,6 +88,7 @@ import('./lib/geometryEdit'), import('./lib/lightParams'), import('./lib/shadowDefaults'), + import('./lib/palette'), import('./lib/themes'), import('./lib/vrRadialMenu'), import('./lib/vrPalette'), @@ -117,8 +118,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } }) } }) diff --git a/src/extensions/Grid.svelte b/src/extensions/Grid.svelte index e96f333b..c0e41ee4 100644 --- a/src/extensions/Grid.svelte +++ b/src/extensions/Grid.svelte @@ -5,14 +5,13 @@ {#if showGrid} {/if} diff --git a/src/lib/environment.js b/src/lib/environment.js index 7f1d0210..e97665d6 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -18,9 +18,9 @@ import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; export const ENVIRONMENT_PRESETS = { studio: { label: 'Studio', - background: '#3b4048', + background: '#363b43', fog: null, - hemi: { sky: '#ffffff', ground: '#565d68', intensity: 1.1 }, + hemi: { sky: '#ffffff', ground: '#4c525c', intensity: 1.1 }, sun: { color: '#ffffff', intensity: 1.8, position: [6, 10, 4] }, exposure: 1 }, diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js index 398919bb..000e90a5 100644 --- a/src/lib/geometries.svelte.js +++ b/src/lib/geometries.svelte.js @@ -3,6 +3,7 @@ import { RectAreaLightUniformsLib } from 'three/examples/jsm/lights/RectAreaLigh import { toggleExpand, fixLight } from '../stores/appStore.js'; import { customGeometryBuilders } from '$lib/customGeometries'; import { stampGeometryParams } from '$lib/geometryEdit'; +import { paletteColorFor } from '$lib/palette'; // RectAreaLight renders black on Standard/Physical materials until the // uniforms lib initializes — once per session is enough (79) @@ -56,9 +57,11 @@ export function createGeometry(command, uuid) { let mesh = customGeometryBuilders[geometry] ? customGeometryBuilders[geometry](options[0],options[1],options[2],options[3]) : new (/** @type {any} */ (THREE))[geometry+'Geometry'](options[0],options[1],options[2],options[3]); - let material = new THREE.MeshStandardMaterial({ color: 0x00ff00 }); - let object = new THREE.Mesh(mesh, material); + let object = new THREE.Mesh(mesh, new THREE.MeshStandardMaterial({ roughness: 0.85 })); if (uuid) object.uuid = uuid + // deterministic palette color keyed by the FINAL uuid (peers compute the + // same color from the create message's uuid) — V-3, replaces 0x00ff00 + object.material.color.set(paletteColorFor(object.uuid)); object.name = geometry; stampGeometryParams(object); // editable params survive sync (78) sceneObjects.add(object); diff --git a/src/lib/palette.js b/src/lib/palette.js new file mode 100644 index 00000000..7c34a985 --- /dev/null +++ b/src/lib/palette.js @@ -0,0 +1,30 @@ +// Default object palette (V-3). New primitives used to be uniformly bright +// green (0x00ff00) — ugly, and ten cubes were indistinguishable. Instead each +// new primitive picks a color from a curated muted palette by a DETERMINISTIC +// hash of its uuid: the creator and every receiver compute the SAME color from +// the same uuid in the `create` message, so the look is shared with zero extra +// wire bytes and objects naturally cycle through the palette. +// +// Colors are mid-value muted tones that read well on the studio background and +// take the new shadows + AO gracefully. Assigned as hex strings — safe under +// THREE color management (the setHSL-linearization gotcha only bites HSL). + +export const DEFAULT_PALETTE = [ + '#e07a5f', // terracotta + '#f2cc8f', // sand + '#81b29a', // sage + '#6d9dc5', // steel blue + '#8f7fc8', // slate lavender + '#d387ab', // rose + '#5fb0b7', // teal + '#b8b2a7' // warm gray +]; + +/** Deterministic palette color for an object, keyed by uuid. + * @param {string} uuid @returns {string} hex color */ +export function paletteColorFor(uuid) { + let hash = 0; + const key = String(uuid ?? ''); + for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) | 0; + return DEFAULT_PALETTE[Math.abs(hash) % DEFAULT_PALETTE.length]; +} diff --git a/tests/e2e/palette.test.cjs b/tests/e2e/palette.test.cjs new file mode 100644 index 00000000..0485493e --- /dev/null +++ b/tests/e2e/palette.test.cjs @@ -0,0 +1,51 @@ +// V-3: new primitives get a curated palette color (not 0x00ff00), assigned +// deterministically by uuid so peers compute the SAME color with no wire bytes. +const h = require('./helpers.cjs'); + +// map every 'Box' mesh uuid -> its material color hex +const boxColors = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const out = {}; + g?.traverse((o) => { + if (o.isMesh && o.name === 'Box') out[o.uuid] = '#' + o.material.color.getHexString(); + }); + resolve(out); + })(); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + const palette = await A.page.evaluate(() => window.__stores.palette.DEFAULT_PALETTE); + h.check(Array.isArray(palette) && palette.length === 8, `palette has 8 colors (${palette.length})`); + h.check(!palette.includes('#00ff00'), 'palette does not contain the old bright green'); + + // connect B first, so a create on A replicates via the `create` message and + // B re-runs createGeometry(command, uuid) — directly exercising the + // deterministic uuid->color hash (not a serialized material) + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + await A.page.evaluate(() => { + for (let i = 0; i < 5; i++) window.__stores.commandsHandler.sceneCommand('/create box'); + }); + await B.page.waitForTimeout(1500); + + const colors = await boxColors(A.page); + const uuids = Object.keys(colors); + const vals = uuids.map((u) => colors[u]); + h.check(uuids.length >= 5, `created boxes present on A (${uuids.length})`); + h.check(vals.every((c) => palette.includes(c)), `every new primitive is a palette color (${vals.join(', ')})`); + h.check(new Set(vals).size > 1, `colors vary across objects (${new Set(vals).size} distinct)`); + + const bColors = await boxColors(B.page); + const matches = uuids.filter((u) => bColors[u] && bColors[u] === colors[u]); + h.check(matches.length === uuids.length, `peer B computes the identical color per uuid (${matches.length}/${uuids.length})`); + + await h.finish(browser); +}); From 8122698f441f5b985084c66a85e8a1a4392c4311 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 15:20:42 +0300 Subject: [PATCH 03/20] [feat] Add > Terrain sculptable ground primitive (roadmap 12 T-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - customGeometries.terrain(size=24, segments=48): PlaneGeometry rotated flat (up = +Y, resting at y=0); segments clamped to 48 so a sculpted terrain's non-indexed meshgeo snapshot (18*seg^2 = 41,472 floats) stays under the 45,000 cap that terrainSculpt/T-2 will reuse - primitivesCatalog: new Ground group with Terrain — appears in the Add menu, search, and spawn-at-click automatically (buildAddChildren maps groups generically) - createGeometry: Terrain gets a sage material + userData.terrain flag (deterministic on both peers, survives toJSON + GLTF extras) that the T-2 Sculpt menu will key off - e2e terrain.test.cjs (two-peer: tri count, verts, flag, size, sage, segment clamp), build green, svelte-check 502/77 Note: add-menu.test.cjs "search Enter" check is a pre-existing failure (reproduces on the clean base without this change) — not T-1. Co-Authored-By: Claude Opus 4.8 --- src/lib/customGeometries.js | 16 +++++++- src/lib/geometries.svelte.js | 7 ++++ src/lib/primitivesCatalog.js | 4 ++ tests/e2e/terrain.test.cjs | 73 ++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/terrain.test.cjs diff --git a/src/lib/customGeometries.js b/src/lib/customGeometries.js index fffb6662..9e0246a7 100644 --- a/src/lib/customGeometries.js +++ b/src/lib/customGeometries.js @@ -85,10 +85,24 @@ function corner(a, b, c) { return geometry; } +/** Flat sculptable ground: size (metres) x segments per side. Segments are + * clamped to 48 so a sculpted terrain's non-indexed snapshot (18*seg^2 floats = + * 41,472 at 48) stays under the meshgeo cap (45,000) — see terrainSculpt / T-2. + * A PlaneGeometry in XY rotated flat so up is +Y, resting at y=0. + * @param {any=} a @param {any=} b */ +function terrain(a, b) { + const size = num(a, 24); + const segments = Math.min(Math.max(Math.round(num(b, 48)), 2), 48); + const geometry = new THREE.PlaneGeometry(size, size, segments, segments); + geometry.rotateX(-Math.PI / 2); + return geometry; +} + /** @type {Record THREE.BufferGeometry>} */ export const customGeometryBuilders = { Wedge: wedge, Stairs: stairs, Arch: arch, - Corner: corner + Corner: corner, + Terrain: terrain }; diff --git a/src/lib/geometries.svelte.js b/src/lib/geometries.svelte.js index 000e90a5..5be6d32f 100644 --- a/src/lib/geometries.svelte.js +++ b/src/lib/geometries.svelte.js @@ -63,6 +63,13 @@ export function createGeometry(command, uuid) { // same color from the create message's uuid) — V-3, replaces 0x00ff00 object.material.color.set(paletteColorFor(object.uuid)); object.name = geometry; + if (geometry === 'Terrain') { + // terrain gets a distinct sage look + a flag the Sculpt menu keys off + // (deterministic on both peers; survives toJSON + GLTF extras) — T-1 + object.material.color.set('#81b29a'); + object.material.roughness = 0.95; + object.userData.terrain = true; + } stampGeometryParams(object); // editable params survive sync (78) sceneObjects.add(object); //Trigger reactivity for UI list of objects diff --git a/src/lib/primitivesCatalog.js b/src/lib/primitivesCatalog.js index 426c1988..1e299f2d 100644 --- a/src/lib/primitivesCatalog.js +++ b/src/lib/primitivesCatalog.js @@ -34,6 +34,10 @@ export const primitivesCatalog = [ { label: 'Corner', command: '/create Corner 2 2 0.25' } ] }, + { + group: 'Ground', + items: [{ label: 'Terrain', command: '/create Terrain 24 48' }] + }, { group: 'Light', items: [ diff --git a/tests/e2e/terrain.test.cjs b/tests/e2e/terrain.test.cjs new file mode 100644 index 00000000..1db5b0a9 --- /dev/null +++ b/tests/e2e/terrain.test.cjs @@ -0,0 +1,73 @@ +// T-1: Add > Terrain — a replicated subdivided ground plane under the meshgeo +// cap, stamped userData.terrain, appearing in the Add menu catalog. +const h = require('./helpers.cjs'); + +const terrainInfo = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + let t = null; + g?.traverse((o) => { + if (o.isMesh && o.name === 'Terrain') t = o; + }); + if (!t) return resolve(null); + t.geometry.computeBoundingBox(); + const size = t.geometry.boundingBox.getSize(new window.__stores.THREE.Vector3()); + resolve({ + uuid: t.uuid, + tris: t.geometry.index ? t.geometry.index.count / 3 : t.geometry.attributes.position.count / 3, + verts: t.geometry.attributes.position.count, + terrainFlag: t.userData.terrain === true, + sizeX: Math.round(size.x), + sizeZ: Math.round(size.z), + color: '#' + t.material.color.getHexString() + }); + })(); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create Terrain 24 48')); + await B.page.waitForTimeout(1200); + + const a = await terrainInfo(A.page); + h.check(!!a, 'terrain created on A'); + h.check(a.tris === 4608, `terrain has 4608 tris (${a.tris}) — under the meshgeo cap`); + h.check(a.verts === 2401, `terrain has 49x49 = 2401 vertices (${a.verts})`); + h.check(a.terrainFlag === true, 'terrain stamped userData.terrain'); + h.check(a.sizeX === 24 && a.sizeZ === 24, `terrain spans 24x24 (${a.sizeX}x${a.sizeZ})`); + h.check(a.color === '#81b29a', `terrain uses the sage color (${a.color})`); + + const b = await terrainInfo(B.page); + h.check(!!b && b.uuid === a.uuid, 'terrain replicated to B with the same uuid'); + h.check(b && b.tris === a.tris && b.terrainFlag === true, 'B terrain matches geometry + flag'); + + // segments clamp: a hand-typed over-cap count is limited to 48 + await A.page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create Terrain 30 100')); + await A.page.waitForTimeout(400); + const clamped = await A.page.evaluate( + () => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + let max = 0; + g?.traverse((o) => { + if (o.isMesh && o.name === 'Terrain') { + const tris = o.geometry.index ? o.geometry.index.count / 3 : 0; + max = Math.max(max, tris); + } + }); + resolve(max); + })(); + }) + ); + h.check(clamped <= 4608, `segments clamp holds the tri count under the cap (max ${clamped})`); + + await h.finish(browser); +}); From abdf0f3c333688ad0789af8ee1cf56badee54453 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 15:29:25 +0300 Subject: [PATCH 04/20] [feat] multi-select-aware object context menu (roadmap 12 U-2) - objectMenu.buildObjectMenuItems: when the clicked object is part of a 2+ selection, set-oriented items act on the whole set with counted labels (Delete (3), Duplicate (3), ...); object-specific items (rename/edit mesh/add note/request control) stay on the clicked one. Falls back to selectionUuids() so both callers work unchanged; neither right-click path re-selects, so the multi-selection survives menu open - new "Group selection": objectActions.groupSelection creates one empty group at the selection centroid and moves every member in, wrapped in a history batch so undo restores the flat layout in ONE step; all replicated via the existing group/create/move messages - Ungroup exposed on the desktop menu for Group objects (was VR/AI only) - Delete no longer clobbers the selection when the clicked object is in it; savePrefabSelection captures all members as one group prefab; ping.pingObjects pings the union-bounds top for a set - e2e multiselect-menu.test.cjs (two-peer: group replicates + one-step undo, multi delete, multi prefab); object-delete/multi-select/prefabs suites still green; build green, svelte-check 502/77 Co-Authored-By: Claude Opus 4.8 --- src/lib/objectActions.js | 56 ++++++++++++++- src/lib/objectMenu.js | 92 +++++++++++++++++++------ src/lib/ping.js | 19 +++++ src/lib/prefabs.js | 36 ++++++++++ tests/e2e/multiselect-menu.test.cjs | 103 ++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/multiselect-menu.test.cjs diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 3416375d..9b61aa5e 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -1,7 +1,8 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { dropToSurface } from './snapping'; -import { recordTransform, recordEntry, recordObjectPresence, registerHistoryKind } from './history'; +import { recordTransform, recordEntry, recordObjectPresence, registerHistoryKind, beginHistoryBatch, endHistoryBatch } from './history'; +import { createGroup } from './geometries.svelte'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { objectsGroup, @@ -466,6 +467,59 @@ export function ungroupObject(groupUuid) { return true; } +/** + * Group the current multi-selection into a NEW empty group placed at the + * selection centroid, then move every member into it (U-2). All pieces are + * already replicated primitives — createGroup + the group message + a move for + * the centroid — wrapped in ONE history batch so undo restores the flat layout + * in a single step. Returns the new group's uuid (or null if <2 selected). + */ +export function groupSelection() { + const uuids = selectionUuids(); + if (uuids.length < 2) return null; + const group = get(objectsGroup); + const members = uuids + .map((uuid) => group?.getObjectByProperty('uuid', uuid)) + .filter(Boolean); + if (members.length < 2) return null; + + // centroid of members in world space → the group's pivot + const centroid = new THREE.Vector3(); + const world = new THREE.Vector3(); + for (const member of members) { + member.getWorldPosition(world); + centroid.add(world); + } + centroid.divideScalar(members.length); + + /** @type {any} */ + const peer = get(peers); + beginHistoryBatch(); + // empty group (replicated via the same message the /group command uses) + const groupUuid = createGroup('/group Selection'); + const newGroup = group?.getObjectByProperty('uuid', groupUuid); + if (peer) peer.send({ type: 'group', command: '/group Selection', uuid: groupUuid }); + recordObjectPresence('create', newGroup); + // move the empty group to the centroid BEFORE attaching (both peers attach + // with the group already at the pivot, so member local coords match) + if (newGroup) { + newGroup.position.copy(centroid); + if (peer) + peer.send({ + type: 'move', + uuid: groupUuid, + pos: newGroup.position.toArray(), + rot: newGroup.rotation.toArray(), + scale: newGroup.scale.toArray() + }); + } + for (const uuid of uuids) moveObjectToGroup(uuid, groupUuid); + endHistoryBatch('Group objects'); + objectsGroup.update((value) => value); + applySelectionSet([groupUuid]); + return groupUuid; +} + /** * One-shot "Align to ground": drop the selected object onto the surface below, * replicate and record an undoable history entry. diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 87a5071e..a4339287 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -5,16 +5,20 @@ import { renamingObject } from '../stores/appStore'; import { focusObject, duplicateObject, + duplicateSelection, toggleObjectVisibility, alignToGround, requestDeleteSelection, - selectObject + groupSelection, + ungroupObject, + selectObject, + selectionUuids } from './objectActions'; import { requestControl, nameOf } from './lockControl'; -import { savePrefab } from './prefabs'; +import { savePrefab, savePrefabSelection } from './prefabs'; import { enterEditMode } from './meshEdit'; import { addAnnotation } from './annotationsHandler'; -import { pingObject } from './ping'; +import { pingObject, pingObjects } from './ping'; /** * The FULL object context menu, shared so the direct object menu (right-click an @@ -22,16 +26,33 @@ import { pingObject } from './ping'; * "Selected" submenu (ViewportMenu.svelte) expose the SAME actions — they used to * drift (the indirect path had fewer). Reads stores via get() (menus rebuild on * open, so this is fine). - * @param {string} uuid @param {{ point?: number[] | null, locked?: boolean }} [opts] + * + * Multi-select aware (U-2): when the right-clicked uuid is part of the current + * selection AND the selection has 2+ members, set-oriented items act on the whole + * SET with a counted label; a "Group selection" item appears. Object-specific + * items (rename / edit mesh / add note / request control) stay on the clicked one. + * @param {string} uuid @param {{ point?: number[] | null, locked?: boolean, selection?: string[] }} [opts] */ export function buildObjectMenuItems(uuid, opts = {}) { const point = opts.point ?? null; const locks = get(lockedObjects); const locked = opts.locked ?? !!locks.find((lock) => lock[1] === uuid); - const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + const group = get(objectsGroup); + const object = group?.getObjectByProperty('uuid', uuid); const muted = get(mutedFlowObjects).includes(uuid); const lockHolder = locks.find((lock) => lock[1] === uuid)?.[0]; const lockedTooltip = locked ? 'Locked by ' + nameOf(lockHolder) : ''; + + // selection set the clicked object belongs to (empty when it's a lone click) + const selection = opts.selection ?? selectionUuids(); + const multi = selection.length > 1 && selection.includes(uuid); + const targets = multi ? selection : [uuid]; + const suffix = multi ? ` (${targets.length})` : ''; + const isGroup = object?.type === 'Group'; + + /** run a per-object action across the target set */ + const forEach = (/** @type {(u: string) => void} */ fn) => () => targets.forEach(fn); + return [ ...(locked ? [ @@ -42,18 +63,41 @@ export function buildObjectMenuItems(uuid, opts = {}) { } ] : []), - { label: 'Focus camera', tooltip: 'F', action: () => focusObject(uuid) }, - { label: 'Duplicate', tooltip: 'Ctrl+D', action: () => duplicateObject(uuid) }, + { label: 'Focus camera' + suffix, tooltip: 'F', action: () => focusObject(multi ? undefined : uuid) }, + { + label: 'Duplicate' + suffix, + tooltip: 'Ctrl+D', + action: () => (multi ? duplicateSelection() : duplicateObject(uuid)) + }, + ...(multi + ? [ + { + label: 'Group selection' + suffix, + tooltip: 'Move the selected objects into one new group', + action: () => groupSelection() + } + ] + : []), + ...(isGroup + ? [ + { + label: 'Ungroup', + disabled: locked, + tooltip: locked ? lockedTooltip : 'Move the children out, then remove the empty group', + action: () => ungroupObject(uuid) + } + ] + : []), { - label: 'Save as prefab', + label: 'Save as prefab' + suffix, tooltip: 'Reusable copy in your Library (local, instances replicate)', - action: () => savePrefab(uuid) + action: () => (multi ? savePrefabSelection(targets) : savePrefab(uuid)) }, { - label: 'Align to ground', + label: 'Align to ground' + suffix, disabled: locked, - tooltip: locked ? lockedTooltip : 'Drop the object onto the surface below (undoable)', - action: () => alignToGround(uuid) + tooltip: locked ? lockedTooltip : 'Drop onto the surface below (undoable)', + action: forEach((u) => alignToGround(u)) }, { label: 'Edit mesh', @@ -63,30 +107,34 @@ export function buildObjectMenuItems(uuid, opts = {}) { }, { label: 'Add note', tooltip: 'Pin a synced note exactly where you pointed', action: () => addAnnotation(uuid, point) }, { - label: 'Ping this object', + label: multi ? 'Ping selection' + suffix : 'Ping this object', tooltip: 'Everyone sees a pulse here (Alt+click pings anywhere)', - action: () => pingObject(uuid) + action: () => (multi ? pingObjects(targets) : pingObject(uuid)) }, { label: 'Rename', disabled: locked, tooltip: lockedTooltip, action: () => renamingObject.set(uuid) }, { - label: object?.visible === false ? 'Show' : 'Hide', + label: object?.visible === false ? 'Show' + suffix : 'Hide' + suffix, disabled: locked, tooltip: lockedTooltip, - action: () => toggleObjectVisibility(uuid) + action: forEach((u) => toggleObjectVisibility(u)) }, { - label: muted ? 'Enable flow effects' : 'Disable flow effects', - action: () => - mutedFlowObjects.update((list) => (muted ? list.filter((u) => u !== uuid) : [...list, uuid])) + label: (muted ? 'Enable flow effects' : 'Disable flow effects') + suffix, + action: forEach((u) => + mutedFlowObjects.update((list) => + list.includes(u) ? list.filter((entry) => entry !== u) : [...list, u] + ) + ) }, { - label: 'Delete', + label: 'Delete' + suffix, danger: true, disabled: locked, tooltip: locked ? lockedTooltip : 'Del — a group asks first', - // select the target first so the (selection-based) delete acts on it + // when the clicked object is part of the selection, delete the whole set; + // otherwise select just this one first so the delete acts on it action: () => { - selectObject(uuid); + if (!multi) selectObject(uuid); requestDeleteSelection(); } } diff --git a/src/lib/ping.js b/src/lib/ping.js index 0e8977c7..5a74bddd 100644 --- a/src/lib/ping.js +++ b/src/lib/ping.js @@ -69,6 +69,25 @@ export function pingObject(uuid) { sendPing(top); } +/** Ping the top-center of a SET's union bounds (multi-select menu). @param {string[]} uuids */ +export function pingObjects(uuids) { + if (!uuids || uuids.length <= 1) return pingObject(uuids?.[0]); + const group = /** @type {any} */ (get(objectsGroup)); + const box = new THREE.Box3(); + let any = false; + for (const uuid of uuids) { + const object = group?.getObjectByProperty('uuid', uuid); + if (object) { + box.expandByObject(object); + any = true; + } + } + if (!any) return; + const top = box.getCenter(new THREE.Vector3()); + top.y = box.max.y; + sendPing(top); +} + /** Remote ping @param {any} data */ export function applyPing(data) { addPing({ diff --git a/src/lib/prefabs.js b/src/lib/prefabs.js index 0746ec42..5a5c880b 100644 --- a/src/lib/prefabs.js +++ b/src/lib/prefabs.js @@ -85,6 +85,42 @@ export async function savePrefab(uuid, name) { return entry; } +/** Save a MULTI-selection as ONE prefab: clone every member (baking world + * transform so layout is preserved) into a temp group, snapshot that (U-2). + * @param {string[]} uuids @param {string=} name */ +export async function savePrefabSelection(uuids, name) { + if (!uuids || uuids.length <= 1) return savePrefab(uuids?.[0], name); + const group = get(objectsGroup); + const holder = new THREE.Group(); + holder.name = name || 'Group'; + for (const uuid of uuids) { + const object = group?.getObjectByProperty('uuid', uuid); + if (!object) continue; + const clone = object.clone(true); + object.updateWorldMatrix(true, false); + clone.matrix.copy(object.matrixWorld); + clone.matrix.decompose(clone.position, clone.quaternion, clone.scale); + holder.add(clone); + } + if (!holder.children.length) return null; + const element = holder.toJSON(); + if (JSON.stringify(element).length > 5_000_000) { + showToast('Selection is too large for a prefab (>5 MB)'); + return null; + } + const entry = { + id: crypto.randomUUID(), + name: holder.name, + createdAt: Date.now(), + thumbnail: renderThumbnail(element), + element + }; + prefabs.update((list) => [...list, entry]); + await persist(); + showToast('Prefab saved to your library'); + return entry; +} + /** Add a prefab instance to the scene (fresh uuids), replicated + undoable. * @param {any} prefab @param {any=} position optional spawn point (group-local) */ export function instantiatePrefab(prefab, position) { diff --git a/tests/e2e/multiselect-menu.test.cjs b/tests/e2e/multiselect-menu.test.cjs new file mode 100644 index 00000000..34e2cce1 --- /dev/null +++ b/tests/e2e/multiselect-menu.test.cjs @@ -0,0 +1,103 @@ +// U-2: multi-select context menu — the menu is set-aware (Group selection, +// counted labels, multi delete), Group selection makes one replicated group, +// and a multi prefab captures all members. +const h = require('./helpers.cjs'); + +// create N boxes on a page, return their uuids +const makeBoxes = (page, n) => + page.evaluate((n) => { + const before = new Set(); + window.__stores.objectsGroup.subscribe((g) => g?.children.forEach((c) => before.add(c.uuid)))(); + for (let i = 0; i < n; i++) window.__stores.commandsHandler.sceneCommand('/create box'); + const ids = []; + window.__stores.objectsGroup.subscribe((g) => + g?.children.forEach((c) => { + if (!before.has(c.uuid) && c.name === 'Box') ids.push(c.uuid); + }) + )(); + return ids; + }, n); + +const topLevel = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const groups = []; + g?.children.forEach((c) => { + if (c.type === 'Group') groups.push({ uuid: c.uuid, children: c.children.length }); + }); + resolve({ total: g?.children.length ?? 0, groups }); + })(); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // --- Group selection: menu item + one replicated group with 3 children ---- + let ids = await makeBoxes(A.page, 3); + h.check(ids.length === 3, `created 3 boxes (${ids.length})`); + + const menu = await A.page.evaluate((ids) => { + window.__stores.objectActions.applySelectionSet(ids); + const items = window.__stores.objectMenu.buildObjectMenuItems(ids[1]); + return items.map((i) => i.label); + }, ids); + h.check(menu.some((l) => l.startsWith('Group selection')), 'menu offers Group selection when multi'); + h.check(menu.some((l) => l === 'Delete (3)'), `Delete label is counted (${menu.find((l) => l.startsWith('Delete'))})`); + h.check(menu.some((l) => l === 'Duplicate (3)'), 'Duplicate label is counted'); + + await A.page.evaluate(() => window.__stores.objectActions.groupSelection()); + await B.page.waitForTimeout(1200); + + let a = await topLevel(A.page); + h.check(a.groups.length === 1 && a.groups[0].children === 3, `A: one group of 3 (${JSON.stringify(a.groups)})`); + let b = await topLevel(B.page); + h.check(b.groups.length === 1 && b.groups[0].children === 3, `B: group replicated with 3 children (${JSON.stringify(b.groups)})`); + + // undo restores the flat layout in ONE step (aibatch) + await A.page.evaluate(() => window.__stores.history.undo()); + await B.page.waitForTimeout(800); + a = await topLevel(A.page); + h.check(a.groups.length === 0, `A: undo removed the group in one step (${a.groups.length} groups)`); + + // --- Multi delete: removes ALL selected, replicated ---------------------- + ids = await makeBoxes(A.page, 3); + await A.page.evaluate((ids) => { + window.__stores.objectActions.applySelectionSet(ids); + window.__stores.objectActions.requestDeleteSelection(); + }, ids); + await B.page.waitForTimeout(1000); + const goneA = await A.page.evaluate( + (ids) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => r(ids.every((u) => !g.getObjectByProperty('uuid', u))))() + ), + ids + ); + h.check(goneA === true, 'multi-delete removed every selected object on A'); + const goneB = await B.page.evaluate( + (ids) => + new Promise((r) => + window.__stores.objectsGroup.subscribe((g) => r(ids.every((u) => !g.getObjectByProperty('uuid', u))))() + ), + ids + ); + h.check(goneB === true, 'multi-delete replicated to B'); + + // --- Multi prefab: one entry whose element is a group of 3 ---------------- + ids = await makeBoxes(A.page, 3); + const prefab = await A.page.evaluate(async (ids) => { + window.__stores.objectActions.applySelectionSet(ids); + const entry = await window.__stores.prefabs.savePrefabSelection(ids, 'Trio'); + return { name: entry?.name, childCount: entry?.element?.object?.children?.length ?? 0 }; + }, ids); + h.check(prefab.name === 'Trio', `multi prefab saved (${prefab.name})`); + h.check(prefab.childCount === 3, `prefab captured all 3 members (${prefab.childCount})`); + + await h.finish(browser); +}); From 85eb77783388dfc3fc3dc51a1b38a90ea26b95b3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 16:14:44 +0300 Subject: [PATCH 05/20] [feat] ping v2 - object highlights + discoverability (roadmap 12 U-1) - ping messages gain an optional uuid: pingObject sends it, and receivers flash a highlight box around that object for the ping TTL via a new PingHighlights overlay (Box3Helper, emissive-independent, tracks the object per frame, scene-root so it never touches replicated materials) - positional pings (Alt+click / new "Ping here" viewport-menu item) carry no uuid and stay point-only - VR: pingFromController carries the hit object's uuid when the ray lands on one; new radial Tools > Ping entry fires an immediate ping from the pointer hand (partner to the existing right-stick-click ping) - applyPing passes uuid through unchanged (no peerHandler change) - e2e ping-highlights.test.cjs (two-peer: object ping highlights + clears, positional stays uuid-less, radial entry present); existing ping/ping-v2 suites green; build green, svelte-check 502/77 Co-Authored-By: Claude Opus 4.8 --- src/components/PingHighlights.svelte | 57 +++++++++++++++++++++++ src/components/Scene.svelte | 2 + src/components/menu/ViewportMenu.svelte | 6 +++ src/lib/ping.js | 18 ++++--- src/lib/vrControls.js | 22 ++++++++- src/lib/vrRadialMenu.js | 3 ++ tests/e2e/ping-highlights.test.cjs | 62 +++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 src/components/PingHighlights.svelte create mode 100644 tests/e2e/ping-highlights.test.cjs diff --git a/src/components/PingHighlights.svelte b/src/components/PingHighlights.svelte new file mode 100644 index 00000000..9891cd58 --- /dev/null +++ b/src/components/PingHighlights.svelte @@ -0,0 +1,57 @@ + + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index ec2b4762..1c2c6ec3 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -45,6 +45,7 @@ import MeasureOverlay from './MeasureOverlay.svelte'; import AnnotationPins from './AnnotationPins.svelte'; import PingMarkers from './PingMarkers.svelte'; + import PingHighlights from './PingHighlights.svelte'; import PathWaypoints from './PathWaypoints.svelte'; import LockHighlights from './LockHighlights.svelte'; import Grid from '../extensions/Grid.svelte'; @@ -888,6 +889,7 @@ + diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index a82cd749..6e50b7c4 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -12,6 +12,7 @@ import { viewportMenu, objectSearch, objectSearchEnabled } from '../../stores/appStore'; import { buildAddChildren } from '$lib/addObjects'; import { buildObjectMenuItems } from '$lib/objectMenu'; + import { sendPing } from '$lib/ping'; // Scene.svelte routes right-TAPS here (77): empty viewport → this menu with // the clicked ground point; an object under the cursor → its own context @@ -66,6 +67,11 @@ }, { label: 'Undo', disabled: !$canUndo, tooltip: 'Ctrl+Z', action: undo }, { label: 'Redo', disabled: !$canRedo, tooltip: 'Ctrl+Y', action: redo }, + { + label: 'Ping here', + tooltip: 'Everyone sees a pulse at this spot (or Alt+click anywhere)', + action: () => sendPing(menu?.point ?? [0, 0, 0]) + }, // 124: everything that acts on the CURRENT SELECTION lives in one submenu. // Fixed "Selected" label (object names get very long) + the SAME items as the // direct object right-click menu (buildObjectMenuItems), so the two are in parity. diff --git a/src/lib/ping.js b/src/lib/ping.js index 5a74bddd..e196a9fe 100644 --- a/src/lib/ping.js +++ b/src/lib/ping.js @@ -11,7 +11,7 @@ import { playPing } from './pingAudio'; export const PING_TTL = 4000; -/** @type {import('svelte/store').Writable<{id: string, peerId: string, name: string, pos: number[], ts: number, color?: string, sound?: string}[]>} */ +/** @type {import('svelte/store').Writable<{id: string, peerId: string, name: string, pos: number[], ts: number, color?: string, sound?: string, uuid?: string}[]>} */ export const pings = writable([]); // per-user ping preferences (Settings; '' color = automatic peer color) @@ -36,8 +36,10 @@ function addPing(ping) { }, PING_TTL + 100); } -/** Ping a world position, locally and for all peers @param {THREE.Vector3 | number[]} position */ -export function sendPing(position) { +/** Ping a world position, locally and for all peers. An optional `uuid` marks + * the ping as an OBJECT ping: receivers also flash a highlight around that + * object for PING_TTL (PingHighlights). @param {THREE.Vector3 | number[]} position @param {string=} uuid */ +export function sendPing(position, uuid) { const pos = Array.isArray(position) ? position : position.toArray(); /** @type {any} */ const peer = get(peers); @@ -51,10 +53,11 @@ export function sendPing(position) { pos, ts: Date.now(), color, - sound + sound, + uuid: uuid ?? undefined }; addPing(ping); - if (peer) peer.send({ type: 'ping', id: ping.id, peerId: ping.peerId, name, pos, color, sound }); + if (peer) peer.send({ type: 'ping', id: ping.id, peerId: ping.peerId, name, pos, color, sound, uuid: uuid ?? undefined }); } /** Ping the top-center of an object by uuid (used by the object context menu). @@ -66,7 +69,7 @@ export function pingObject(uuid) { const box = new THREE.Box3().setFromObject(object); const top = box.getCenter(new THREE.Vector3()); top.y = box.max.y; - sendPing(top); + sendPing(top, uuid); // carry the uuid so peers highlight the object too } /** Ping the top-center of a SET's union bounds (multi-select menu). @param {string[]} uuids */ @@ -97,6 +100,7 @@ export function applyPing(data) { pos: data.pos, ts: Date.now(), color: data.color, - sound: data.sound + sound: data.sound, + uuid: data.uuid }); } diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ba6d5a65..0c245eea 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -2157,6 +2157,13 @@ export function executeVRMenuAction(name) { vrMenuOpen.set(false); return; } + if (name === 'ping') { + // U-1: ping immediately from the POINTER hand (the menu is on the other + // hand), then close the ring; highlights the object if the ray hits one + pingFromController(controllerIndexFor(get(vrMenuHand) === 'right' ? 'left' : 'right')); + vrMenuOpen.set(false); + return; + } if (name === 'edit:granularity') { toggleFaceGranularity(); // 212: FACE <-> POLYGON return; @@ -2488,9 +2495,20 @@ export function executeVRMenuAction(name) { } else if (name === 'close') vrMenuOpen.set(false); } -/** Right-stick click: ping where the controller ray lands (87.6) @param {number} index */ +/** Right-stick click / radial Ping: ping where the controller ray lands. When + * it lands ON an object, carry that object's uuid so peers highlight it too + * (U-1). (87.6) @param {number} index */ function pingFromController(index) { - const point = pingPointFromRay(controllerRay(index), get(objectsGroup)); + const ray = controllerRay(index); + const group = get(objectsGroup); + const hits = group ? ray.intersectObjects(group.children, true) : []; + if (hits[0]) { + const top = topLevelObjectOf(hits[0].object); + sendPing(hits[0].point, top?.uuid); + hapticPulse(0.4, 60); + return; + } + const point = pingPointFromRay(ray, group); if (!point) return; sendPing(point); hapticPulse(0.4, 60); diff --git a/src/lib/vrRadialMenu.js b/src/lib/vrRadialMenu.js index 8b292927..c7f2dcd3 100644 --- a/src/lib/vrRadialMenu.js +++ b/src/lib/vrRadialMenu.js @@ -198,6 +198,9 @@ function registerBuiltins() { registerVRMenuEntry({ id: 'tool:select', group: 'tools', label: 'Select', order: 0, active: () => get(vrToolMode) === 'select' }); registerVRMenuEntry({ id: 'tool:box', group: 'tools', label: 'Box Select', order: 1, active: () => get(vrToolMode) === 'box' }); registerVRMenuEntry({ id: 'tool:draw', group: 'tools', label: 'Draw', order: 2, active: () => get(vrToolMode) === 'draw' }); + // Ping (U-1): immediate ping from the pointer hand — a discoverable partner + // to the right-stick-click ping; highlights the object if the ray hits one + registerVRMenuEntry({ id: 'ping', group: 'tools', label: 'Ping', order: 3 }); // Add ▸ — ids resolve in executeVRMenuAction's switch, which spawns the // primitive 2m ahead of the camera (spawnPrimitive) diff --git a/tests/e2e/ping-highlights.test.cjs b/tests/e2e/ping-highlights.test.cjs new file mode 100644 index 00000000..e2cc0927 --- /dev/null +++ b/tests/e2e/ping-highlights.test.cjs @@ -0,0 +1,62 @@ +// U-1: ping v2 object highlights — an object ping carries a uuid so peers flash +// a highlight box around it; positional pings carry no uuid; the VR radial +// exposes a Ping entry; the highlight clears after the ping TTL. +const h = require('./helpers.cjs'); + +const pingsOn = (page) => + page.evaluate(() => new Promise((r) => window.__stores.ping.pings.subscribe((p) => r(p))())); + +const highlightChildren = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.__stores.globalScene.subscribe((scene) => { + const grp = scene?.getObjectByName('ping-highlights'); + resolve(grp ? grp.children.length : -1); + })(); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // VR radial exposes a Ping entry under Tools + const toolIds = await A.page.evaluate(() => + window.__stores.vrRadialMenu.ringEntries('tools').map((e) => e.id) + ); + h.check(toolIds.includes('ping'), `VR Tools ring has a Ping entry (${toolIds.join(',')})`); + + // A pings an OBJECT: the message carries the uuid, B highlights it + const boxUuid = await A.page.evaluate(() => { + window.__stores.commandsHandler.sceneCommand('/create box'); + let uuid = null; + window.__stores.objectsGroup.subscribe((g) => g?.children.forEach((c) => { if (c.name === 'Box') uuid = c.uuid; }))(); + return uuid; + }); + await B.page.waitForTimeout(900); + + await A.page.evaluate((uuid) => window.__stores.ping.pingObject(uuid), boxUuid); + await B.page.waitForTimeout(700); + + const bPings = await pingsOn(B.page); + h.check(!!bPings.find((p) => p.uuid === boxUuid), 'object ping replicated to B with the uuid'); + + const bHighlights = await highlightChildren(B.page); + h.check(bHighlights >= 1, `B renders a ping highlight box for the object (${bHighlights})`); + + // a positional ping (Alt+click / Ping here) carries NO uuid → no highlight + await A.page.evaluate(() => window.__stores.ping.sendPing([2, 0, 2])); + await B.page.waitForTimeout(500); + const bPings2 = await pingsOn(B.page); + h.check(!!bPings2.find((p) => !p.uuid && p.pos[0] === 2), 'positional ping replicated without a uuid'); + + // after the TTL the object highlight clears + await B.page.waitForTimeout(4300); + const cleared = await highlightChildren(B.page); + h.check(cleared === 0, `highlight clears after the ping TTL (${cleared})`); + + await h.finish(browser); +}); From ca50bac2456af03fa58db085315d4a5a03d15ac1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 16:21:12 +0300 Subject: [PATCH 06/20] [fix] VR snap-angle cycle unified + live radial label (roadmap 12 R-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnose-first: vrControls already reads vrTeleportEnabled/vrSnapAngle LIVE each frame and the VR settings panel labels are reactive, so the behaviour did apply — but the radial snap-turn control diverged from the panel/desktop: - the radial cycled [15,30,45] (no Off) while the panel + desktop use [0,15,30,45]; unified SNAP_ANGLES to [0,15,30,45] so the radial can also turn snap-turn off and cycling is consistent everywhere - the radial's static "Turn deg" label never showed the value, so a change read as "nothing happened"; made it a live label ("Turn: 45 / Off") that re-derives on the store (VRMenu now renders function labels) - widened the menu-entry label type to string | (() => string) - also updated vr-radial-menu.test.cjs for the U-1 Tools > Ping entry - e2e vr-settings-immediacy.test.cjs; vr-radial-menu / vr-settings-panel green; build green, svelte-check 502/77 Co-Authored-By: Claude Opus 4.8 --- src/components/play/VRMenu.svelte | 2 +- src/lib/vrRadialMenu.js | 10 +++-- tests/e2e/vr-radial-menu.test.cjs | 4 +- tests/e2e/vr-settings-immediacy.test.cjs | 48 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/vr-settings-immediacy.test.cjs diff --git a/src/components/play/VRMenu.svelte b/src/components/play/VRMenu.svelte index 90f606e3..3a4d9366 100644 --- a/src/components/play/VRMenu.svelte +++ b/src/components/play/VRMenu.svelte @@ -104,7 +104,7 @@ {#if s.entry.label} string), order?: number, * ring?: string, action?: () => void, active?: () => boolean, * color?: string, closes?: boolean, visible?: () => boolean}} entry * `ring` makes it a navigation sector into that sub-ring; `color` renders the @@ -180,7 +180,9 @@ export function sectorLayout(i, count) { // ---- built-in rings ---- -const SNAP_ANGLES = [15, 30, 45]; +// unified with the VR settings panel + desktop Settings (R-2): 0 = Off, so the +// radial can also turn snap-turn off and cycling is consistent across all three +const SNAP_ANGLES = [0, 15, 30, 45]; function registerBuiltins() { // base ring (8 sectors; 109 remap): Redo/Undo swapped per user muscle @@ -229,7 +231,9 @@ function registerBuiltins() { registerVRMenuEntry({ id: 'snapangle', group: 'scene', - label: 'Turn °', + // live label so the change is visible immediately in the radial (R-2); + // the sector re-derives on $vrSnapAngle in VRMenu + label: () => 'Turn: ' + (get(vrSnapAngle) ? get(vrSnapAngle) + '°' : 'Off'), order: 9, action: () => { const next = diff --git a/tests/e2e/vr-radial-menu.test.cjs b/tests/e2e/vr-radial-menu.test.cjs index 58ceebeb..1fb9350c 100644 --- a/tests/e2e/vr-radial-menu.test.cjs +++ b/tests/e2e/vr-radial-menu.test.cjs @@ -77,8 +77,8 @@ h.run(async () => { `base ring is the 109 remap + 214 Tools submenu (${registry.root.join(',')})` ); h.check( - registry.tools.join(',') === 'tool:select,tool:box,tool:draw', - `Tools submenu lists Select / Box Select / Draw (${registry.tools.join(',')})` + registry.tools.join(',') === 'tool:select,tool:box,tool:draw,ping', + `Tools submenu lists Select / Box Select / Draw / Ping (${registry.tools.join(',')})` ); h.check( registry.system.includes('nav:mic') && diff --git a/tests/e2e/vr-settings-immediacy.test.cjs b/tests/e2e/vr-settings-immediacy.test.cjs new file mode 100644 index 00000000..b2518d39 --- /dev/null +++ b/tests/e2e/vr-settings-immediacy.test.cjs @@ -0,0 +1,48 @@ +// R-2: VR settings immediacy — the snap-turn angle cycle is unified across the +// radial, the VR settings panel, and desktop Settings ([0,15,30,45], 0 = Off), +// the radial label reflects the current value live, and toggles write the store +// that vrControls reads live each frame. Headless (state + entry label); the +// in-headset feel is the user's check. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // the radial snapangle entry has a LIVE label reflecting the store + const label0 = await A.page.evaluate(() => { + window.__stores.vrSnapAngle.set(0); + const e = window.__stores.vrRadialMenu.findMenuEntry('snapangle'); + return typeof e.label === 'function' ? e.label() : e.label; + }); + h.check(/off/i.test(label0), `radial snap label shows Off at 0 (${label0})`); + const label45 = await A.page.evaluate(() => { + window.__stores.vrSnapAngle.set(45); + const e = window.__stores.vrRadialMenu.findMenuEntry('snapangle'); + return typeof e.label === 'function' ? e.label() : e.label; + }); + h.check(/45/.test(label45), `radial snap label updates to 45 live (${label45})`); + + // cycling from the radial reaches Off (0) — the unified [0,15,30,45] cycle + const seq = await A.page.evaluate(async () => { + const e = window.__stores.vrRadialMenu.findMenuEntry('snapangle'); + const read = () => new Promise((r) => window.__stores.vrSnapAngle.subscribe((v) => r(v))()); + window.__stores.vrSnapAngle.set(45); + const out = []; + for (let i = 0; i < 4; i++) { + e.action(); + out.push(await read()); + } + return out; + }); + h.check(seq.includes(0), `radial cycle reaches Off (${seq.join(' -> ')})`); + h.check(new Set(seq).size === 4, `radial cycles through all four steps (${seq.join(',')})`); + + // teleport toggle writes the store vrControls reads live each frame + const before = await A.page.evaluate(() => new Promise((r) => window.__stores.vrTeleportEnabled.subscribe((v) => r(v))())); + await A.page.evaluate(() => window.__stores.vrControls.executeVRMenuAction('settings:teleport')); + const after = await A.page.evaluate(() => new Promise((r) => window.__stores.vrTeleportEnabled.subscribe((v) => r(v))())); + h.check(before !== after, `settings:teleport flips the live-read store (${before} -> ${after})`); + + await h.finish(browser); +}); From f7d606f748483261207c2444a1edcd7d9f292054 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 16:28:10 +0300 Subject: [PATCH 07/20] [feat] VR pointer beam + hit reticle + hover shell (roadmap 12 R-1) The VR ray was a 1px THREE.Line (0x8ab4ff) that all but vanishes on-device, and hover feedback was an emissive tint that shows nothing on MeshBasicMaterial objects. Now: - the ray is a tapered additive-blended cylinder beam that trims to the hit distance and shifts idle->hover color (light blue -> cyan) - a hit reticle disc sits at the beam tip, scaled with distance for a constant angular size, shown only when the ray lands on something - the pointed object gets a scene-root Box3Helper shell (emissive- INDEPENDENT, tracks world bounds per frame, never parented into objectsGroup so it can't leak into GLTF sync); the emissive tint stays as a secondary cue - e2e vr-ray-hover.test.cjs verifies the shell headlessly (appears, tracks bounds, scene-root, no objectsGroup leak, hides on clear); on-device beam/ reticle feel is the user's check. build green, svelte-check 499/77 Co-Authored-By: Claude Opus 4.8 --- src/lib/vrControls.js | 95 +++++++++++++++++++++++++++------ tests/e2e/vr-ray-hover.test.cjs | 44 +++++++++++++++ 2 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/vr-ray-hover.test.cjs diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 0c245eea..ac44be20 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -234,37 +234,89 @@ let scaleGrab = null; let lastMoveSent = 0; // --- clarity pack: controller rays, hover highlight, snap turn --- -/** @type {any[]} */ let rayLines = []; +/** @type {any[]} */ let rayLines = []; // fat beam meshes, one per controller +/** @type {any[]} */ let rayReticles = []; // hit-point disc at each beam's tip /** @type {any} */ let hoveredObject = null; let hoveredEmissive = 0; +/** @type {any} */ let hoverBox = null; // scene-root shell around the hovered object + +const RAY_IDLE = 0x8ab4ff; +const RAY_HOVER = 0x5fd0ff; let snapArmed = true; function ensureRayLines() { if (rayLines.length > 0 || !renderer) return; + // a tapered cylinder along -Z (spans 0..-1) reads as a visible beam on-device + // where a 1px THREE.Line vanishes; additive blending gives it a soft glow + const beamGeo = new THREE.CylinderGeometry(0.0012, 0.0035, 1, 8, 1, true); + beamGeo.rotateX(-Math.PI / 2); // axis +Y -> -Z (narrow top ends toward the tip) + beamGeo.translate(0, 0, -0.5); // span 0 (controller) .. -1 (tip) for (let i = 0; i < 2; i++) { - const geometry = new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, 0, -1) - ]); - const line = new THREE.Line( - geometry, - new THREE.LineBasicMaterial({ color: 0x8ab4ff, transparent: true, opacity: 0.7 }) + const beam = new THREE.Mesh( + beamGeo, + new THREE.MeshBasicMaterial({ + color: RAY_IDLE, + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending, + depthWrite: false + }) + ); + beam.name = 'vr-ray'; + beam.scale.z = 5; + renderer.xr.getController(i).add(beam); + rayLines.push(beam); + + // hit reticle: a small ring at the beam tip, scaled with distance so it + // keeps a constant angular size; shown only when the ray hits something + const reticle = new THREE.Mesh( + new THREE.RingGeometry(0.02, 0.03, 20), + new THREE.MeshBasicMaterial({ + color: RAY_HOVER, + transparent: true, + opacity: 0.9, + depthWrite: false, + side: THREE.DoubleSide + }) ); - line.name = 'vr-ray'; - line.scale.z = 5; - renderer.xr.getController(i).add(line); - rayLines.push(line); + reticle.name = 'vr-ray-reticle'; + reticle.visible = false; + renderer.xr.getController(i).add(reticle); + rayReticles.push(reticle); + } +} + +/** Scene-root shell around the hovered object — emissive-INDEPENDENT (a + * MeshBasicMaterial object shows no emissive tint), copies world bounds per + * frame, never parented into objectsGroup (would leak into GLTF sync). + * Exported for headless tests. @param {any} object */ +export function updateHoverBox(object) { + const scene = get(globalScene); + if (!scene) return; + if (!hoverBox) { + hoverBox = new THREE.Box3Helper(new THREE.Box3(), new THREE.Color(RAY_HOVER)); + hoverBox.name = 'vr-hover-box'; + hoverBox.material.transparent = true; + hoverBox.material.opacity = 0.7; + hoverBox.material.depthTest = false; + hoverBox.renderOrder = 997; + scene.add(hoverBox); } + if (object) { + hoverBox.visible = true; + hoverBox.box.setFromObject(object); + } else hoverBox.visible = false; } function setHovered(object) { + // the shell is the primary, emissive-independent cue; the emissive tint is a + // secondary touch for materials that support it + updateHoverBox(object); if (hoveredObject === object) return; - // restore the previous highlight if (hoveredObject?.material?.emissive) hoveredObject.material.emissive.setHex(hoveredEmissive); hoveredObject = null; if (object) { - // tint the first emissive-capable mesh in the subtree - let target = null; + /** @type {any} */ let target = null; object.traverse((/** @type {any} */ node) => { if (!target && node.material?.emissive) target = node; }); @@ -281,6 +333,7 @@ function updateRaysAndHover(presenting) { ensureRayLines(); rayLines.forEach((line) => (line.visible = presenting)); if (!presenting) { + rayReticles.forEach((r) => (r.visible = false)); setHovered(null); return; } @@ -288,15 +341,25 @@ function updateRaysAndHover(presenting) { const pointerIndex = controllerIndexFor(get(vrMenuHand) === 'right' ? 'left' : 'right'); for (let i = 0; i < 2; i++) { let distance = 5; + let hit = false; let hitObject = null; if (group) { const hits = controllerRay(i).intersectObjects(group.children, true); if (hits.length > 0) { distance = hits[0].distance; + hit = true; hitObject = topLevelObjectOf(hits[0].object); } } - if (rayLines[i]) rayLines[i].scale.z = distance; + if (rayLines[i]) { + rayLines[i].scale.z = distance; + rayLines[i].material.color.setHex(hit ? RAY_HOVER : RAY_IDLE); + } + if (rayReticles[i]) { + rayReticles[i].visible = hit; + rayReticles[i].position.z = -distance; + rayReticles[i].scale.setScalar(Math.max(distance, 0.2)); // constant angular size + } if (i === pointerIndex) setHovered(get(vrMenuOpen) ? null : hitObject); } } diff --git a/tests/e2e/vr-ray-hover.test.cjs b/tests/e2e/vr-ray-hover.test.cjs new file mode 100644 index 00000000..ea87bed2 --- /dev/null +++ b/tests/e2e/vr-ray-hover.test.cjs @@ -0,0 +1,44 @@ +// R-1: VR pointer ray + hover. Headless coverage of the emissive-independent +// hover shell (a scene-root Box3Helper that tracks the pointed object's world +// bounds and never enters objectsGroup). Read synchronously right after the +// call — the per-frame updateRaysAndHover(false) clears the hover when NOT in +// a VR session. The fat beam + hit reticle are visual; on-device feel is the +// user's manual check. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // create an object, point the hover shell at it, read state synchronously + const hovered = await A.page.evaluate(() => { + const THREE = window.__stores.THREE; + window.__stores.commandsHandler.sceneCommand('/create Box 2 2 2'); + let box = null; + let scene = null; + window.__stores.objectsGroup.subscribe((g) => g?.children.forEach((c) => { if (c.name === 'Box') box = c; }))(); + window.__stores.globalScene.subscribe((s) => (scene = s))(); + box.updateMatrixWorld(true); + window.__stores.vrControls.updateHoverBox(box); + const shell = scene.getObjectByName('vr-hover-box'); + const size = shell.box.getSize(new THREE.Vector3()); + let inObjects = false; + window.__stores.objectsGroup.subscribe((g) => g?.traverse((o) => { if (o.name === 'vr-hover-box') inObjects = true; }))(); + return { visible: shell.visible, inScene: shell.parent === scene, sizeX: +size.x.toFixed(2), inObjects }; + }); + h.check(hovered.visible === true, 'hover shell appears around the pointed object'); + h.check(hovered.inScene === true, 'hover shell lives at the scene root'); + h.check(hovered.sizeX >= 1.9 && hovered.sizeX <= 2.1, `hover shell matches the object bounds (${hovered.sizeX})`); + h.check(hovered.inObjects === false, 'hover shell never leaks into GLTF sync (not under objectsGroup)'); + + // clearing the hover hides the shell + const cleared = await A.page.evaluate(() => { + window.__stores.vrControls.updateHoverBox(null); + let scene = null; + window.__stores.globalScene.subscribe((s) => (scene = s))(); + return scene.getObjectByName('vr-hover-box').visible; + }); + h.check(cleared === false, 'hover shell hides when nothing is pointed at'); + + await h.finish(browser); +}); From d8c675ecefee3c1ec5eea543f3d04d1b9a639977 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 16:39:33 +0300 Subject: [PATCH 08/20] [feat] audio-pack install path + sound-node rolloff (roadmap 12 M-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sound node gains a rolloff param (0.5-4, default 1) wired to panner.rolloffFactor and the restart key; radius slider max 30 -> 60 for larger ambiences - packs: a default-list entry may carry a `zip` field (a self-describing .zip pack) — installDefaultPackZip fetches it and runs the existing kind-agnostic import path, so audio/SFX packs work; the Explorer pack row gains an "Install pack" action for such entries - PACKS.md documents the audio/.zip pack format + CC0 sourcing note (the actual starter-audio .zip content drops at static/library/starter-audio/) - e2e audio-pack.test.cjs: a generated audio .zip imports as an 'audio' Explorer item, install helper present, rolloff default = 1 - build green, svelte-check 498/77 (under baseline) Note: sound-node.test.cjs has a pre-existing flake (flow-palette empty-hint overlaps the Play button at the node's x:20 position) — fails identically on the base without this change, so left as-is. Co-Authored-By: Claude Opus 4.8 --- PACKS.md | 21 +++++++ src/App.svelte | 5 +- src/components/editors/Explorer.svelte | 16 +++++ src/components/editors/nodes/SoundNode.svelte | 14 ++++- src/lib/nodeCatalog.js | 2 +- src/lib/packs.js | 14 +++++ src/lib/soundRuntime.js | 3 +- tests/e2e/audio-pack.test.cjs | 58 +++++++++++++++++++ 8 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/audio-pack.test.cjs diff --git a/PACKS.md b/PACKS.md index 204c5fbb..cdd71313 100644 --- a/PACKS.md +++ b/PACKS.md @@ -13,6 +13,27 @@ There are two kinds of pack: `PACKS_BASE` constant in `src/lib/packs.js` (e.g. a jsDelivr CDN URL over a GitHub repo), or drag a `.zip` in with **+ Import pack**. +### Default-list `.zip` packs (audio / SFX / mixed) — M-2 + +The default model packs use the model-list format (one item folder per glTF). +A default-list entry can instead point at a self-describing **`.zip`** with a +`zip` field — used for **audio / SFX packs** (or any mixed-kind pack), because the +`.zip` import path is kind-agnostic (`kindOf` stores audio as `audio`, textures as +`texture`, …). Such an entry shows an **⬇ Install pack** action in the Explorer +Packs list (right-click the pack) that fetches the `.zip` and imports it locally: + +```jsonc +// static/library/libraryList.json +{ "name": "starter-audio", "title": "Starter Music & SFX", "zip": "/library/starter-audio/pack.zip", + "license": "CC0-1.0" } +``` + +Drop the `.zip` at `static/library/starter-audio/pack.zip` (a normal pack `.zip`: +`manifest.json` + `assets/…mp3|ogg|wav`). Prefer **CC0** loops/one-shots (freesound +CC0, OpenGameArt CC0). Keep each file under the 5 MB share cap so it round-trips to +peers. Installed audio items appear in the Explorer library and can be assigned to +a **Sound** node (spatial) or the **Scene music** channel (global). + ## Repo / .zip structure ``` diff --git a/src/App.svelte b/src/App.svelte index 71708952..f5b46b16 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -111,6 +111,7 @@ import('./lib/packs'), import('./lib/customNodes'), import('./lib/nodesHandler'), + import('./lib/nodeCatalog'), import('./lib/objectMenu'), import('./lib/animationPreview'), import('./lib/ai/providers'), @@ -118,8 +119,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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 } }) } }) diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 4568e4ec..11154c71 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -35,6 +35,7 @@ loadPackItems, packByName, importPackZip, + installDefaultPackZip, removeImportedPack, licenseLabel, rememberThumb @@ -259,6 +260,21 @@ function packRowMenu(e: MouseEvent, pack: any) { e.preventDefault(); const items: any[] = [{ label: 'ⓘ Attribution / license', action: () => showPackAttribution(pack) }]; + // M-2: a default-list .zip pack (e.g. audio/SFX) installs on demand + if (pack.source === 'default' && pack.zip) + items.push({ + label: '⬇ Install pack', + action: async () => { + try { + const imported = await installDefaultPackZip(pack); + packsExpanded = true; + openFolder('pack:' + imported.name); + showToast(`Installed "${imported.title}"`); + } catch (err: any) { + showToast('Install failed: ' + (err?.message ?? 'bad .zip')); + } + } + }); if (pack.source === 'imported') items.push({ label: '🗑 Delete pack', diff --git a/src/components/editors/nodes/SoundNode.svelte b/src/components/editors/nodes/SoundNode.svelte index 063be075..3824f34a 100644 --- a/src/components/editors/nodes/SoundNode.svelte +++ b/src/components/editors/nodes/SoundNode.svelte @@ -57,12 +57,24 @@ class="nodrag accent-[#ff4000]" type="range" min="1" - max="30" + max="60" step="1" value={data.radius ?? 5} on:input={(e) => setNodeData(id, { radius: +e.currentTarget.value })} /> +

+ {#if $musicBlocked && $music.playing} + click anywhere to enable audio + {/if} +
+ setMusicVolume(v)} /> + + musicLocalVolume.set(v)} /> + Mute music on this device +

+ One background track for everyone, synced to the same moment. Volume is shared; the local trim + mute affect only you. +

+ +
Show light helpers } shared music state (NOT persisted) */ +export const music = writable({ ...DEFAULT }); + +// per-device overlay (LOCAL, persisted) — your own volume trim + mute +export const musicLocalVolume = writable( + typeof localStorage !== 'undefined' ? +(localStorage.getItem('musicLocalVolume') ?? '1') : 1 +); +export const musicMuted = writable( + typeof localStorage !== 'undefined' ? localStorage.getItem('musicMuted') === 'true' : false +); + +/** whether the audio context is currently blocked by the browser autoplay policy */ +export const musicBlocked = writable(false); + +// ---- playback runtime ------------------------------------------------------ + +/** @type {any} */ let ctx = null; +/** @type {any} */ let gain = null; +/** @type {any} */ let source = null; +/** @type {AudioBuffer|null} */ let buffer = null; +let bufferHash = /** @type {string|null} */ (null); +let decoding = false; +let startedKey = ''; // hash+startedAt of the currently-playing source + +function audioContext() { + try { + if (!ctx) ctx = ensureAudioContext(); + return ctx; + } catch { + return null; + } +} + +function effectiveGain() { + const shared = get(music).volume ?? 0.8; + const local = get(musicMuted) ? 0 : get(musicLocalVolume) ?? 1; + return shared * local; +} + +/** @param {string} hash */ +async function ensureBuffer(hash) { + if (bufferHash === hash && buffer) return; + if (decoding) return; + const item = itemByHash(hash); + if (!item) { + requestAsset(hash); // pull once; the reconciler retries next tick + return; + } + decoding = true; + try { + const blob = await itemBlob(item.id); + const context = audioContext(); + if (blob && context) { + buffer = await context.decodeAudioData(await blob.arrayBuffer()); + bufferHash = hash; + } + } catch (error) { + console.log('music decode failed', error); + } + decoding = false; +} + +function stopSource() { + try { + source?.stop(); + } catch {} + source?.disconnect(); + source = null; + startedKey = ''; +} + +/** @param {any} state */ +function startSource(state) { + const context = audioContext(); + if (!context || !buffer) return; + stopSource(); + if (!gain) { + gain = context.createGain(); + gain.connect(context.destination); + } + gain.gain.value = effectiveGain(); + const src = context.createBufferSource(); + src.buffer = buffer; + src.loop = true; + src.connect(gain); + // synced phase: everyone starts inside the same loop cycle + const offset = ((Date.now() - (state.startedAt || Date.now())) / 1000) % buffer.duration; + src.start(0, Math.max(0, offset)); + source = src; + startedKey = state.hash + '|' + state.startedAt; +} + +/** Reconcile the audio graph to the shared+local state. Runs on a 500ms timer so + * it also recovers when the suspended context resumes after the first gesture. */ +function reconcile() { + const state = get(music); + const context = audioContext(); + if (context && context.state === 'suspended') { + context.resume().catch(() => {}); + musicBlocked.set(true); + } else { + musicBlocked.set(false); + } + if (!state.hash || !state.playing) { + if (source) stopSource(); + return; + } + if (bufferHash !== state.hash || !buffer) { + ensureBuffer(state.hash); + return; + } + const key = state.hash + '|' + state.startedAt; + if (!source || startedKey !== key) { + if (context && context.state === 'running') startSource(state); + return; + } + if (gain) gain.gain.value = effectiveGain(); // volume changes without a restart +} + +// ---- replication (latest-wins singleton, mirrors environment) -------------- + +/** Apply a change locally + replicate. @param {any} partial */ +export function commitMusic(partial) { + const state = { ...get(music), ...partial, changedAt: Date.now() }; + music.set(state); + reconcile(); + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'music', ...state }); +} + +/** Set (or clear) the shared track by content hash; pushes the bytes to peers. + * @param {string|null} hash @param {string} name */ +export function setMusicTrack(hash, name = '') { + commitMusic({ hash, name, playing: !!hash, startedAt: hash ? Date.now() : 0 }); + if (hash) sendAsset(hash); +} + +/** Transport: play (restarts the synced phase) / stop. @param {boolean} playing */ +export function setMusicPlaying(playing) { + commitMusic({ playing, startedAt: playing ? Date.now() : get(music).startedAt }); +} + +/** Shared volume (0..1) — adjusts gain without restarting. @param {number} v */ +export function setMusicVolume(v) { + commitMusic({ volume: Math.max(0, Math.min(1, v)) }); +} + +/** Remote/handshake apply: newest change wins (env pattern). @param {any} data */ +export function applyRemoteMusic(data) { + if ((data?.changedAt ?? 0) <= (get(music).changedAt ?? 0)) return; + music.set({ + hash: data.hash ?? null, + name: data.name ?? '', + volume: data.volume ?? 0.8, + playing: !!data.playing, + startedAt: data.startedAt ?? 0, + changedAt: data.changedAt + }); + reconcile(); +} + +/** Handshake payload (singleton: like environmentState). */ +export function musicState() { + return { type: 'music', ...get(music) }; +} + +let started = false; +export function startSceneMusic() { + if (started || typeof window === 'undefined') return; + started = true; + musicLocalVolume.subscribe((v) => { + try { + localStorage.setItem('musicLocalVolume', String(v)); + } catch {} + reconcile(); + }); + musicMuted.subscribe((v) => { + try { + localStorage.setItem('musicMuted', String(v)); + } catch {} + reconcile(); + }); + setInterval(reconcile, 500); +} + +/** test/debug view of the live music chain */ +export function musicDebug() { + const state = get(music); + const offset = buffer && state.startedAt ? ((Date.now() - state.startedAt) / 1000) % buffer.duration : 0; + return { + hash: state.hash, + playing: state.playing, + startedAt: state.startedAt, + buffered: !!buffer, + sourceLive: !!source, + effectiveGain: effectiveGain(), + offset + }; +} diff --git a/tests/e2e/scene-music.test.cjs b/tests/e2e/scene-music.test.cjs new file mode 100644 index 00000000..25067ea8 --- /dev/null +++ b/tests/e2e/scene-music.test.cjs @@ -0,0 +1,67 @@ +// M-1: scene music singleton — a shared background track syncs latest-wins as a +// separate `music` message, late joiners pull the bytes by hash and converge on +// the same startedAt (synced phase), and stop replicates. Audible output isn't +// asserted headlessly; state + graph flags are. +const h = require('./helpers.cjs'); + +// a valid 1s mono 16-bit 8kHz WAV of silence (decodeAudioData can parse it) +function wav() { + const rate = 8000, secs = 1, n = rate * secs; + const buf = Buffer.alloc(44 + n * 2); + buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVE', 8); + buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); + buf.write('data', 36); buf.writeUInt32LE(n * 2, 40); + return Array.from(new Uint8Array(buf)); +} + +const musicOf = (page) => + page.evaluate(() => new Promise((r) => window.__stores.sceneMusic.music.subscribe((m) => r(m))())); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // A stores a track in its Explorer library and sets it as the scene music + const hash = await A.page.evaluate(async (bytes) => { + const buf = new Uint8Array(bytes).buffer; + const item = await window.__stores.explorer.addItemFromBytes(buf, 'loop.wav', null); + window.__stores.sceneMusic.setMusicTrack(item.hash, 'loop.wav'); + return item.hash; + }, wav()); + h.check(!!hash, 'A stored a track and set it as scene music'); + + // the music state replicates to B (hash/playing/startedAt) + await h.eventually(() => musicOf(B.page), (m) => m.hash === hash && m.playing === true, 'music state replicated to B'); + const a = await musicOf(A.page); + const b = await musicOf(B.page); + h.check(a.startedAt === b.startedAt && a.startedAt > 0, `both peers share the startedAt phase anchor (${a.startedAt})`); + + // B pulls the bytes by hash into the Shared folder (assetShare) and decodes + await B.page.waitForTimeout(2500); + const bPulled = await B.page.evaluate( + (hash) => + new Promise((r) => + window.__stores.explorer.explorerItems.subscribe((list) => r(list.some((i) => i.hash === hash)))() + ), + hash + ); + h.check(bPulled === true, 'B pulled the shared track by hash'); + const bDebug = await B.page.evaluate(() => window.__stores.sceneMusic.musicDebug()); + h.check(bDebug.buffered === true, 'B decoded the track (playback graph built)'); + h.check(bDebug.offset >= 0 && bDebug.offset < 1.001, `B computes a synced-clock loop offset (${bDebug.offset.toFixed(3)})`); + + // local mute/volume are per-device and don't touch the shared state + await B.page.evaluate(() => window.__stores.sceneMusic.musicMuted.set(true)); + const afterMute = await musicOf(A.page); + h.check(afterMute.playing === true, "B's local mute does not stop A's shared playback"); + + // stop on A replicates to B + await A.page.evaluate(() => window.__stores.sceneMusic.setMusicPlaying(false)); + await h.eventually(() => musicOf(B.page), (m) => m.playing === false, 'stop replicated to B'); + + await h.finish(browser); +}); From 4246dcb63705c780d4aff4db5a6cf4e65df06dc3 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 16:57:24 +0300 Subject: [PATCH 10/20] [feat] N8AO ambient occlusion + view modes (roadmap 12 V-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add n8ao (N8AOPostPass) into the existing Outline EffectComposer, before the outline passes; grounds objects with soft contact AO. Enabled by the local viewMode 'shaded-ao'; quality (halfRes + mode) follows shadowQuality so there's one perf knob. Desktop-only for free (the composer unmounts in VR/play and postprocessing doesn't run in WebXR) - viewMode store (sceneStore, LOCAL, localStorage, default 'shaded-ao') + viewMode.js: 'wireframe' sets scene.overrideMaterial (local-only — a per-material sweep would leak into replicated materials); the infinite grid and shadow catcher hide in wireframe (grid gated in Scene, catcher via environment respecting wireframeActive) - Inspector Configure Scene > View gains a "Viewport - this device" 3-chip switch (Shaded / Shaded + AO / Wireframe) - e2e view-mode.test.cjs (default AO, wireframe override is local + hides the catcher, clears on exit, persists); shadows/environment/tint suites green; build green with the new dep, svelte-check 498/77 (under baseline) Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 11 ++++++ package.json | 1 + src/App.svelte | 7 ++-- src/components/Outline.svelte | 22 +++++++++++- src/components/Scene.svelte | 4 +-- src/components/menu/Inspector.svelte | 18 +++++++++- src/lib/environment.js | 3 +- src/lib/viewMode.js | 47 +++++++++++++++++++++++++ src/stores/sceneStore.js | 8 +++++ tests/e2e/view-mode.test.cjs | 52 ++++++++++++++++++++++++++++ 10 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 src/lib/viewMode.js create mode 100644 tests/e2e/view-mode.test.cjs diff --git a/package-lock.json b/package-lock.json index e620ec7f..0b89f904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "codemirror": "^6.0.2", "fflate": "^0.8.3", "invert-color": "^2.0.0", + "n8ao": "^2.0.0", "peerjs": "^1.5.4", "postprocessing": "^6.36.4", "svelte-hamburgers": "^5.0.0", @@ -3135,6 +3136,16 @@ "thenify-all": "^1.0.0" } }, + "node_modules/n8ao": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-2.0.0.tgz", + "integrity": "sha512-7oajUGXk10jJIcjGxOgjRY2X/gy5wiLOY4eOiAfFJ51ljN6Djmsg+j8HMOip+SpqV7OkwzF9VCkDRLluxVlySA==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", diff --git a/package.json b/package.json index 5bace71f..55531381 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "codemirror": "^6.0.2", "fflate": "^0.8.3", "invert-color": "^2.0.0", + "n8ao": "^2.0.0", "peerjs": "^1.5.4", "postprocessing": "^6.36.4", "svelte-hamburgers": "^5.0.0", diff --git a/src/App.svelte b/src/App.svelte index 1d228bad..5565d551 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -25,6 +25,7 @@ import { startMultiTransform } from '$lib/multiTransform' import { startLightParams } from '$lib/lightParams' import { startShadowDefaults } from '$lib/shadowDefaults' + import { startViewMode } from '$lib/viewMode' import { startAutosave } from '$lib/autosave' import { startSceneAssets } from '$lib/sceneAssets' import { startNetworkQuality } from '$lib/networkQuality' @@ -47,6 +48,7 @@ startMultiTransform() startLightParams() startShadowDefaults() + startViewMode() loadUserModules() startEnvironment() startSceneMusic() @@ -92,6 +94,7 @@ import('./lib/lightParams'), import('./lib/shadowDefaults'), import('./lib/palette'), + import('./lib/viewMode'), import('./lib/themes'), import('./lib/vrRadialMenu'), import('./lib/vrPalette'), @@ -122,8 +125,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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 } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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 } }) } }) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 886ce28e..350eab75 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -1,5 +1,6 @@
+ {/if}
{/if} diff --git a/src/components/menu/SimControls.svelte b/src/components/menu/SimControls.svelte new file mode 100644 index 00000000..6bda81c1 --- /dev/null +++ b/src/components/menu/SimControls.svelte @@ -0,0 +1,44 @@ + + +
+ {#if $simulating} + + + + {:else} + + {/if} +
diff --git a/src/components/menu/ViewportMenu.svelte b/src/components/menu/ViewportMenu.svelte index 6e50b7c4..db1219aa 100644 --- a/src/components/menu/ViewportMenu.svelte +++ b/src/components/menu/ViewportMenu.svelte @@ -3,7 +3,7 @@ import ContextMenu from '../ContextMenu.svelte'; import { undo, redo, canUndo, canRedo } from '$lib/history'; import { drawMode, toggleDrawMode } from '$lib/drawMode'; - import { simulating, remoteSimulating, toggleSimulation } from '$lib/physics'; + import { simulating, simPaused, remoteSimulating, toggleSimulation, pauseSimulation, resetSimulation } from '$lib/physics'; import { nameOf } from '$lib/lockControl'; import { snapEnabled, snapSettings, surfaceSnap } from '$lib/snapping'; import { measureMode, toggleMeasure } from '$lib/measure'; @@ -101,9 +101,22 @@ disabled: !!$remoteSimulating, tooltip: $remoteSimulating ? nameOf($remoteSimulating) + ' is simulating' - : 'Objects wired to a Mass node fall and collide; stop leaves one undo step', + : 'Dynamic objects fall and collide; stop leaves one undo step (P)', action: toggleSimulation - } + }, + ...($simulating + ? [ + { + label: $simPaused ? '▶ Resume simulation' : '⏸ Pause simulation', + action: () => pauseSimulation() + }, + { + label: '↺ Reset simulation', + tooltip: 'Restore the initial layout (no undo entry)', + action: () => resetSimulation() + } + ] + : []) ] }, { diff --git a/src/lib/commandsHandler.svelte.js b/src/lib/commandsHandler.svelte.js index adbcca89..bf1bef63 100644 --- a/src/lib/commandsHandler.svelte.js +++ b/src/lib/commandsHandler.svelte.js @@ -352,6 +352,14 @@ export async function objectParameters(data) { } else if (data.parameter == 'receiveShadow') { let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); if (mesh) mesh.receiveShadow = data.receiveShadow; + } else if (data.parameter == 'physics') { + // P-A: userData.physics is the source of truth for the Inspector-set + // body params (mode/mass/restitution/friction/collider); null = cleared + let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); + if (mesh) { + if (data.physics) mesh.userData.physics = data.physics; + else delete mesh.userData.physics; + } } else if (data.parameter == 'renderOrder') { let mesh = sceneObjects.getObjectByProperty('uuid', data.uuid); if (mesh) mesh.renderOrder = data.renderOrder; diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 1a9e9c35..4ea86e03 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -654,9 +654,30 @@ function tick(now) { } }); + // P-A: physics steps AFTER the animation pass in the SAME frame, so the + // order is deterministic: flow poses objects -> physics reads kinematic + // targets -> world.step() -> physics writes dynamic results. One slot (a + // dedicated hook, not a moduleFrameTask: those have no removal or ordering + // guarantee); physics sets it on sim start and clears it on stop. + if (postTick) { + try { + postTick(now); + } catch (error) { + console.log('post-tick hook failed', error); + } + } + requestAnimationFrame(tick); } +/** @type {((now: number) => void) | null} */ +let postTick = null; + +/** Install/clear the single post-tick hook (physics). @param {((now: number) => void) | null} fn */ +export function setPostTick(fn) { + postTick = fn; +} + export function startFlowRuntime() { if (started || typeof window === 'undefined') return; started = true; diff --git a/src/lib/history.js b/src/lib/history.js index d7578c0f..c5b75ec6 100644 --- a/src/lib/history.js +++ b/src/lib/history.js @@ -24,8 +24,8 @@ export function registerHistoryKind(kind, apply) { kindHandlers[kind] = apply; } -/** @type {import('svelte/store').Writable} */ -const undoStack = writable([]); +/** @type {import('svelte/store').Writable} exported READ-ONLY (tests/debug) */ +export const undoStack = writable([]); /** @type {import('svelte/store').Writable} */ const redoStack = writable([]); diff --git a/src/lib/multiTransform.js b/src/lib/multiTransform.js index b8d7b83c..edb2a6f8 100644 --- a/src/lib/multiTransform.js +++ b/src/lib/multiTransform.js @@ -4,6 +4,9 @@ import { globalScene, objectsGroup, TControls, selectedObjects } from '../stores import { peers } from '../stores/appStore'; import { recordTransformSet } from './history'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; +// physics is reached DYNAMICALLY: a static import would close the cycle +// multiTransform -> physics -> lockControl -> objectActions -> multiTransform +// (the vite-dev TDZ trap; Rollup tolerates it, the dev server 500s) // Multi-select transforms (phase 13). TransformControls drives ONE object, so // a hidden pivot Group sits at the selection centroid and the gizmo attaches @@ -76,6 +79,8 @@ function onDraggingChanged(/** @type {any} */ event) { .filter(Boolean) .map((member) => { suspendAnimation(member.uuid); + // P-A: mid-sim, grabbed dynamic bodies follow the pivot kinematically + import('./physics').then((m) => m.holdBody(member.uuid)); member.updateMatrixWorld(true); return { object: member, @@ -93,6 +98,7 @@ function onDraggingChanged(/** @type {any} */ event) { const set = []; for (const entry of dragMembers) { resumeAnimation(entry.object.uuid); + import('./physics').then((m) => m.releaseBody(entry.object.uuid)); const after = { pos: entry.object.position.toArray(), rot: entry.object.rotation.toArray(), diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 9b61aa5e..7f280a65 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -357,6 +357,13 @@ registerHistoryKind('props', (entry, state) => { if (peer) peer.send({ type: 'objectParameters', parameter: 'visible', uuid: entry.uuid, visible: state.visible }); } + if ('physics' in state) { + // P-A: Inspector physics edits are undoable through the same kind + if (state.physics) object.userData.physics = state.physics; + else delete object.userData.physics; + if (peer) + peer.send({ type: 'objectParameters', parameter: 'physics', uuid: entry.uuid, physics: state.physics }); + } objectsGroup.update((value) => value); return true; }); diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 1221b787..f6a4b509 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -16,7 +16,7 @@ import { applyAssetFile, answerAssetRequest } from '$lib/assetShare'; import { applyModuleMessage, moduleVersions, checkModuleVersions, sendModuleStates, applyModuleStates } from '$lib/moduleSDK'; import { applyLockRequest, applyUnlock, applyLockDenied } from '$lib/lockControl'; import { applyDrawLive, applyDrawEnd } from '$lib/drawMode'; -import { applySimulate } from '$lib/physics'; +import { applySimulate, physicsExternalMove } from '$lib/physics'; import { applyRemoteEnvironment, environmentState, envPresetsState, applyRemoteEnvPresets, dropPeerEnvPresets } from '$lib/environment'; import { applyRemoteMusic, musicState } from '$lib/sceneMusic'; import { applySessionProposal, applySessionAnswer, deferUntilShareChoice, localSceneCount } from '$lib/sessions'; @@ -155,6 +155,11 @@ export class PeerConnection { initVoiceChat(this); wire(); + // Wire the message dispatcher onto a connection. Historically only INBOUND + // conns listened for data; with the adopted-inbound channel (see below) a + // peer may send back over OUR outgoing conn, so those wire it too (P-A). + this.wireData = handleData.bind(this); + function handleConnection(conn) { // Update approval status on expected connections @@ -188,6 +193,28 @@ export class PeerConnection { conn.close(); } + // ADOPT an open inbound conn as our send channel when our outgoing one + // is dead (P-A find): the host closes the joiner's original conn before + // approving, the close is often never signaled, and the fresh reopen can + // wedge mid-ICE — leaving the JOINER unable to send ANYTHING to the host. + // DataConnections are bidirectional, and this one is provably alive. + conn.on('open', () => { + const existing = this.connections[conn.peer]; + if (existing?.open) return; // stable outgoing conn stays preferred + console.log('adopting inbound connection from ' + conn.peer + ' as the send channel'); + if (existing) { try { existing.close(); } catch {} } + this.connections[conn.peer] = conn; + conn.on('close', () => this.onConnClose(conn.peer, conn)); + this.openedPeers.add(conn.peer); + peers.update((value) => value); + this.sendHandshake(conn, conn.peer, true, this.peer.id); + }); + + this.wireData(conn); + } + + /** @this {any} @param {any} conn */ + function handleData(conn) { conn.on('data', (data) => { // console.log(data); if(data.type == 'hosts') { @@ -211,6 +238,9 @@ export class PeerConnection { changeName(data.uuid, data.name); } else if(data.type == 'move') { moveGeometry(data.uuid, data.pos, data.rot, data.scale); + // P-A: mid-sim, a peer's move stream on a dynamic body becomes a + // kinematic hold (drops back to dynamic after 250ms of silence) + physicsExternalMove(data.uuid); } else if(data.type == 'simulate') { applySimulate(data); } else if(data.type == 'environment') { @@ -384,20 +414,13 @@ export class PeerConnection { peers.update((value) => value); this.sendHandshake(conn, peerId, getobjects, id); }); + // the remote may adopt THIS conn as their send channel (bidirectional) + this.wireData(conn); } else { if (this.connections[peerId].peer == peerId) { console.log(`Peer ${peerId} is already connected or has a pending request. Connection status: ${this.connections[peerId].open}`) if(!this.connections[peerId].open) { - console.log('Restoring connection: ' + peerId); - const conn = this.peer.connect(peerId); - this.connections[peerId] = conn; - conn.on('close', () => this.onConnClose(peerId, conn)); - conn.on('open', () => { - console.log('Connection to ' + peerId + ' restored'); - this.openedPeers.add(peerId); - peers.update((value) => value); - this.sendHandshake(conn, peerId, getobjects, id); - }); + this.restoreConnection(peerId, getobjects, id, 0); } } @@ -405,6 +428,46 @@ export class PeerConnection { } } + // Post-approval reopen of OUR outgoing conn to a host. The host closed our + // original conn before approving, and real WebRTC often never signals that + // close — the first fresh connect can wedge on the stale negotiation state + // and silently never open. RETRY with a bounded backoff until one opens + // (same fix the headless agent's peerBridge needed, roadmap #10) — without + // this the JOINING peer can never send anything to the host. + /** @param {string} peerId @param {boolean} getobjects @param {string} id @param {number} attempt */ + restoreConnection(peerId, getobjects, id, attempt) { + if (this.connections[peerId]?.open) return; // an adopted inbound conn already covers this peer + console.log('Restoring connection: ' + peerId + (attempt ? ' (attempt ' + (attempt + 1) + ')' : '')); + // drop the stale never-opened conn FIRST — left in peerjs's per-peer + // bookkeeping it can wedge the fresh negotiation (offer never starts) + const stale = this.connections[peerId]; + if (stale && !stale.open) { + try { stale.close(); } catch {} + delete this.connections[peerId]; + } + const conn = this.peer.connect(peerId); + this.connections[peerId] = conn; + conn.on('close', () => this.onConnClose(peerId, conn)); + conn.on('open', () => { + console.log('Connection to ' + peerId + ' restored'); + this.openedPeers.add(peerId); + peers.update((value) => value); + this.sendHandshake(conn, peerId, getobjects, id); + }); + this.wireData(conn); + setTimeout(() => { + // still ours, still never opened -> replace the stale conn and retry + if (this.connections[peerId] !== conn || conn.open) return; + if (attempt >= 4) { + console.log('restore to ' + peerId + ' gave up after ' + (attempt + 1) + ' attempts'); + return; + } + try { conn.close(); } catch {} + delete this.connections[peerId]; + this.restoreConnection(peerId, getobjects, id, attempt + 1); + }, 4000); + } + // A peer's outgoing connection dropped. Self-heal locally: drop the dead conn // and run the FULL per-peer teardown right here, instead of relying on a // relayed 'disconnected' — that relay never reaches the last peer in a 2-peer diff --git a/src/lib/physics.js b/src/lib/physics.js index af15b0b1..28366cd7 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -4,28 +4,62 @@ import { flowNodes, flowEdges } from '../stores/flowStore'; import { objectsGroup, lockedObjects, selectedObject } from '../stores/sceneStore'; import { peers, showToast } from '../stores/appStore'; import { recordTransformSet } from './history'; -import { notifyExternalMove } from './flowRuntime'; +import { + notifyExternalMove, + setPostTick, + isAnimatedTarget, + suspendAnimation, + resumeAnimation +} from './flowRuntime'; import { nameOf } from './lockControl'; -// Physics preview: the INITIATOR runs rapier locally and broadcasts plain +// Physics preview (P-A rework): the INITIATOR runs rapier and broadcasts plain // `move` messages (~10/s per awake body) — peers just watch standard moves. -// Objects wired to a Mass node are dynamic; everything else is static -// scenery. Stopping leaves one transformSet undo entry = "restore layout". +// The step is a flowRuntime POST-TICK hook (not its own rAF), so the per-frame +// order is deterministic: flow poses objects -> physics feeds kinematic targets +// -> world.step() -> dynamic results write back. Three body classes: +// dynamic — mass param (flow node, or userData.physics mode 'dynamic') +// kinematic — flow-ANIMATED objects: the flow pose drives the body each step +// (setNextKinematicTranslation/Rotation), so rapier derives the +// platform velocity and a spinning slab flings resting boxes. +// ZERO extra peer traffic: peers run flowRuntime deterministically, +// so the kinematic object's pose already matches frame-for-frame. +// fixed — everything else (scenery). +// Objects BOTH dynamic and animated: dynamic wins — the effect is suspended for +// the run (the established "someone else owns the transform" contract) and the +// settled pose becomes the new animation base at stop. +// Drag-during-sim: a held dynamic body flips kinematic and follows the gizmo; +// release restores dynamic with a velocity estimate = throw. A PEER's drag needs +// no new message: incoming `move`s on a dynamic body become a kinematic hold +// (physicsExternalMove), released after 250ms of silence. +// Stopping leaves one transformSet undo entry = "restore layout". export const simulating = writable(false); +export const simPaused = writable(false); /** @type {import('svelte/store').Writable} peer currently simulating */ export const remoteSimulating = writable(null); /** @type {any} */ let RAPIER = null; /** @type {any} */ let world = null; -/** @type {{object: any, body: any, offset: THREE.Vector3, initialQuat: THREE.Quaternion}[]} */ +/** @typedef {{object: any, body: any, offset: THREE.Vector3, initialQuat: THREE.Quaternion, + * mode: 'dynamic'|'kinematic', hull: boolean, hold: 'user'|'external'|null, holdUntil: number, + * samples: {t: number, pos: THREE.Vector3, rot: THREE.Euler}[], + * lastWritten: {pos: THREE.Vector3, quat: THREE.Quaternion}, + * lastSent?: {pos: THREE.Vector3, quat: THREE.Quaternion}}} BodyEntry */ +/** @type {BodyEntry[]} */ let bodies = []; /** @type {{uuid: string, before: any}[]} */ let beforeStates = []; -let raf = 0; +/** @type {string[]} dynamic+animated uuids whose effects we suspended for the run */ +let suspendedForRun = []; let lastStep = 0; let lastBroadcast = 0; +let accumulator = 0; // fixed-timestep leftover (see step) const PHYSICS_TYPES = ['mass', 'bounciness', 'friction']; +const HULL_MAX_VERTS = 5000; +const MAX_LINVEL = 20; // m/s clamp on release-velocity estimates +const MAX_ANGVEL = 20; // rad/s +const EXTERNAL_HOLD_MS = 250; // peer-move silence before a held body drops /** Pre-load the wasm module (also lets tests warm the vite dep cache) */ export async function warmup() { @@ -44,12 +78,28 @@ function transformOf(object) { }; } -/** Per-object physics params from the flow graph (read once at sim start) */ -function collectParams() { - const nodes = get(flowNodes); - const edges = get(flowEdges); +/** + * Per-object physics params. Source of truth = object.userData.physics + * {mode:'auto'|'static'|'dynamic', mass, restitution, friction, collider} + * (replicates free via object sync / GLTF extras / sessions), seeded first; + * flow nodes (mass/bounciness/friction -> objectselector) OVERRIDE it, so + * existing graphs behave byte-identically. @param {any} group + */ +function collectParams(group) { /** @type {Record} */ const map = {}; + group?.children.forEach((/** @type {any} */ object) => { + const p = object.userData?.physics; + if (!p) return; + map[object.uuid] = {}; + if (p.mode === 'dynamic') map[object.uuid].mass = p.mass ?? 1; + if (p.mode === 'static') map[object.uuid].forceStatic = true; + if (p.restitution != null) map[object.uuid].restitution = p.restitution; + if (p.friction != null) map[object.uuid].friction = p.friction; + if (p.collider) map[object.uuid].collider = p.collider; + }); + const nodes = get(flowNodes); + const edges = get(flowEdges); edges.forEach((edge) => { const source = nodes.find((n) => n.id === edge.source); if (!source || !PHYSICS_TYPES.includes(source.type)) return; @@ -58,7 +108,11 @@ function collectParams() { const uuid = target.data?.selected; if (!uuid || uuid === '-None-') return; map[uuid] ??= {}; - if (source.type === 'mass') map[uuid].mass = source.data?.kg ?? 1; + // flow wiring wins over userData (incl. re-dynamicizing a 'static' object) + if (source.type === 'mass') { + map[uuid].mass = source.data?.kg ?? 1; + delete map[uuid].forceStatic; + } if (source.type === 'bounciness') map[uuid].restitution = source.data?.value ?? 0.3; if (source.type === 'friction') map[uuid].friction = source.data?.value ?? 0.5; }); @@ -78,6 +132,23 @@ export async function toggleSimulation() { await startSimulation(); } +/** Convex-hull collider desc for a single-Mesh object (scale BAKED into the + * vertices — rapier colliders don't scale). Returns null when ineligible + * (Groups, huge meshes) so the caller falls back to the box. @param {any} object */ +function hullDesc(object) { + if (!object.isMesh || !object.geometry?.attributes?.position) return null; + const position = object.geometry.attributes.position; + if (position.count > HULL_MAX_VERTS) return null; + const scaled = new Float32Array(position.count * 3); + const s = object.scale; + for (let i = 0; i < position.count; i++) { + scaled[i * 3] = position.getX(i) * s.x; + scaled[i * 3 + 1] = position.getY(i) * s.y; + scaled[i * 3 + 2] = position.getZ(i) * s.z; + } + return RAPIER.ColliderDesc.convexHull(scaled); +} + async function startSimulation() { const group = get(objectsGroup); /** @type {any} */ @@ -85,13 +156,16 @@ async function startSimulation() { if (!group) return; await warmup(); - const params = collectParams(); + const params = collectParams(group); const locked = get(lockedObjects).map((l) => l[1]); - let dynamicUuids = Object.keys(params).filter((u) => params[u].mass != null && !locked.includes(u)); + let dynamicUuids = Object.keys(params).filter( + (u) => params[u].mass != null && !params[u].forceStatic && !locked.includes(u) + ); if (dynamicUuids.length === 0) { const selected = get(selectedObject); if (selected && group.getObjectByProperty('uuid', selected.uuid) && !locked.includes(selected.uuid)) { params[selected.uuid] = { ...(params[selected.uuid] ?? {}), mass: 1 }; + delete params[selected.uuid].forceStatic; dynamicUuids = [selected.uuid]; showToast('No Mass nodes wired — simulating the selected object with mass 1'); } else { @@ -102,8 +176,12 @@ async function startSimulation() { world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); world.createCollider(RAPIER.ColliderDesc.cuboid(500, 0.1, 500).setTranslation(0, -0.1, 0)); + // NOTE for later phases: static scenery would benefit from + // ColliderDesc.trimesh (fixed bodies only) and terrain from a heightfield — + // both deferred; every collider today is a cuboid AABB or an opt-in hull. bodies = []; beforeStates = []; + suspendedForRun = []; const box = new THREE.Box3(); const size = new THREE.Vector3(); const center = new THREE.Vector3(); @@ -116,103 +194,386 @@ async function startSimulation() { box.getCenter(center); const p = params[object.uuid]; const dynamic = !!p && p.mass != null && dynamicUuids.includes(object.uuid); - const bodyDesc = (dynamic ? RAPIER.RigidBodyDesc.dynamic() : RAPIER.RigidBodyDesc.fixed()) - .setTranslation(center.x, center.y, center.z); + // flow-animated objects (not dynamic) become KINEMATIC platforms: the + // flow pose feeds the body each step so rapier derives their velocity + const kinematic = !dynamic && isAnimatedTarget(object.uuid); + // sleep OFF for dynamics: a kinematic platform moving UNDER a sleeping + // body never wakes it (existing contact, unchanged normal) — the resting + // box would ignore the spinning slab; broadcasts gate on movement instead + const bodyDesc = (dynamic + ? RAPIER.RigidBodyDesc.dynamic().setCanSleep(false) + : kinematic + ? RAPIER.RigidBodyDesc.kinematicPositionBased() + : RAPIER.RigidBodyDesc.fixed() + ).setTranslation(center.x, center.y, center.z); const body = world.createRigidBody(bodyDesc); - const colliderDesc = RAPIER.ColliderDesc.cuboid(size.x, size.y, size.z); + let colliderDesc = p?.collider === 'hull' ? hullDesc(object) : null; + if (p?.collider === 'hull' && !colliderDesc) + showToast('Convex hull unavailable for "' + (object.name || object.type) + '" — using a box'); + let usedHull = !!colliderDesc; + colliderDesc ??= RAPIER.ColliderDesc.cuboid(size.x, size.y, size.z); if (p?.restitution != null) colliderDesc.setRestitution(p.restitution); if (p?.friction != null) colliderDesc.setFriction(p.friction); if (dynamic) colliderDesc.setMass(p.mass); world.createCollider(colliderDesc, body); + // hull vertices are in the object's LOCAL frame -> the body carries the + // object's own transform (offset zero); the AABB box path keeps the + // classic center-offset bookkeeping + const entry = { + object, + body, + offset: usedHull ? new THREE.Vector3() : object.position.clone().sub(center), + initialQuat: usedHull ? new THREE.Quaternion() : object.quaternion.clone(), + mode: /** @type {'dynamic'|'kinematic'} */ (dynamic ? 'dynamic' : 'kinematic'), + hull: usedHull, + hold: /** @type {'user'|'external'|null} */ (null), + holdUntil: 0, + samples: /** @type {any[]} */ ([]), + // the pose WE last wrote — a deviation means someone else (a peer's + // move applier, undo, an AI edit) wrote the object mid-sim + lastWritten: { pos: object.position.clone(), quat: object.quaternion.clone() } + }; + if (usedHull) { + body.setTranslation({ x: object.position.x, y: object.position.y, z: object.position.z }, true); + body.setRotation( + { x: object.quaternion.x, y: object.quaternion.y, z: object.quaternion.z, w: object.quaternion.w }, + true + ); + } if (dynamic) { beforeStates.push({ uuid: object.uuid, before: transformOf(object) }); - bodies.push({ - object, - body, - offset: object.position.clone().sub(center), - initialQuat: object.quaternion.clone() - }); + // dynamic wins over an animation: suspend the effect for the run + if (isAnimatedTarget(object.uuid)) { + suspendAnimation(object.uuid); + suspendedForRun.push(object.uuid); + } + bodies.push(entry); + } else if (kinematic) { + bodies.push(entry); } }); simulating.set(true); + simPaused.set(false); if (peer) peer.send({ type: 'simulate', running: true, peerId: peer.peer.id }); lastStep = performance.now(); - raf = requestAnimationFrame(step); + accumulator = 0; + setPostTick(step); // steps at the end of every flowRuntime tick } const bodyQuat = new THREE.Quaternion(); const rotatedOffset = new THREE.Vector3(); +const targetQuat = new THREE.Quaternion(); +const invInitial = new THREE.Quaternion(); +const targetPos = new THREE.Vector3(); + +/** The body pose that matches the object's CURRENT transform (inverse of the + * dynamic write-back math). @param {any} entry */ +function kinematicTargetOf(entry) { + const { object, offset, initialQuat } = entry; + invInitial.copy(initialQuat).invert(); + targetQuat.copy(object.quaternion).multiply(invInitial); + rotatedOffset.copy(offset).applyQuaternion(targetQuat); + targetPos.copy(object.position).sub(rotatedOffset); + return { pos: targetPos.clone(), quat: targetQuat.clone() }; +} + +/** Ring buffer of recent held poses -> a release-velocity estimate. @param {any} entry @param {number} now */ +function recordHoldSample(entry, now) { + entry.samples.push({ + t: now, + pos: entry.object.position.clone(), + rot: entry.object.rotation.clone() + }); + if (entry.samples.length > 4) entry.samples.shift(); +} + +/** Flip a held body back to dynamic, imparting the estimated velocity (throw). + * @param {any} entry */ +function releaseHold(entry) { + entry.hold = null; + entry.holdUntil = 0; + entry.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true); + const s = entry.samples; + if (s.length >= 2) { + const a = s[0]; + const b = s[s.length - 1]; + const dt = Math.max((b.t - a.t) / 1000, 1e-3); + const clamp = (/** @type {number} */ v, /** @type {number} */ m) => Math.max(-m, Math.min(m, v)); + entry.body.setLinvel( + { + x: clamp((b.pos.x - a.pos.x) / dt, MAX_LINVEL), + y: clamp((b.pos.y - a.pos.y) / dt, MAX_LINVEL), + z: clamp((b.pos.z - a.pos.z) / dt, MAX_LINVEL) + }, + true + ); + entry.body.setAngvel( + { + x: clamp((b.rot.x - a.rot.x) / dt, MAX_ANGVEL), + y: clamp((b.rot.y - a.rot.y) / dt, MAX_ANGVEL), + z: clamp((b.rot.z - a.rot.z) / dt, MAX_ANGVEL) + }, + true + ); + } + entry.samples = []; +} + +/** + * The initiator grabbed a dynamic body (gizmo/multi-pivot drag): it follows the + * pointer as a kinematic until release. @param {string} uuid + */ +export function holdBody(uuid) { + const entry = bodies.find((e) => e.object.uuid === uuid && e.mode === 'dynamic'); + if (!entry || !world) return false; + entry.hold = 'user'; + entry.samples = []; + entry.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true); + return true; +} + +/** Drag ended: back to dynamic + throw velocity. @param {string} uuid */ +export function releaseBody(uuid) { + const entry = bodies.find((e) => e.object.uuid === uuid && e.hold === 'user'); + if (!entry || !world) return false; + releaseHold(entry); + return true; +} + +/** + * An incoming peer `move` landed on a body mid-sim (peerHandler calls this after + * moveGeometry applied the transform). A dynamic body becomes an EXTERNAL + * kinematic hold that follows the move stream; 250ms of silence releases it + * with a (coarse, ~10Hz-sampled) velocity estimate. Returns true if consumed. + * @param {string} uuid + */ +export function physicsExternalMove(uuid) { + if (!world || !get(simulating)) return false; + const entry = bodies.find((e) => e.object.uuid === uuid && e.mode === 'dynamic'); + if (!entry || entry.hold === 'user') return false; + if (entry.hold !== 'external') { + entry.hold = 'external'; + entry.samples = []; + entry.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true); + } + entry.holdUntil = performance.now() + EXTERNAL_HOLD_MS; + return true; +} + +const FIXED_DT = 1 / 60; +const MAX_SUBSTEPS = 8; /** @param {number} now */ function step(now) { if (!world) return; - world.timestep = Math.min((now - lastStep) / 1000, 1 / 30) || 1 / 60; + if (get(simPaused)) { + lastStep = now; // don't accumulate a giant timestep across the pause + accumulator = 0; + return; + } + // fixed-timestep accumulator: sim time tracks REAL time even when rAF is + // throttled (background/headless tabs) — the old per-frame 1/30 clamp made + // the sim run in slow motion below 30fps. Backlog is capped (spiral guard). + accumulator += Math.min((now - lastStep) / 1000, 0.25); lastStep = now; - world.step(); + const substeps = Math.min(Math.floor(accumulator / FIXED_DT), MAX_SUBSTEPS); + if (substeps === 0) return; // sub-frame remainder — step next frame + accumulator -= substeps * FIXED_DT; + if (accumulator > FIXED_DT) accumulator = 0; // drop the capped backlog + + // EXTERNAL-write detection: a dynamic body's object transform is physics- + // owned between our write-backs — if it deviated, another writer (a peer's + // move stream, undo, an AI edit) moved it. Engage/refresh a kinematic hold; + // 250ms without further writes releases it. Detecting by DEVIATION keeps the + // mechanism self-contained (the dev server can split module instances, so a + // hook called from peerHandler can land on a different physics instance). + bodies.forEach((entry) => { + if (entry.mode !== 'dynamic' || entry.hold === 'user') return; + const written = entry.lastWritten; + // component-wise compare — NOT quaternion dot: dot(q,q) = |q|^2, and + // rapier's f32 components leave the norm ~1e-9 off unit, which reads as + // a phantom deviation and pins resting bodies in a permanent hold + const q = entry.object.quaternion; + const deviated = + written.pos.distanceToSquared(entry.object.position) > 1e-10 || + Math.abs(written.quat.x - q.x) + + Math.abs(written.quat.y - q.y) + + Math.abs(written.quat.z - q.z) + + Math.abs(written.quat.w - q.w) > + 1e-6; + if (!deviated) return; + if (entry.hold !== 'external') { + entry.hold = 'external'; + entry.samples = []; + entry.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true); + } + entry.holdUntil = now + EXTERNAL_HOLD_MS; + written.pos.copy(entry.object.position); + written.quat.copy(entry.object.quaternion); + }); + + // kinematic targets are INTERPOLATED across the substeps: feeding the final + // pose only once would give the body its full velocity on substep 1 and ZERO + // on the rest — friction would alternately drag and brake a resting box and + // a spinning platform would net no fling. Slerp keeps the velocity continuous. + /** @type {{entry: BodyEntry, startPos: THREE.Vector3, startQuat: THREE.Quaternion, end: {pos: THREE.Vector3, quat: THREE.Quaternion}}[]} */ + const feeds = []; + bodies.forEach((entry) => { + if (entry.mode === 'kinematic' || entry.hold) { + const t = entry.body.translation(); + const r = entry.body.rotation(); + feeds.push({ + entry, + startPos: new THREE.Vector3(t.x, t.y, t.z), + startQuat: new THREE.Quaternion(r.x, r.y, r.z, r.w), + end: kinematicTargetOf(entry) + }); + if (entry.hold) { + recordHoldSample(entry, now); + if (entry.hold === 'external' && now > entry.holdUntil) releaseHold(entry); + } + } + }); + + world.timestep = FIXED_DT; + const stepPos = new THREE.Vector3(); + const stepQuat = new THREE.Quaternion(); + for (let k = 1; k <= substeps; k++) { + const f = k / substeps; + for (const feed of feeds) { + if (feed.entry.hold === null && feed.entry.mode !== 'kinematic') continue; // released mid-frame + stepPos.copy(feed.startPos).lerp(feed.end.pos, f); + stepQuat.copy(feed.startQuat).slerp(feed.end.quat, f); + feed.entry.body.setNextKinematicTranslation({ x: stepPos.x, y: stepPos.y, z: stepPos.z }); + feed.entry.body.setNextKinematicRotation({ x: stepQuat.x, y: stepQuat.y, z: stepQuat.z, w: stepQuat.w }); + } + world.step(); + } /** @type {any} */ const peer = get(peers); const broadcast = now - lastBroadcast > 100; if (broadcast) lastBroadcast = now; - bodies.forEach(({ object, body, offset, initialQuat }) => { + bodies.forEach((entry) => { + const { object, body, offset, initialQuat, mode, hold } = entry; + // kinematic platforms + held bodies: flow/gizmo own the object transform + if (mode === 'kinematic' || hold) return; const t = body.translation(); const r = body.rotation(); bodyQuat.set(r.x, r.y, r.z, r.w); rotatedOffset.copy(offset).applyQuaternion(bodyQuat); object.position.set(t.x + rotatedOffset.x, t.y + rotatedOffset.y, t.z + rotatedOffset.z); object.quaternion.copy(bodyQuat).multiply(initialQuat); - if (broadcast && peer && !body.isSleeping()) { - peer.send({ - type: 'move', - uuid: object.uuid, - pos: object.position.toArray(), - rot: [object.rotation.x, object.rotation.y, object.rotation.z], - scale: object.scale.toArray() - }); + entry.lastWritten.pos.copy(object.position); + entry.lastWritten.quat.copy(object.quaternion); + if (broadcast && peer) { + // sleep is disabled (kinematic-wake), so gate broadcasts on MOVEMENT: + // settled bodies stop producing traffic + const moved = + !entry.lastSent || + entry.lastSent.pos.distanceToSquared(object.position) > 1e-8 || + Math.abs(entry.lastSent.quat.dot(object.quaternion)) < 1 - 1e-8; + if (moved) { + entry.lastSent ??= { pos: new THREE.Vector3(), quat: new THREE.Quaternion() }; + entry.lastSent.pos.copy(object.position); + entry.lastSent.quat.copy(object.quaternion); + peer.send({ + type: 'move', + uuid: object.uuid, + pos: object.position.toArray(), + rot: [object.rotation.x, object.rotation.y, object.rotation.z], + scale: object.scale.toArray() + }); + } } }); objectsGroup.update((value) => value); - raf = requestAnimationFrame(step); } -export function stopSimulation() { +/** Pause/resume the stepping (world + suspensions stay alive). Peers simply see + * motion stop; the pause flag rides the existing simulate message. @param {boolean=} paused */ +export function pauseSimulation(paused) { if (!get(simulating)) return; - cancelAnimationFrame(raf); + const next = paused ?? !get(simPaused); + simPaused.set(next); + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'simulate', running: true, paused: next, peerId: peer.peer.id }); +} + +/** @param {{reset?: boolean}=} opts reset restores the initial layout (no undo entry) */ +export function stopSimulation(opts = {}) { + if (!get(simulating)) return; + setPostTick(null); // clear the hook BEFORE freeing the world /** @type {any} */ const peer = get(peers); const group = get(objectsGroup); - // final authoritative transform per dynamic body + one-step undo entry + /** @type {{uuid: string, before: any, after: any}[]} */ const items = []; beforeStates.forEach(({ uuid, before }) => { const object = group?.getObjectByProperty('uuid', uuid); if (!object) return; + if (opts.reset) { + // reset = put everything back where it started; a net no-op for history + object.position.fromArray(before.pos); + object.rotation.set(before.rot[0], before.rot[1], before.rot[2]); + object.scale.fromArray(before.scale); + } const after = transformOf(object); - if (JSON.stringify(before) !== JSON.stringify(after)) items.push({ uuid, before, after }); + if (!opts.reset && JSON.stringify(before) !== JSON.stringify(after)) + items.push({ uuid, before, after }); notifyExternalMove(uuid); if (peer) peer.send({ type: 'move', uuid: uuid, pos: after.pos, rot: after.rot, scale: after.scale }); }); if (items.length > 0) recordTransformSet(items); + // resume the effects we suspended for dynamic+animated objects: the settled + // (or reset) pose becomes the new animation base + suspendedForRun.forEach((uuid) => resumeAnimation(uuid)); + suspendedForRun = []; + world?.free?.(); world = null; bodies = []; beforeStates = []; simulating.set(false); + simPaused.set(false); if (peer) peer.send({ type: 'simulate', running: false, peerId: peer.peer.id }); if (items.length > 0) showToast('Simulation stopped — Ctrl+Z restores the initial layout'); + objectsGroup.update((value) => value); +} + +/** Reset: restore the initial layout and stop (no history entry — net no-op). */ +export function resetSimulation() { + stopSimulation({ reset: true }); } /** @param {any} data */ export function applySimulate(data) { remoteSimulating.set(data.running ? data.peerId : null); - if (data.running) showToast('▶ ' + nameOf(data.peerId) + ' is simulating physics'); + if (data.running && !data.paused) showToast('▶ ' + nameOf(data.peerId) + ' is simulating physics'); } /** @param {string} peerId */ export function physicsPeerDisconnected(peerId) { if (get(remoteSimulating) === peerId) remoteSimulating.set(null); } + +/** test/debug view of the live bodies */ +export function physicsDebug() { + return bodies.map((entry) => ({ + uuid: entry.object.uuid, + mode: entry.mode, + hull: !!entry.hull, + hold: entry.hold, + bodyType: entry.body?.bodyType?.(), + sleeping: entry.body?.isSleeping?.() ?? null, + linvel: entry.body?.linvel?.() ?? null, + angvel: entry.body?.angvel?.() ?? null, + bodyRot: entry.body?.rotation?.() ?? null + })); +} diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index 4b797072..f7b69dbe 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -180,6 +180,15 @@ export const shortcuts = [ label: `Recall camera bookmark ${slot}`, action: () => recallBookmark(slot - 1) })), + { + keys: 'P', + group: 'Scene', + label: 'Simulate physics (toggle)', + action: () => { + if (get(editingObject) || get(faceEditObject) || get(specatorMode)) return; + import('./physics').then((m) => m.toggleSimulation()); + } + }, { keys: 'V (hold)', group: 'Voice', diff --git a/tests/e2e/physics-kinematic.test.cjs b/tests/e2e/physics-kinematic.test.cjs new file mode 100644 index 00000000..2c3c2eb4 --- /dev/null +++ b/tests/e2e/physics-kinematic.test.cjs @@ -0,0 +1,180 @@ +// P-A: physics core rework — flow-animated objects become KINEMATIC bodies (a +// spinning slab flings a resting box), Inspector-only userData.physics makes an +// object dynamic with no flow nodes, hull colliders opt in per object (groups +// fall back to box), a peer's move stream holds a dynamic body kinematically, +// and pause/reset work (reset leaves no undo entry). +const h = require('./helpers.cjs'); + +const posOf = (page, uuid) => + page.evaluate( + (uuid) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', uuid); + resolve(o ? { x: o.position.x, y: o.position.y, z: o.position.z } : null); + })(); + }), + uuid + ); + +const undoDepth = (page) => + page.evaluate(() => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))())); + +h.run(async () => { + const browser = await h.launch(); + + // throwaway page warms the vite dep-optimizer for the lazy rapier import + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(4000); + await warm.ctx.close(); + } + + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + // collapse the dev-server dual-module-instance split (peerHandler's static + // physics vs the __stores dynamic one) BEFORE connecting; ids change on reload + for (const peer of [A, B]) { + await h.freshReload(peer); + peer.id = await peer.page.evaluate( + () => new Promise((r) => window.__stores.peers.subscribe((p) => r(p?.peer?.id))()) + ); + } + await h.connect(B, A); + + // --- scene: a wide slab with a SPIN effect + a dynamic box resting on it ---- + const { slabUuid, boxUuid } = await A.page.evaluate(async () => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create Box 6 0.4 6'); // slab + cmd('/create Box 1 1 1'); // rider + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const [slab, box] = group.children.slice(-2); + slab.position.set(0, 1, 0); + box.position.set(2, 1.75, 0); // resting on the slab top, 2m off-axis + // dynamic via Inspector-style userData.physics (NO flow mass node) + box.userData.physics = { mode: 'dynamic', mass: 1 }; + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + peer.send({ type: 'move', uuid: slab.uuid, pos: [0, 1, 0], rot: [0, 0, 0], scale: [1, 1, 1] }); + peer.send({ type: 'move', uuid: box.uuid, pos: [2, 1.75, 0], rot: [0, 0, 0], scale: [1, 1, 1] }); + peer.send({ type: 'objectParameters', parameter: 'physics', uuid: box.uuid, physics: box.userData.physics }); + // spin effect on the slab -> it becomes a KINEMATIC platform mid-sim + const nodes = [ + { id: 'sp1', type: 'spin', position: { x: 0, y: 0 }, data: { type: 'spin', axis: 'y', speed: 2 }, class: 'w-[150px]' }, + { id: 'sel1', type: 'objectselector', position: { x: 300, y: 0 }, data: { type: 'objectselector', selected: slab.uuid }, class: 'w-[150px]' } + ]; + const edge = { id: 'e1', source: 'sp1', target: 'sel1' }; + window.__stores.flowNodes.set(nodes); + window.__stores.flowEdges.set([edge]); + nodes.forEach((node) => peer.send({ type: 'nodecreate', node })); + peer.send({ type: 'edgecreate', edge }); + return { slabUuid: slab.uuid, boxUuid: box.uuid }; + }); + await A.page.waitForTimeout(2000); // let the flow tick adopt the slab (baseState) + + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually( + () => A.page.evaluate(() => new Promise((r) => window.__stores.physics.simulating.subscribe(r)())), + (v) => v === true, + 'simulation started on A' + ); + + // classification: slab = kinematic platform, box = dynamic (Inspector-only) + const debug = await A.page.evaluate(() => window.__stores.physics.physicsDebug()); + const slabEntry = debug.find((d) => d.uuid === slabUuid); + const boxEntry = debug.find((d) => d.uuid === boxUuid); + h.check(slabEntry?.mode === 'kinematic', `spin slab classified kinematic (${slabEntry?.mode})`); + h.check(boxEntry?.mode === 'dynamic', `userData.physics box classified dynamic with NO flow node (${boxEntry?.mode})`); + + // the spinning platform FLINGS the rider: it leaves its start (x=2,z=0) and + // ends off the slab; the displacement replicates to B via normal moves + await h.eventually( + () => posOf(A.page, boxUuid), + (p) => p && Math.hypot(p.x - 2, p.z) > 1.5, + 'spinning slab flings the resting box on A', + 15000 + ); + await h.eventually( + () => posOf(B.page, boxUuid), + (p) => p && Math.hypot(p.x - 2, p.z) > 1.5, + 'fling replicated to B', + 10000 + ); + // the slab itself keeps its flow pose on B (kinematic = zero physics traffic) + const bSlab = await posOf(B.page, slabUuid); + h.check(bSlab && Math.abs(bSlab.x) < 0.01 && Math.abs(bSlab.y - 1) < 0.01, `B slab stays at its flow pose (${JSON.stringify(bSlab)})`); + + // --- external move hold: B drags the dynamic box mid-sim ------------------- + await B.page.evaluate(async (uuid) => { + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + window.__extDrag = setInterval(() => { + peer.send({ type: 'move', uuid, pos: [0, 5, 3], rot: [0, 0, 0], scale: [1, 1, 1] }); + }, 100); + }, boxUuid); + await A.page.waitForTimeout(800); + // the hold flag flickers between identical-value sends (it only refreshes on + // a DEVIATION), so sample fast in-page rather than one racy read + const sawHold = await A.page.evaluate( + async (uuid) => { + for (let i = 0; i < 30; i++) { + const d = window.__stores.physics.physicsDebug().find((e) => e.uuid === uuid); + if (d?.hold === 'external') return true; + await new Promise((r) => setTimeout(r, 50)); + } + return false; + }, + boxUuid + ); + h.check(sawHold === true, "B's move stream holds the box kinematically on A"); + const heldPos = await posOf(A.page, boxUuid); + h.check(heldPos && Math.abs(heldPos.y - 5) < 0.5, `held box follows the peer stream (y=${heldPos?.y.toFixed(2)})`); + await B.page.evaluate(() => clearInterval(window.__extDrag)); + // after ~250ms of silence it drops back to dynamic and falls + await h.eventually( + () => posOf(A.page, boxUuid), + (p) => p && p.y < 4, + 'released box falls again after the stream stops', + 8000 + ); + + // --- pause halts, reset restores exactly with NO undo entry ---------------- + const depthBefore = await undoDepth(A.page); + await A.page.evaluate(() => window.__stores.physics.pauseSimulation(true)); + await A.page.waitForTimeout(300); + const p1 = await posOf(A.page, boxUuid); + await A.page.waitForTimeout(500); + const p2 = await posOf(A.page, boxUuid); + h.check(p1 && p2 && p1.y === p2.y, `pause halts the fall (y ${p1?.y.toFixed(2)} == ${p2?.y.toFixed(2)})`); + + await A.page.evaluate(() => window.__stores.physics.resetSimulation()); + await h.eventually( + () => posOf(A.page, boxUuid), + (p) => p && Math.abs(p.x - 2) < 0.01 && Math.abs(p.y - 1.75) < 0.01, + 'reset restores the exact start layout on A' + ); + await h.eventually( + () => posOf(B.page, boxUuid), + (p) => p && Math.abs(p.x - 2) < 0.01 && Math.abs(p.y - 1.75) < 0.01, + 'reset replicated to B' + ); + const depthAfter = await undoDepth(A.page); + h.check(depthAfter === depthBefore, `reset records no undo entry (${depthBefore} -> ${depthAfter})`); + + // --- hull colliders: opt-in per object, groups fall back ------------------- + const hullInfo = await A.page.evaluate(async () => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create Sphere 1'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const sphere = group.children[group.children.length - 1]; + sphere.position.set(-5, 4, 0); + sphere.userData.physics = { mode: 'dynamic', mass: 1, collider: 'hull' }; + window.__stores.objectActions.selectObject(sphere.uuid); + await window.__stores.physics.toggleSimulation(); + const debug = window.__stores.physics.physicsDebug(); + return debug.find((d) => d.uuid === sphere.uuid); + }); + h.check(hullInfo?.hull === true, `sphere uses a convex-hull collider (${JSON.stringify(hullInfo)})`); + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + + await h.finish(browser); +}); From 9943d055c8b336c33c0a8cb31af44ba64bfb7b5f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 22:45:57 +0300 Subject: [PATCH 13/20] [feat] module SDK input layer + api.physics (roadmap 12 K-C) - NEW src/lib/inputRuntime.js (store-only, peerApproval pattern - imports nothing from peerHandler/vrControls so no cycle): held key codes with the shortcuts text-entry guard, getInput() {codes, axes, vrButtons} snapshot, onInput down/up events, window-blur clears held keys (stuck-key guard), registerBindings(moduleId, ...) lists display-only entries in Settings > Shortcuts under a "Module: " group - input CLAIMS pause the host's own consumers while a module drives: 'keys' gates PointerLockControls WASD + the editor fly-navigation; 'locomotion' gates VR left-stick locomotion (VRControls.svelte) - vrControls publishes each hand's stick axes + trigger/squeeze into inputRuntime per frame (the safe import direction) - moduleSDK: api.registerBindings/input/onInput/claimInput/releaseInput + api.physics {simulating, isInitiator, applyImpulse} via PRIMED dynamic imports (static edges would close flowRuntime->moduleSDK / physics-> flowRuntime cycles - the vite-dev TDZ trap); physics gains isInitiator() + applyImpulse(uuid, [x,y,z]) (initiator-only) - MODULES.md documents the input API + the forward-inputs-to-initiator recipe as the blessed authoritative pattern - __stores hook gains inputRuntime + shortcutsRegistry - e2e sdk-input.test.cjs (8 checks incl. behavioral claim gating of the editor fly); build green, svelte-check 502/77 held Co-Authored-By: Claude Fable 5 --- MODULES.md | 33 ++++ src/App.svelte | 8 +- .../play/PointerLockControls.svelte | 4 + src/components/play/VRControls.svelte | 2 + src/lib/editorNavigation.js | 2 + src/lib/inputRuntime.js | 160 ++++++++++++++++++ src/lib/moduleSDK.js | 62 +++++++ src/lib/physics.js | 15 ++ src/lib/vrControls.js | 8 + tests/e2e/sdk-input.test.cjs | 78 +++++++++ 10 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 src/lib/inputRuntime.js create mode 100644 tests/e2e/sdk-input.test.cjs diff --git a/MODULES.md b/MODULES.md index 0d1de084..823966e5 100644 --- a/MODULES.md +++ b/MODULES.md @@ -147,6 +147,39 @@ api.registerStateSync({ }); ``` +### Input (K-C) + +```js +// declare bindings so they LIST in Settings ▸ Shortcuts (display-only — +// you read the keys yourself via input()/onInput) +api.registerBindings([{ label: 'Drive forward', keys: 'W' }]); + +api.registerFrameTask(() => { + const { codes, axes } = api.input(); // codes: Set<'KeyW'...> (event.code), + if (codes.has('KeyW')) drive(1); // axes: {lx,ly,rx,ry} = VR stick axes +}); +api.onInput((kind, code) => {}); // 'down'/'up' events; returns unsubscribe + +// pause the HOST's use of an input scope while your module drives: +// 'keys' — WASD camera fly + play-mode movement +// 'locomotion' — VR left-stick locomotion +api.claimInput('keys'); // ALWAYS release when your mode ends +api.releaseInput('keys'); +``` + +### Physics (P-A) + +All mutations are INITIATOR-ONLY — the peer that started the simulation steps +the world (golden rule: authoritative, never mixed with deterministic). The +blessed recipe for driven physics (pong's paddle pattern): every peer forwards +its INPUT via `api.send({op:'drive', ...})` at ~20Hz, and only the peer where +`api.physics.isInitiator()` is true applies it. + +```js +api.physics.isInitiator(); // true while THIS peer runs the sim +api.physics.applyImpulse(uuid, [0, 5, 0]); // push a dynamic body (initiator-only) +``` + ### Misc ```js diff --git a/src/App.svelte b/src/App.svelte index 5565d551..53912cd5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -26,6 +26,7 @@ import { startLightParams } from '$lib/lightParams' import { startShadowDefaults } from '$lib/shadowDefaults' import { startViewMode } from '$lib/viewMode' + import { startInputRuntime } from '$lib/inputRuntime' import { startAutosave } from '$lib/autosave' import { startSceneAssets } from '$lib/sceneAssets' import { startNetworkQuality } from '$lib/networkQuality' @@ -49,6 +50,7 @@ startLightParams() startShadowDefaults() startViewMode() + startInputRuntime() loadUserModules() startEnvironment() startSceneMusic() @@ -95,6 +97,8 @@ import('./lib/shadowDefaults'), import('./lib/palette'), import('./lib/viewMode'), + import('./lib/inputRuntime'), + import('./lib/shortcuts'), import('./lib/themes'), import('./lib/vrRadialMenu'), import('./lib/vrPalette'), @@ -125,8 +129,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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 } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, 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, 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 } }) } }) diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte index 56b1d178..f707ab89 100644 --- a/src/components/play/PointerLockControls.svelte +++ b/src/components/play/PointerLockControls.svelte @@ -5,6 +5,7 @@ import { isLocked, playerCam, editorCam, globalScene } from '../../stores/sceneStore' import { userdata, peers } from '../../stores/appStore' import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay' + import { inputClaims } from '$lib/inputRuntime' const { renderer, camera, invalidate } = useThrelte() @@ -67,6 +68,9 @@ useTask( (delta) => { + // K-C: a module claimed the keys (possession) — WASD drives IT, not the camera + if ($inputClaims.includes('keys')) return + const beforeX = $cameraParent?.position.x ?? 0 const beforeZ = $cameraParent?.position.z ?? 0 diff --git a/src/components/play/VRControls.svelte b/src/components/play/VRControls.svelte index 2b62f040..e2e5b25c 100644 --- a/src/components/play/VRControls.svelte +++ b/src/components/play/VRControls.svelte @@ -7,6 +7,7 @@ import { useThrelte, useTask } from '@threlte/core'; import { vrFlying, vrMenuOpen, vrObjectsPanelOpen, vrGrabbedHand } from '../../stores/sceneStore'; import { computeMoveOffset, worldScale, twoGripStretchActive, controllerIndexFor } from '$lib/vrControls'; + import { inputClaims } from '$lib/inputRuntime'; import { dungeonData, slideMove } from '$lib/dungeonPlay'; const { renderer, camera, scene } = useThrelte(); @@ -36,6 +37,7 @@ if ($vrMenuOpen || $vrObjectsPanelOpen) return; // menu/panel own the sticks (74/101) if ($vrGrabbedHand === 'left') return; // a left-hand grab owns its stick (100) if (twoGripStretchActive()) return; // 186: both grips + sticks stretch, not move + if ($inputClaims.includes('locomotion')) return; // K-C: a module drives instead const space = xr.getReferenceSpace(); if (!space) return; diff --git a/src/lib/editorNavigation.js b/src/lib/editorNavigation.js index 556ef34d..f4069e4a 100644 --- a/src/lib/editorNavigation.js +++ b/src/lib/editorNavigation.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { isLocked, isVRMode } from '../stores/sceneStore'; import { specatorMode } from '../stores/appStore'; +import { isClaimed } from './inputRuntime'; // WASD fly-panning for the desktop editor, Q down / E up, Shift = 3x. // Camera position and orbit target move together. Inert while typing, in @@ -55,6 +56,7 @@ const UP = new THREE.Vector3(0, 1, 0); export function updateEditorNavigation(delta, camera, controls) { if (pressed.size === 0 || !camera || !controls) return; if (get(isLocked) || get(isVRMode) || get(specatorMode)) return; + if (isClaimed('keys')) return; // K-C: a module owns WASD (possession) movement.set(0, 0, 0); camera.getWorldDirection(forward); diff --git a/src/lib/inputRuntime.js b/src/lib/inputRuntime.js new file mode 100644 index 00000000..719fff3d --- /dev/null +++ b/src/lib/inputRuntime.js @@ -0,0 +1,160 @@ +import { writable, get } from 'svelte/store'; +import { registerShortcut } from './shortcuts'; + +// Module SDK input layer (K-C). STORE-ONLY module (the peerApproval.js pattern): +// imports nothing from peerHandler/vrControls, so vrControls can feed it VR +// stick axes and the SDK can expose it without closing an import cycle (the +// vite-dev TDZ trap). Modules declare BINDINGS (listed in Settings ▸ Shortcuts), +// poll per-frame input (getInput) or subscribe to down/up events (onInput), and +// CLAIM input scopes so the host stops consuming the same keys/sticks: +// 'keys' — PointerLockControls WASD + the editor fly-navigation pause +// 'locomotion' — VR left-stick locomotion pauses (modules drive instead) + +/** currently held key codes (event.code, e.g. 'KeyW') */ +const codes = new Set(); +/** latest VR stick axes, published by vrControls each frame */ +const vrAxes = { lx: 0, ly: 0, rx: 0, ry: 0 }; +/** latest VR button states, published by vrControls each frame */ +const vrButtons = { ltrigger: false, rtrigger: false, lsqueeze: false, rsqueeze: false }; +/** @type {Set<(kind: 'down'|'up', code: string) => void>} */ +const listeners = new Set(); + +/** active claims — stores so host consumers can subscribe reactively + * @type {import('svelte/store').Writable} */ +export const inputClaims = writable([]); + +/** Is a scope currently claimed by any module? @param {'keys'|'locomotion'} scope */ +export function isClaimed(scope) { + return get(inputClaims).includes(scope); +} + +/** @param {'keys'|'locomotion'} scope */ +export function claimInput(scope) { + inputClaims.update((list) => (list.includes(scope) ? list : [...list, scope])); +} + +/** @param {'keys'|'locomotion'} scope */ +export function releaseInput(scope) { + inputClaims.update((list) => list.filter((s) => s !== scope)); +} + +/** Release everything a module might have left claimed (module error/disable). */ +export function releaseAllInput() { + inputClaims.set([]); +} + +/** + * Declare a module's key bindings: display-only entries in the shortcuts + * registry so they appear in Settings ▸ Shortcuts under the module's group. + * @param {string} moduleId + * @param {{id?: string, label: string, keys: string}[]} bindings + */ +export function registerBindings(moduleId, bindings) { + for (const binding of bindings ?? []) { + registerShortcut({ + keys: binding.keys, + group: 'Module: ' + moduleId, + label: binding.label + // no action — modules poll getInput()/subscribe onInput; listing here + // is for discoverability (the V push-to-talk precedent) + }); + } +} + +/** Per-frame input snapshot for module frame tasks. */ +export function getInput() { + return { + codes: new Set(codes), + axes: { ...vrAxes }, + vrButtons: { ...vrButtons } + }; +} + +/** Subscribe to key down/up events. Returns an unsubscribe. + * @param {(kind: 'down'|'up', code: string) => void} fn */ +export function onInput(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +/** vrControls publishes the acting hand's stick axes each frame (the safe + * import direction — vrControls -> inputRuntime). + * @param {'left'|'right'} hand @param {number} x @param {number} y */ +export function setVRAxes(hand, x, y) { + if (hand === 'left') { + vrAxes.lx = x; + vrAxes.ly = y; + } else { + vrAxes.rx = x; + vrAxes.ry = y; + } +} + +/** @param {'left'|'right'} hand @param {boolean} trigger @param {boolean} squeeze */ +export function setVRButtons(hand, trigger, squeeze) { + if (hand === 'left') { + vrButtons.ltrigger = trigger; + vrButtons.lsqueeze = squeeze; + } else { + vrButtons.rtrigger = trigger; + vrButtons.rsqueeze = squeeze; + } +} + +/** @param {KeyboardEvent} event */ +function onKeyDown(event) { + /** @type {any} */ + const target = event.target; + // never steal keys from text entry (same guard as shortcuts.js) + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable) + ) + return; + if (!codes.has(event.code)) { + codes.add(event.code); + listeners.forEach((fn) => { + try { + fn('down', event.code); + } catch (error) { + console.log('input listener failed', error); + } + }); + } +} + +/** @param {KeyboardEvent} event */ +function onKeyUp(event) { + if (codes.delete(event.code)) + listeners.forEach((fn) => { + try { + fn('up', event.code); + } catch (error) { + console.log('input listener failed', error); + } + }); +} + +function onBlur() { + // keys stuck down across a focus loss would run a possessed object forever + for (const code of [...codes]) { + codes.delete(code); + listeners.forEach((fn) => { + try { + fn('up', code); + } catch {} + }); + } +} + +let started = false; +export function startInputRuntime() { + if (started || typeof window === 'undefined') return; + started = true; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); +} diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 96ff872d..600770c2 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -62,6 +62,27 @@ const messageHandlers = {}; /** @type {Record any, applyState: (state: any) => void}>} */ const stateSyncs = {}; +// input/physics are reached via primed DYNAMIC imports: static edges would close +// cycles back into this module (flowRuntime -> moduleSDK; physics -> flowRuntime) +// — the vite-dev TDZ trap. The refs resolve at boot, long before any module +// frame task polls them; the fallbacks cover the first few frames. +/** @type {any} */ let inputRuntimeRef = null; +/** @type {any} */ let physicsRef = null; +if (typeof window !== 'undefined') { + import('./inputRuntime').then((m) => (inputRuntimeRef = m)); + import('./physics').then((m) => (physicsRef = m)); +} +function inputApi() { + return ( + inputRuntimeRef ?? { + getInput: () => ({ codes: new Set(), axes: { lx: 0, ly: 0, rx: 0, ry: 0 }, vrButtons: {} }) + } + ); +} +function physicsApi() { + return physicsRef; +} + /** @param {string} moduleId */ function makeApi(moduleId) { return { @@ -179,6 +200,47 @@ function makeApi(moduleId) { menu.registerVRMenuEntry({ ...entry, id: moduleId + ':' + entry.id }) ); }, + /** + * Declare key bindings so they list in Settings ▸ Shortcuts under this + * module (display-only — poll api.input() / subscribe api.onInput). + * @param {{label: string, keys: string}[]} bindings + */ + registerBindings(bindings) { + import('./inputRuntime').then((m) => m.registerBindings(moduleId, bindings)); + }, + /** Per-frame input snapshot: {codes: Set<'KeyW'...>, axes: {lx,ly,rx,ry}, vrButtons} */ + input() { + return inputApi().getInput(); + }, + /** Key down/up events; returns an unsubscribe. @param {(kind: 'down'|'up', code: string) => void} fn */ + onInput(fn) { + let unsub = () => {}; + import('./inputRuntime').then((m) => (unsub = m.onInput(fn))); + return () => unsub(); + }, + /** Pause the host's own use of an input scope while your module drives: + * 'keys' (WASD camera fly / play movement) or 'locomotion' (VR left stick). + * ALWAYS release (module disable/error releases everything). + * @param {'keys'|'locomotion'} scope */ + claimInput(scope) { + import('./inputRuntime').then((m) => m.claimInput(scope)); + }, + /** @param {'keys'|'locomotion'} scope */ + releaseInput(scope) { + import('./inputRuntime').then((m) => m.releaseInput(scope)); + }, + /** + * Physics access (P-A/P-B). All mutations are INITIATOR-ONLY (the peer + * that started the simulation steps the world — golden rule 8): forward + * inputs to the initiator via api.send and let IT call these. + */ + physics: { + /** true while THIS peer runs the simulation */ + simulating: () => physicsApi()?.isInitiator() ?? false, + isInitiator: () => physicsApi()?.isInitiator() ?? false, + /** push a dynamic body @param {string} uuid @param {number[]} impulse */ + applyImpulse: (uuid, impulse) => physicsApi()?.applyImpulse(uuid, impulse) ?? false + }, scene: () => get(globalScene), objectsGroup: () => get(objectsGroup), /** The assets the shared scene uses right now — [{group, name, kind, hash}] (108) */ diff --git a/src/lib/physics.js b/src/lib/physics.js index 28366cd7..182c75ce 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -552,6 +552,21 @@ export function resetSimulation() { stopSimulation({ reset: true }); } +/** Whether THIS peer is the one stepping the world (initiator-authority). */ +export function isInitiator() { + return get(simulating); +} + +/** Push a dynamic body (module SDK) — initiator-only, mid-sim. + * @param {string} uuid @param {number[]} impulse [x,y,z] */ +export function applyImpulse(uuid, impulse) { + if (!world || !get(simulating)) return false; + const entry = bodies.find((e) => e.object.uuid === uuid && e.mode === 'dynamic' && !e.hold); + if (!entry) return false; + entry.body.applyImpulse({ x: impulse[0] ?? 0, y: impulse[1] ?? 0, z: impulse[2] ?? 0 }, true); + return true; +} + /** @param {any} data */ export function applySimulate(data) { remoteSimulating.set(data.running ? data.peerId : null); diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ac44be20..7b0eaa9b 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -113,6 +113,7 @@ import { import { vrKeyboardTarget, openVRKeyboard, pressVRKey, closeVRKeyboard } from './vrKeyboard'; import { sceneCommand } from './commandsHandler.svelte'; import { sendPing } from './ping'; +import { setVRAxes, setVRButtons } from './inputRuntime'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { drawMode, toggleDrawMode, addStrokePoint, endStroke } from './drawMode'; import { setPttHeld, cycleMicMode, vrMicMode, micActive, pttActive } from './voiceChat'; @@ -2661,6 +2662,13 @@ export function updateVRControls() { if (source.handedness === 'right' && aPressed !== !!prev.a) setPttHeld(aPressed); prev.a = aPressed; + // K-C: publish this hand's stick + trigger/squeeze into the SDK input + // layer (inputRuntime is store-only; this is the safe import direction) + if (source.handedness === 'left' || source.handedness === 'right') { + setVRAxes(source.handedness, source.gamepad.axes?.[2] ?? 0, source.gamepad.axes?.[3] ?? 0); + setVRButtons(source.handedness, !!buttons[0]?.pressed, !!buttons[1]?.pressed); + } + // squeeze grabs const squeezePressed = !!buttons[1]?.pressed; gripHeld[index] = squeezePressed; // 186: track for the two-grip stretch diff --git a/tests/e2e/sdk-input.test.cjs b/tests/e2e/sdk-input.test.cjs new file mode 100644 index 00000000..b159cb2f --- /dev/null +++ b/tests/e2e/sdk-input.test.cjs @@ -0,0 +1,78 @@ +// K-C: SDK input layer — key codes visible in getInput(), module bindings list +// in the shortcuts registry, the 'keys' claim pauses the editor fly-navigation, +// and window blur clears held keys. Single page (input is local by nature). +const h = require('./helpers.cjs'); + +const camPos = (page) => + page.evaluate( + () => new Promise((r) => window.__stores.globalCamera.subscribe((c) => r(c ? [c.position.x, c.position.y, c.position.z] : null))()) + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // key codes land in the per-frame snapshot + await A.page.keyboard.down('T'); + await A.page.waitForTimeout(100); + const held = await A.page.evaluate(() => [...window.__stores.inputRuntime.getInput().codes]); + await A.page.keyboard.up('T'); + h.check(held.includes('KeyT'), `held keys appear in getInput().codes (${held.join(',')})`); + const released = await A.page.evaluate(() => window.__stores.inputRuntime.getInput().codes.size); + h.check(released === 0, `key-up clears the code (${released} held)`); + + // onInput events fire down/up + const events = await A.page.evaluate(async () => { + const log = []; + const off = window.__stores.inputRuntime.onInput((kind, code) => log.push(kind + ':' + code)); + const down = new KeyboardEvent('keydown', { code: 'KeyG', key: 'g', bubbles: true }); + const up = new KeyboardEvent('keyup', { code: 'KeyG', key: 'g', bubbles: true }); + window.dispatchEvent(down); + window.dispatchEvent(up); + off(); + return log; + }); + h.check(events.join(',') === 'down:KeyG,up:KeyG', `onInput fires down/up (${events.join(',')})`); + + // module bindings list in the shortcuts registry under the module group + const listed = await A.page.evaluate(() => { + window.__stores.inputRuntime.registerBindings('testmod', [{ label: 'Test forward', keys: 'W' }]); + return window.__stores.shortcutsRegistry.shortcuts.some( + (s) => s.group === 'Module: testmod' && s.label === 'Test forward' + ); + }); + h.check(listed === true, 'module binding listed in the shortcuts registry'); + + // the 'keys' claim pauses the editor WASD fly: W moves the camera normally, + // and stops moving it once claimed + const before = await camPos(A.page); + await A.page.mouse.click(640, 400); // focus the canvas area + await A.page.keyboard.down('W'); + await A.page.waitForTimeout(500); + await A.page.keyboard.up('W'); + const moved = await camPos(A.page); + const dist1 = Math.hypot(moved[0] - before[0], moved[1] - before[1], moved[2] - before[2]); + h.check(dist1 > 0.05, `editor fly moves the camera without a claim (${dist1.toFixed(2)})`); + + await A.page.evaluate(() => window.__stores.inputRuntime.claimInput('keys')); + const p1 = await camPos(A.page); + await A.page.keyboard.down('W'); + await A.page.waitForTimeout(500); + await A.page.keyboard.up('W'); + const p2 = await camPos(A.page); + const dist2 = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]); + h.check(dist2 < 0.01, `claimed 'keys' pauses the editor fly (${dist2.toFixed(3)})`); + await A.page.evaluate(() => window.__stores.inputRuntime.releaseInput('keys')); + const claims = await A.page.evaluate(() => new Promise((r) => window.__stores.inputRuntime.inputClaims.subscribe(r)())); + h.check(claims.length === 0, 'release clears the claim'); + + // blur clears held keys (stuck-key guard) + await A.page.keyboard.down('D'); + await A.page.waitForTimeout(100); + await A.page.evaluate(() => window.dispatchEvent(new Event('blur'))); + const afterBlur = await A.page.evaluate(() => window.__stores.inputRuntime.getInput().codes.size); + h.check(afterBlur === 0, `blur clears held keys (${afterBlur})`); + await A.page.keyboard.up('D'); + + await h.finish(browser); +}); From 770ce92b7a78e1b7caa8c82dc76d657e3434d993 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 23:00:23 +0300 Subject: [PATCH 14/20] [feat] physics joints - weld/hinge/motors + attach menu (roadmap 12 P-B) - NEW src/lib/joints.js: replicated sceneJoints defs {id, a, b, kind fixed|revolute, anchorA/B (OBJECT-local at attach), axisA, motor} - the annotations pattern (a joint references two uuids, so it can't ride either object's userData). jointcreate/jointdelete apply-local + send; late joiners pull via getjoints -> joints (retry-until-open reply); history kind 'joint' (presence-style, one-step undo/redo that also replicates); sender-side cascade when objects are deleted; sessions serialize/restore the defs (restore re-broadcasts each jointcreate) - physics.js builds rapier impulse joints at startSimulation: object-local anchors -> world -> BODY-local (box bodies start identity-rotated so a world axis is valid in both frames; hull bodies use the object frame); fixed bodies are joinable too (weld a box to scenery pins it); setJointMotor(jointId, vel, maxForce) drives a revolute (initiator-only) - object context menu: exactly two selected -> Physics > Weld together / Hinge (X/Y/Z axis of the FIRST object, anchored at the second) ; Detach joints appears when any joint touches the selection - api.physics gains setJointMotor + joints() (module SDK) - e2e physics-joints.test.cjs (14 checks: replication, undo/redo, menu, welded pair keeps its 1.5 offset through a fall on BOTH peers, motorized hinge spins its wheel, detach); physics-kinematic + physics suites still green; build green, svelte-check 502/77 held Co-Authored-By: Claude Fable 5 --- src/App.svelte | 5 +- src/lib/joints.js | 179 ++++++++++++++++++++++++++++++ src/lib/moduleSDK.js | 7 +- src/lib/objectActions.js | 4 + src/lib/objectMenu.js | 36 ++++++ src/lib/peerHandler.svelte.js | 10 ++ src/lib/physics.js | 81 +++++++++++++- src/lib/sessions.js | 5 + tests/e2e/physics-joints.test.cjs | 129 +++++++++++++++++++++ 9 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 src/lib/joints.js create mode 100644 tests/e2e/physics-joints.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 53912cd5..cd58f669 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -82,6 +82,7 @@ import('./lib/lockControl'), import('./lib/prefabs'), import('./lib/physics'), + import('./lib/joints'), import('./lib/userModules'), import('./lib/environment'), import('./lib/sceneMusic'), @@ -129,8 +130,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, 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, 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 } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, 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, 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 } }) } }) diff --git a/src/lib/joints.js b/src/lib/joints.js new file mode 100644 index 00000000..3e776ca9 --- /dev/null +++ b/src/lib/joints.js @@ -0,0 +1,179 @@ +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { objectsGroup } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { registerHistoryKind, recordEntry } from './history'; + +// Physics joints (P-B): a REPLICATED list of joint definitions between object +// pairs (the annotations pattern — a joint references two uuids, so it can't +// live on either object's userData without asymmetric death on delete). +// Anchors/axis are captured in each object's LOCAL space at attach time, so a +// weld holds the attach pose and defs survive the pair moving around between +// simulations. The sim (physics.js) builds rapier impulse joints from these at +// startSimulation; `motor` on a revolute drives it (setJointMotor, initiator- +// only). Replication: jointcreate/jointdelete apply-local + send; late joiners +// pull via getjoints -> joints (sendAnnotations retry pattern); deleting an +// object cascades jointdelete at the SENDER (receivers just apply each one). + +/** @typedef {{id: string, a: string, b: string, kind: 'fixed'|'revolute', + * anchorA: number[], anchorB: number[], axisA?: number[], + * motor?: {vel: number, maxForce: number}}} JointDef */ + +/** @type {import('svelte/store').Writable} */ +export const sceneJoints = writable([]); + +/** @param {string} uuid */ +function objectOf(uuid) { + return get(objectsGroup)?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** Insert/replace locally (no replication, no history). @param {JointDef} joint */ +function upsertLocal(joint) { + sceneJoints.update((list) => { + const index = list.findIndex((j) => j.id === joint.id); + if (index >= 0) { + const next = [...list]; + next[index] = joint; + return next; + } + return [...list, joint]; + }); +} + +/** @param {string} id */ +function removeLocal(id) { + sceneJoints.update((list) => list.filter((j) => j.id !== id)); +} + +/** + * Create a joint between two objects at their CURRENT relative pose and + * replicate it. Weld anchor = the midpoint between the two origins; hinge + * anchor = B's origin (put the wheel where it should spin, then hinge) with + * the axis = A's chosen LOCAL axis at the current pose. + * @param {'fixed'|'revolute'} kind @param {string} aUuid @param {string} bUuid + * @param {'x'|'y'|'z'=} axis @param {{vel: number, maxForce: number}=} motor + * @returns {JointDef | null} + */ +export function createJoint(kind, aUuid, bUuid, axis, motor) { + const a = objectOf(aUuid); + const b = objectOf(bUuid); + if (!a || !b || aUuid === bUuid) { + showToast('Select two objects to attach'); + return null; + } + a.updateWorldMatrix(true, false); + b.updateWorldMatrix(true, false); + const aPos = a.getWorldPosition(new THREE.Vector3()); + const bPos = b.getWorldPosition(new THREE.Vector3()); + const anchorWorld = kind === 'revolute' ? bPos.clone() : aPos.clone().lerp(bPos, 0.5); + /** @type {JointDef} */ + const joint = { + id: crypto.randomUUID().slice(0, 8), + a: aUuid, + b: bUuid, + kind, + anchorA: a.worldToLocal(anchorWorld.clone()).toArray(), + anchorB: b.worldToLocal(anchorWorld.clone()).toArray(), + ...(kind === 'revolute' + ? { axisA: axis === 'x' ? [1, 0, 0] : axis === 'z' ? [0, 0, 1] : [0, 1, 0] } + : {}), + ...(motor ? { motor } : {}) + }; + upsertLocal(joint); + recordEntry({ kind: 'joint', joint, before: { present: false }, after: { present: true } }); + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'jointcreate', joint }); + return joint; +} + +/** Delete one joint (replicated + undoable). @param {string} id */ +export function deleteJoint(id) { + const joint = get(sceneJoints).find((j) => j.id === id); + if (!joint) return; + removeLocal(id); + recordEntry({ kind: 'joint', joint, before: { present: true }, after: { present: false } }); + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'jointdelete', id }); +} + +/** Every joint touching any of these objects. @param {string[]} uuids */ +export function jointsFor(uuids) { + return get(sceneJoints).filter((j) => uuids.includes(j.a) || uuids.includes(j.b)); +} + +/** Detach = delete every joint touching the given objects (menu action). + * @param {string[]} uuids @returns {number} */ +export function detachJoints(uuids) { + const hits = jointsFor(uuids); + hits.forEach((j) => deleteJoint(j.id)); + return hits.length; +} + +/** SENDER-side cascade when objects are deleted: each jointdelete replicates, + * receivers only apply (golden rule 1). @param {string[]} uuids */ +export function cascadeJointDeletes(uuids) { + jointsFor(uuids).forEach((j) => deleteJoint(j.id)); +} + +// ---- receive side ----------------------------------------------------------- + +/** @param {any} data */ +export function applyJointCreate(data) { + if (data?.joint?.id) upsertLocal(data.joint); +} + +/** @param {any} data */ +export function applyJointDelete(data) { + if (data?.id) removeLocal(data.id); +} + +/** Merge a late-joiner snapshot by id. @param {any[]} list */ +export function applyJointsSnapshot(list) { + if (!Array.isArray(list)) return; + list.forEach((joint) => joint?.id && upsertLocal(joint)); +} + +/** Full-state reply on handshake (sendAnnotations retry pattern). @param {string} peerId */ +export function sendJoints(peerId, attempt = 0) { + /** @type {any} */ + const peer = get(peers); + if (!peer) return; + const list = get(sceneJoints); + if (list.length === 0) return; + const conn = peer.connections[peerId]; + if (!conn || !conn.open) { + if (attempt < 20) setTimeout(() => sendJoints(peerId, attempt + 1), 500); + return; + } + conn.send({ type: 'joints', joints: list }); +} + +// ---- persistence (sessions/.tpscene) --------------------------------------- + +export function jointsSnapshot() { + return get(sceneJoints); +} + +/** @param {any[]} list */ +export function jointsRestore(list) { + sceneJoints.set(Array.isArray(list) ? list : []); +} + +// ---- undo/redo -------------------------------------------------------------- + +// presence-style entries (mirrors create/delete): replaying re-applies locally +// AND replicates, so peers follow the undo like any other edit +registerHistoryKind('joint', (entry, state) => { + /** @type {any} */ + const peer = get(peers); + if (state.present) { + upsertLocal(entry.joint); + if (peer) peer.send({ type: 'jointcreate', joint: entry.joint }); + } else { + removeLocal(entry.joint.id); + if (peer) peer.send({ type: 'jointdelete', id: entry.joint.id }); + } + return true; +}); diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 600770c2..dbf9409b 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -239,7 +239,12 @@ function makeApi(moduleId) { simulating: () => physicsApi()?.isInitiator() ?? false, isInitiator: () => physicsApi()?.isInitiator() ?? false, /** push a dynamic body @param {string} uuid @param {number[]} impulse */ - applyImpulse: (uuid, impulse) => physicsApi()?.applyImpulse(uuid, impulse) ?? false + applyImpulse: (uuid, impulse) => physicsApi()?.applyImpulse(uuid, impulse) ?? false, + /** drive a revolute joint's motor (P-B) @param {string} jointId @param {number} vel @param {number=} maxForce */ + setJointMotor: (jointId, vel, maxForce) => + physicsApi()?.setJointMotor(jointId, vel, maxForce) ?? false, + /** the replicated joint defs @returns {Promise} */ + joints: () => import('./joints').then((m) => m.jointsSnapshot()) }, scene: () => get(globalScene), objectsGroup: () => get(objectsGroup), diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 7f280a65..cab05832 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { dropToSurface } from './snapping'; import { recordTransform, recordEntry, recordObjectPresence, registerHistoryKind, beginHistoryBatch, endHistoryBatch } from './history'; +import { cascadeJointDeletes } from './joints'; import { createGroup } from './geometries.svelte'; import { suspendAnimation, resumeAnimation } from './flowRuntime'; import { @@ -195,6 +196,9 @@ export function selectionUuids() { export function deleteObjectsByUuid(uuids) { if (!uuids.length) return 0; deselectObject(); + // P-B: cascade joint deletes at the SENDER (each jointdelete replicates; + // receivers only apply) + cascadeJointDeletes(uuids); /** @type {any} */ const peer = get(peers); const group = get(objectsGroup); diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index a4339287..482c3404 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -15,6 +15,7 @@ import { selectionUuids } from './objectActions'; import { requestControl, nameOf } from './lockControl'; +import { createJoint, detachJoints, jointsFor } from './joints'; import { savePrefab, savePrefabSelection } from './prefabs'; import { enterEditMode } from './meshEdit'; import { addAnnotation } from './annotationsHandler'; @@ -78,6 +79,41 @@ export function buildObjectMenuItems(uuid, opts = {}) { } ] : []), + // P-B: joints — attach exactly TWO objects (weld holds the pose, a hinge + // spins about the FIRST-clicked object's chosen local axis, anchored at + // the second object's origin); Detach appears when any joint touches this + ...(targets.length === 2 || jointsFor(targets).length + ? [ + { + label: 'Physics', + children: [ + ...(targets.length === 2 + ? [ + { + label: 'Weld together', + tooltip: 'Fixed joint — they move as one during simulations', + action: () => createJoint('fixed', targets[0], targets[1]) + }, + ...['x', 'y', 'z'].map((axis) => ({ + label: `Hinge (${axis.toUpperCase()} axis)`, + tooltip: 'Revolute joint about the first object’s local ' + axis.toUpperCase() + ' axis, anchored at the second object', + action: () => createJoint('revolute', targets[0], targets[1], /** @type {'x'|'y'|'z'} */ (axis)) + })) + ] + : []), + ...(jointsFor(targets).length + ? [ + { + label: `Detach joints (${jointsFor(targets).length})`, + danger: true, + action: () => detachJoints(targets) + } + ] + : []) + ] + } + ] + : []), ...(isGroup ? [ { diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index f6a4b509..5a9bfa66 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -17,6 +17,7 @@ import { applyModuleMessage, moduleVersions, checkModuleVersions, sendModuleStat import { applyLockRequest, applyUnlock, applyLockDenied } from '$lib/lockControl'; import { applyDrawLive, applyDrawEnd } from '$lib/drawMode'; import { applySimulate, physicsExternalMove } from '$lib/physics'; +import { applyJointCreate, applyJointDelete, applyJointsSnapshot, sendJoints } from '$lib/joints'; import { applyRemoteEnvironment, environmentState, envPresetsState, applyRemoteEnvPresets, dropPeerEnvPresets } from '$lib/environment'; import { applyRemoteMusic, musicState } from '$lib/sceneMusic'; import { applySessionProposal, applySessionAnswer, deferUntilShareChoice, localSceneCount } from '$lib/sessions'; @@ -243,6 +244,14 @@ export class PeerConnection { physicsExternalMove(data.uuid); } else if(data.type == 'simulate') { applySimulate(data); + } else if(data.type == 'jointcreate') { + applyJointCreate(data); + } else if(data.type == 'jointdelete') { + applyJointDelete(data); + } else if(data.type == 'joints') { + applyJointsSnapshot(data.joints); + } else if(data.type == 'getjoints') { + sendJoints(data.sender); } else if(data.type == 'environment') { applyRemoteEnvironment(data); } else if(data.type == 'music') { @@ -393,6 +402,7 @@ export class PeerConnection { if (getobjects) conn.send({type: 'getobjects', sender: this.peer.id, count: localSceneCount()}) if (getobjects) conn.send({type: 'getnodes', sender: this.peer.id}) if (getobjects) conn.send({type: 'getannotations', sender: this.peer.id}) + if (getobjects) conn.send({type: 'getjoints', sender: this.peer.id}) if (getobjects) conn.send({type: 'getmodulestate', sender: this.peer.id}) if (getobjects) conn.send({type: 'getnodedefs', sender: this.peer.id}) // join them into the voice mesh if our mic is live diff --git a/src/lib/physics.js b/src/lib/physics.js index 182c75ce..006b1daf 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -12,6 +12,7 @@ import { resumeAnimation } from './flowRuntime'; import { nameOf } from './lockControl'; +import { sceneJoints } from './joints'; // Physics preview (P-A rework): the INITIATOR runs rapier and broadcasts plain // `move` messages (~10/s per awake body) — peers just watch standard moves. @@ -49,6 +50,10 @@ export const remoteSimulating = writable(null); /** @type {BodyEntry[]} */ let bodies = []; /** @type {{uuid: string, before: any}[]} */ let beforeStates = []; +/** @type {Map} joint def id -> live rapier impulse joint (P-B) */ +let liveJoints = new Map(); +/** @type {Map} uuid -> FIXED body (scenery a joint may pin to) */ +let fixedBodies = new Map(); /** @type {string[]} dynamic+animated uuids whose effects we suspended for the run */ let suspendedForRun = []; let lastStep = 0; @@ -182,11 +187,12 @@ async function startSimulation() { bodies = []; beforeStates = []; suspendedForRun = []; + fixedBodies = new Map(); const box = new THREE.Box3(); const size = new THREE.Vector3(); const center = new THREE.Vector3(); - group.children.forEach((object) => { + group.children.forEach((/** @type {any} */ object) => { box.setFromObject(object); if (!isFinite(box.min.x)) return; // lights/empties box.getSize(size).multiplyScalar(0.5); @@ -250,9 +256,69 @@ async function startSimulation() { bodies.push(entry); } else if (kinematic) { bodies.push(entry); + } else { + fixedBodies.set(object.uuid, body); // a joint may pin something to it (P-B) } }); + // P-B: build rapier impulse joints from the replicated defs. Anchors are + // OBJECT-local at attach time -> world -> BODY-local. Box bodies start with + // IDENTITY rotation (initialQuat compensates), so body-local = world - center + // and a world axis is valid in both bodies' frames; hull bodies carry the + // object rotation, so their body frame IS the object frame (scale baked). + liveJoints = new Map(); + const anchorWorld = new THREE.Vector3(); + const axisWorld = new THREE.Vector3(); + get(sceneJoints).forEach((def) => { + const entryA = bodies.find((e) => e.object.uuid === def.a); + const entryB = bodies.find((e) => e.object.uuid === def.b); + // jointed scenery is possible: fall back to any body we created — bodies[] + // only holds dynamic+kinematic, so look the object up for a fixed body too + const bodyA = entryA?.body ?? fixedBodies.get(def.a); + const bodyB = entryB?.body ?? fixedBodies.get(def.b); + if (!bodyA || !bodyB) return; + const objA = get(objectsGroup)?.getObjectByProperty('uuid', def.a); + const objB = get(objectsGroup)?.getObjectByProperty('uuid', def.b); + if (!objA || !objB) return; + /** body-local point for one side @param {any} obj @param {any} entry @param {number[]} anchorLocal @param {any} body */ + const bodyLocal = (obj, entry, anchorLocal, body) => { + obj.updateWorldMatrix(true, false); + obj.localToWorld(anchorWorld.fromArray(anchorLocal)); + if (entry?.hull) { + // hull body frame = object frame: rotate the world offset back + const t = body.translation(); + return anchorWorld + .sub(new THREE.Vector3(t.x, t.y, t.z)) + .applyQuaternion(obj.getWorldQuaternion(new THREE.Quaternion()).invert()) + .toArray(); + } + const t = body.translation(); + return [anchorWorld.x - t.x, anchorWorld.y - t.y, anchorWorld.z - t.z]; + }; + const a1 = bodyLocal(objA, entryA, def.anchorA, bodyA); + const a2 = bodyLocal(objB, entryB, def.anchorB, bodyB); + let data; + if (def.kind === 'revolute') { + axisWorld.fromArray(def.axisA ?? [0, 1, 0]).applyQuaternion(objA.quaternion).normalize(); + data = RAPIER.JointData.revolute( + { x: a1[0], y: a1[1], z: a1[2] }, + { x: a2[0], y: a2[1], z: a2[2] }, + { x: axisWorld.x, y: axisWorld.y, z: axisWorld.z } + ); + } else { + data = RAPIER.JointData.fixed( + { x: a1[0], y: a1[1], z: a1[2] }, + { w: 1, x: 0, y: 0, z: 0 }, + { x: a2[0], y: a2[1], z: a2[2] }, + { w: 1, x: 0, y: 0, z: 0 } + ); + } + const joint = world.createImpulseJoint(data, bodyA, bodyB, true); + if (def.kind === 'revolute' && def.motor) + joint.configureMotorVelocity(def.motor.vel ?? 0, def.motor.maxForce ?? 100); + liveJoints.set(def.id, joint); + }); + simulating.set(true); simPaused.set(false); if (peer) peer.send({ type: 'simulate', running: true, peerId: peer.peer.id }); @@ -540,6 +606,8 @@ export function stopSimulation(opts = {}) { world = null; bodies = []; beforeStates = []; + liveJoints = new Map(); + fixedBodies = new Map(); simulating.set(false); simPaused.set(false); if (peer) peer.send({ type: 'simulate', running: false, peerId: peer.peer.id }); @@ -557,6 +625,17 @@ export function isInitiator() { return get(simulating); } +/** Drive a revolute joint's motor mid-sim (P-B) — initiator-only (only the + * stepping peer holds live joints; forward inputs to it, pong-paddle pattern). + * @param {string} jointId @param {number} vel rad/s @param {number=} maxForce */ +export function setJointMotor(jointId, vel, maxForce = 100) { + if (!world || !get(simulating)) return false; + const joint = liveJoints.get(jointId); + if (!joint?.configureMotorVelocity) return false; + joint.configureMotorVelocity(vel, maxForce); + return true; +} + /** Push a dynamic body (module SDK) — initiator-only, mid-sim. * @param {string} uuid @param {number[]} impulse [x,y,z] */ export function applyImpulse(uuid, impulse) { diff --git a/src/lib/sessions.js b/src/lib/sessions.js index e17bf4ea..205195a2 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -7,6 +7,7 @@ import { parkAnimatedAtBase } from './flowRuntime'; import { peers, showToast } from '../stores/appStore'; import { recordObjectPresence } from './history'; import { annotationsSnapshot, annotationsRestore } from './autosave'; +import { jointsSnapshot, jointsRestore } from './joints'; import { sceneCommand, sendObjects } from './commandsHandler.svelte'; import { nameOf } from './lockControl'; import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; @@ -100,6 +101,7 @@ export function buildSessionPayload(name) { nodes: get(flowNodes).map(serializeNode), edges: get(flowEdges).map(serializeEdge), annotations: annotationsSnapshot(), + joints: jointsSnapshot(), camera: camera ? { position: camera.position.toArray(), target: controls?.target?.toArray() ?? [0, 0, 0] } : null @@ -340,6 +342,9 @@ export async function applySession(payload) { } } annotationsRestore(payload.annotations ?? []); + // P-B: joints restore locally + replicate each def (receivers only apply) + jointsRestore(payload.joints ?? []); + if (peer) for (const joint of payload.joints ?? []) peer.send({ type: 'jointcreate', joint }); /** @type {any} */ const camera = get(globalCamera); /** @type {any} */ diff --git a/tests/e2e/physics-joints.test.cjs b/tests/e2e/physics-joints.test.cjs new file mode 100644 index 00000000..511eac27 --- /dev/null +++ b/tests/e2e/physics-joints.test.cjs @@ -0,0 +1,129 @@ +// P-B: joints — replicated sceneJoints defs (weld/hinge), one-step undo, the +// Physics context-menu entry, welded pairs staying rigid through a fall, and a +// motorized hinge spinning its wheel. Two-peer + rapier prewarm. +const h = require('./helpers.cjs'); + +const jointsOf = (page) => + page.evaluate(() => new Promise((r) => window.__stores.joints.sceneJoints.subscribe((j) => r(j))())); + +const posOf = (page, uuid) => + page.evaluate( + (uuid) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', uuid); + resolve(o ? { x: o.position.x, y: o.position.y, z: o.position.z } : null); + })(); + }), + uuid + ); + +h.run(async () => { + const browser = await h.launch(); + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(4000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // --- create a pair, weld them, replicate + undo/redo ---------------------- + const { aUuid, bUuid, jointId } = await A.page.evaluate(async () => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create Box 1 1 1'); + cmd('/create Box 1 1 1'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const [a, b] = group.children.slice(-2); + a.position.set(0, 4, 0); + b.position.set(1.5, 4, 0); + a.userData.physics = { mode: 'dynamic', mass: 1 }; + b.userData.physics = { mode: 'dynamic', mass: 1 }; + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + peer.send({ type: 'move', uuid: a.uuid, pos: [0, 4, 0], rot: [0, 0, 0], scale: [1, 1, 1] }); + peer.send({ type: 'move', uuid: b.uuid, pos: [1.5, 4, 0], rot: [0, 0, 0], scale: [1, 1, 1] }); + const joint = window.__stores.joints.createJoint('fixed', a.uuid, b.uuid); + return { aUuid: a.uuid, bUuid: b.uuid, jointId: joint.id }; + }); + h.check(!!jointId, 'weld joint created on A'); + + await h.eventually( + () => jointsOf(B.page), + (j) => j.some((d) => d.id === jointId && d.kind === 'fixed'), + 'jointcreate replicated to B' + ); + + // one-step undo removes the def everywhere; redo restores it + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => jointsOf(B.page), (j) => !j.some((d) => d.id === jointId), 'undo removes the joint on B too'); + await A.page.evaluate(() => window.__stores.history.redo()); + await h.eventually(() => jointsOf(B.page), (j) => j.some((d) => d.id === jointId), 'redo restores the joint on B'); + + // --- context menu: two selected -> Physics submenu ------------------------- + const menu = await A.page.evaluate( + ([a, b]) => { + window.__stores.objectActions.applySelectionSet([a, b]); + const items = window.__stores.objectMenu.buildObjectMenuItems(a); + const physics = items.find((i) => i.label === 'Physics'); + return physics ? physics.children.map((c) => c.label) : null; + }, + [aUuid, bUuid] + ); + h.check(!!menu && menu.some((l) => l.startsWith('Weld')), `menu offers Weld (${menu?.join(' | ')})`); + h.check(!!menu && menu.filter((l) => l.startsWith('Hinge')).length === 3, 'menu offers Hinge X/Y/Z'); + h.check(!!menu && menu.some((l) => l.startsWith('Detach joints')), 'menu offers Detach for the jointed pair'); + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + + // --- welded pair falls as ONE rigid piece ---------------------------------- + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => posOf(A.page, aUuid), (p) => p && p.y < 0.6, 'welded pair fell to the ground on A', 15000); + await A.page.waitForTimeout(800); // settle + const [pa, pb] = [await posOf(A.page, aUuid), await posOf(A.page, bUuid)]; + const gap = Math.hypot(pb.x - pa.x, pb.y - pa.y, pb.z - pa.z); + h.check(Math.abs(gap - 1.5) < 0.15, `weld keeps the 1.5 offset through the fall (${gap.toFixed(2)})`); + await h.eventually( + () => Promise.all([posOf(B.page, aUuid), posOf(B.page, bUuid)]).then(([a2, b2]) => (a2 && b2 ? Math.hypot(b2.x - a2.x, b2.y - a2.y, b2.z - a2.z) : null)), + (g) => g != null && Math.abs(g - 1.5) < 0.2, + 'welded fall replicated to B with the offset intact', + 10000 + ); + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + + // --- motorized hinge spins its wheel --------------------------------------- + const wheel = await A.page.evaluate(async () => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create Box 0.6 0.6 0.6'); // anchor block + cmd('/create Cylinder 0.5 0.5 0.3'); // wheel + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const [anchor, wheel] = group.children.slice(-2); + anchor.position.set(-4, 2, 0); + wheel.position.set(-4, 1, 0); + anchor.userData.physics = { mode: 'dynamic', mass: 5 }; + wheel.userData.physics = { mode: 'dynamic', mass: 1 }; + const joint = window.__stores.joints.createJoint('revolute', anchor.uuid, wheel.uuid, 'y'); + return { wheelUuid: wheel.uuid, jointId: joint.id }; + }); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await A.page.waitForTimeout(600); + const motorOk = await A.page.evaluate((id) => window.__stores.physics.setJointMotor(id, 6, 200), wheel.jointId); + h.check(motorOk === true, 'setJointMotor accepts the live joint'); + await A.page.waitForTimeout(1200); + const spin = await A.page.evaluate((uuid) => { + const d = window.__stores.physics.physicsDebug().find((e) => e.uuid === uuid); + return d?.angvel ? Math.hypot(d.angvel.x, d.angvel.y, d.angvel.z) : 0; + }, wheel.wheelUuid); + h.check(spin > 1, `motor spins the hinged wheel (|angvel| = ${spin.toFixed(2)})`); + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + + // --- detach deletes the defs (replicated) ---------------------------------- + const removed = await A.page.evaluate( + ([a, b]) => window.__stores.joints.detachJoints([a, b]), + [aUuid, bUuid] + ); + h.check(removed === 1, `detach removed the weld (${removed})`); + await h.eventually(() => jointsOf(B.page), (j) => !j.some((d) => d.id === jointId), 'detach replicated to B'); + + await h.finish(browser); +}); From be508cf1d98e12c8fc3cdda0f05c4de9eccfa68a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 23:08:56 +0300 Subject: [PATCH 15/20] [feat] possess + avatar controller module (roadmap 12 K-D) - NEW src/lib/possess.js: possess(uuid, {camera chase|orbit|none, speed, turnSpeed}) drives any object with tank controls (W/S along its facing, A/D turn, arrows too, VR left stick via the K-C axes) on a dedicated rAF. Possessing SELECTS the object (selection = lock, peers see the usual highlight and refusal applies for peer-locked objects), suspends its flow effects, claims the 'keys' + 'locomotion' input scopes (editor fly, play WASD and VR stick locomotion pause), and broadcasts throttled moves at the multiTransform/physics 10Hz rate. Release (Esc / release()) restores the camera, records ONE transform undo entry, sends a final move and re-bases the animation (notifyExternalMove). Mid-sim the move stream rides P-A's external-hold, so a possessed dynamic body follows kinematically. Entering VR auto-releases. - chase camera: eased third-person follow behind the facing (OrbitControls disabled for the ride, restored + retargeted on release); orbit mode just retargets the controls each frame - NEW src/modules/avatar/module.js (core set): registerBindings entries, a "Possess selected object" menu, VR Edit-ring Possess entry - moduleSDK: api.possess/releasePossess/selectedUuid (primed dynamic import - a static edge would cycle via objectActions -> flowRuntime -> moduleSDK); MODULES.md documents it - e2e possess.test.cjs (11 checks, two-peer: claims, lock on B, drive/turn, replication, Esc + one-step undo); build green, svelte-check 501/77 Co-Authored-By: Claude Fable 5 --- MODULES.md | 11 +++ src/App.svelte | 7 +- src/lib/joints.js | 1 + src/lib/moduleSDK.js | 21 +++- src/lib/possess.js | 185 +++++++++++++++++++++++++++++++++++ src/modules/avatar/module.js | 41 ++++++++ src/modules/index.js | 3 +- tests/e2e/possess.test.cjs | 99 +++++++++++++++++++ 8 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 src/lib/possess.js create mode 100644 src/modules/avatar/module.js create mode 100644 tests/e2e/possess.test.cjs diff --git a/MODULES.md b/MODULES.md index 823966e5..c8f0fa88 100644 --- a/MODULES.md +++ b/MODULES.md @@ -180,6 +180,17 @@ api.physics.isInitiator(); // true while THIS peer runs the sim api.physics.applyImpulse(uuid, [0, 5, 0]); // push a dynamic body (initiator-only) ``` +### Possess (K-D) + +```js +// drive any object with WASD/arrows or the VR left stick (tank controls), +// chase camera by default; Esc releases. Possessing SELECTS the object +// (selection = lock), suspends its flow effects, and records ONE undo entry +// on release. Movement replicates as plain throttled moves. +api.possess(api.selectedUuid(), { camera: 'chase' }); // 'chase'|'orbit'|'none' +api.releasePossess(); +``` + ### Misc ```js diff --git a/src/App.svelte b/src/App.svelte index cd58f669..3bbebfc7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -27,6 +27,7 @@ import { startShadowDefaults } from '$lib/shadowDefaults' import { startViewMode } from '$lib/viewMode' import { startInputRuntime } from '$lib/inputRuntime' + import { startPossess } from '$lib/possess' import { startAutosave } from '$lib/autosave' import { startSceneAssets } from '$lib/sceneAssets' import { startNetworkQuality } from '$lib/networkQuality' @@ -51,6 +52,7 @@ startShadowDefaults() startViewMode() startInputRuntime() + startPossess() loadUserModules() startEnvironment() startSceneMusic() @@ -83,6 +85,7 @@ import('./lib/prefabs'), import('./lib/physics'), import('./lib/joints'), + import('./lib/possess'), import('./lib/userModules'), import('./lib/environment'), import('./lib/sceneMusic'), @@ -130,8 +133,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, 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, 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 } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, 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, 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 } }) } }) diff --git a/src/lib/joints.js b/src/lib/joints.js index 3e776ca9..77bc0602 100644 --- a/src/lib/joints.js +++ b/src/lib/joints.js @@ -1,3 +1,4 @@ +// @ts-ignore - no bundled three type declarations (project-wide) import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { objectsGroup } from '../stores/sceneStore'; diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index dbf9409b..d3316203 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; -import { globalScene, objectsGroup } from '../stores/sceneStore'; +import { globalScene, objectsGroup, selectedObject } from '../stores/sceneStore'; import { peers, showToast, modulesOpen } from '../stores/appStore'; import { syncedAnimations } from '../stores/flowStore'; import { customGeometryBuilders } from './customGeometries'; @@ -68,9 +68,11 @@ const stateSyncs = {}; // frame task polls them; the fallbacks cover the first few frames. /** @type {any} */ let inputRuntimeRef = null; /** @type {any} */ let physicsRef = null; +/** @type {any} */ let possessRef = null; if (typeof window !== 'undefined') { import('./inputRuntime').then((m) => (inputRuntimeRef = m)); import('./physics').then((m) => (physicsRef = m)); + import('./possess').then((m) => (possessRef = m)); } function inputApi() { return ( @@ -246,6 +248,23 @@ function makeApi(moduleId) { /** the replicated joint defs @returns {Promise} */ joints: () => import('./joints').then((m) => m.jointsSnapshot()) }, + /** + * Possess an object: WASD/arrows or the VR left stick drive it (tank + * controls) with a follow camera; Esc releases. Possessing selects it + * (selection = lock), suspends its flow effects and records ONE undo + * entry on release. @param {string} uuid + * @param {{camera?: 'chase'|'orbit'|'none', speed?: number, turnSpeed?: number}=} opts + */ + possess(uuid, opts) { + return possessRef?.possess(uuid, opts) ?? false; + }, + releasePossess() { + possessRef?.release(); + }, + /** the currently selected object's uuid (undefined when none) */ + selectedUuid() { + return /** @type {any} */ (get(selectedObject))?.uuid; + }, scene: () => get(globalScene), objectsGroup: () => get(objectsGroup), /** The assets the shared scene uses right now — [{group, name, kind, hash}] (108) */ diff --git a/src/lib/possess.js b/src/lib/possess.js new file mode 100644 index 00000000..beebb544 --- /dev/null +++ b/src/lib/possess.js @@ -0,0 +1,185 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { objectsGroup, lockedObjects, orbitControls, globalCamera, isVRMode } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { recordTransform } from './history'; +import { suspendAnimation, resumeAnimation, notifyExternalMove } from './flowRuntime'; +import { selectObject } from './objectActions'; +import { getInput, onInput, claimInput, releaseInput } from './inputRuntime'; +import { nameOf } from './lockControl'; + +// Possess (K-D): drive any scene object with WASD/arrows (or the VR left +// stick) with a follow camera — the host primitive behind the avatar module +// and the car. Possessing = SELECTING (our selection IS our lock, so peers see +// the usual lock highlight and can't grab the object). Tank controls: W/S move +// along the object's facing, A/D turn — no pointer-lock needed in the editor. +// Movement replicates as plain throttled `move`s (~10Hz, the multiTransform / +// physics rate) + one final move and ONE transform history entry on release. +// Mid-sim, the move stream lands on P-A's external-hold path — a possessed +// dynamic body follows kinematically and drops back to dynamic on release. + +/** @type {import('svelte/store').Writable} possessed uuid */ +export const possessed = writable(null); + +/** @type {any} */ let state = null; // {uuid, opts, before, raf, lastSent, offEsc, camSave} + +const forward = new THREE.Vector3(); +const camTarget = new THREE.Vector3(); +const camOffset = new THREE.Vector3(); + +/** + * Take control of an object. @param {string} uuid + * @param {{camera?: 'chase'|'orbit'|'none', speed?: number, turnSpeed?: number}=} opts + * @returns {boolean} + */ +export function possess(uuid, opts = {}) { + if (state) release(); + const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (!object) { + showToast('Nothing to possess — select an object first'); + return false; + } + const lock = get(lockedObjects).find((entry) => entry[1] === uuid); + if (lock) { + showToast('Locked by ' + nameOf(lock[0]) + ' — ask them to release it'); + return false; + } + selectObject(uuid); // selection = our lock; peers see the usual highlight + suspendAnimation(uuid); // we own the transform for the ride + claimInput('keys'); // pause editor fly / play WASD + claimInput('locomotion'); // pause VR left-stick locomotion + + /** @type {any} */ + const controls = get(orbitControls); + state = { + uuid, + opts: { camera: 'chase', speed: 4, turnSpeed: 2.5, ...opts }, + before: { + pos: object.position.toArray(), + rot: object.rotation.toArray(), + scale: object.scale.toArray() + }, + camSave: controls ? { enabled: controls.enabled, target: controls.target.clone() } : null, + lastSent: 0, + raf: 0, + lastTime: performance.now(), + offEsc: onInput((kind, code) => { + if (kind === 'down' && code === 'Escape') release(); + }) + }; + if (state.opts.camera === 'chase' && controls) controls.enabled = false; + possessed.set(uuid); + state.raf = requestAnimationFrame(tick); + return true; +} + +/** Release control: restore the camera, record ONE undo entry, final move. */ +export function release() { + if (!state) return; + cancelAnimationFrame(state.raf); + state.offEsc?.(); + releaseInput('keys'); + releaseInput('locomotion'); + const { uuid, before, camSave } = state; + /** @type {any} */ + const controls = get(orbitControls); + if (controls && camSave) { + controls.enabled = camSave.enabled; + // keep looking where the ride ended rather than snapping back + const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (object) controls.target.copy(object.position); + } + const object = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + if (object) { + const after = { + pos: object.position.toArray(), + rot: object.rotation.toArray(), + scale: object.scale.toArray() + }; + if (JSON.stringify(before) !== JSON.stringify(after)) + recordTransform({ uuid, before, after }); + /** @type {any} */ + const peer = get(peers); + if (peer) + peer.send({ type: 'move', uuid, pos: after.pos, rot: after.rot, scale: after.scale }); + } + resumeAnimation(uuid); + notifyExternalMove(uuid); // the ride's end pose becomes the animation base + state = null; + possessed.set(null); +} + +/** @param {number} now */ +function tick(now) { + if (!state) return; + const object = get(objectsGroup)?.getObjectByProperty('uuid', state.uuid); + if (!object) { + release(); // deleted out from under us + return; + } + const dt = Math.min((now - state.lastTime) / 1000, 0.1); + state.lastTime = now; + const { codes, axes } = getInput(); + const { speed, turnSpeed, camera } = state.opts; + + // tank controls: W/S (or Up/Down, or VR left-stick y) drive, A/D (Left/ + // Right, stick x) turn — deadzone the stick like computeMoveOffset does + const dead = (/** @type {number} */ v) => (Math.abs(v) > 0.15 ? v : 0); + let drive = + (codes.has('KeyW') || codes.has('ArrowUp') ? 1 : 0) - + (codes.has('KeyS') || codes.has('ArrowDown') ? 1 : 0) - + dead(axes.ly); + let turn = + (codes.has('KeyA') || codes.has('ArrowLeft') ? 1 : 0) - + (codes.has('KeyD') || codes.has('ArrowRight') ? 1 : 0) - + dead(axes.lx); + drive = Math.max(-1, Math.min(1, drive)); + turn = Math.max(-1, Math.min(1, turn)); + + if (drive || turn) { + object.rotation.y += turn * turnSpeed * dt; + forward.set(0, 0, -1).applyQuaternion(object.quaternion); + object.position.addScaledVector(forward, drive * speed * dt); + object.updateMatrix(); + if (now - state.lastSent > 100) { + state.lastSent = now; + /** @type {any} */ + const peer = get(peers); + if (peer) + peer.send({ + type: 'move', + uuid: object.uuid, + pos: object.position.toArray(), + rot: [object.rotation.x, object.rotation.y, object.rotation.z], + scale: object.scale.toArray() + }); + } + } + + /** @type {any} */ + const controls = get(orbitControls); + /** @type {any} */ + const cam = get(globalCamera); + if (camera === 'chase' && cam) { + // third-person: sit behind + above the facing, ease in, look at the object + camOffset.set(0, 2.2, 4.5).applyQuaternion(object.quaternion); + camTarget.copy(object.position).add(camOffset); + cam.position.lerp(camTarget, 1 - Math.pow(0.001, dt)); + cam.lookAt(object.position); + } else if (camera === 'orbit' && controls) { + controls.target.copy(object.position); // free orbit/zoom around the ride + } + + state.raf = requestAnimationFrame(tick); +} + +let started = false; +export function startPossess() { + if (started || typeof window === 'undefined') return; + started = true; + // entering VR mid-possession: the editor camera modes make no sense there + isVRMode.subscribe((vr) => { + if (vr) release(); + }); +} diff --git a/src/modules/avatar/module.js b/src/modules/avatar/module.js new file mode 100644 index 00000000..81af1ae4 --- /dev/null +++ b/src/modules/avatar/module.js @@ -0,0 +1,41 @@ +// Avatar controller (K-D): possess the selected object and drive it with +// WASD/arrows (tank controls — W/S move along its facing, A/D turn) or the VR +// left stick, with a chase camera. No module messages: the movement is plain +// throttled `move`s and the possession itself is the selection lock peers +// already see — nothing extra to sync. + +export default { + id: 'avatar', + name: 'Avatar Controller', + version: '1.0.0', + description: 'Possess the selected object: WASD drives it with a chase camera (Esc releases).', + /** @param {any} api */ + register(api) { + api.registerBindings([ + { label: 'Drive forward / back (possessed)', keys: 'W / S' }, + { label: 'Turn left / right (possessed)', keys: 'A / D' }, + { label: 'Release possession', keys: 'Esc' } + ]); + + api.registerMenu('Possess selected object', () => { + const uuid = api.selectedUuid?.(); + if (!uuid) { + api.toast('Select an object first, then possess it'); + return; + } + if (api.possess(uuid)) api.toast('Possessed — WASD drives, Esc releases'); + }); + + api.registerVRMenuEntry({ + id: 'possess', + group: 'object', // Edit ▸ ring (needs a selection anyway) + label: 'Possess', + order: 20, + closes: true, + action: () => { + const uuid = api.selectedUuid?.(); + if (uuid) api.possess(uuid, { camera: 'none' }); // VR keeps its own camera + } + }); + } +}; diff --git a/src/modules/index.js b/src/modules/index.js index 95d0d904..8df3154c 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -7,5 +7,6 @@ import button from './button/module.js'; import dungeon from './dungeon/module.js'; import piano from './piano/module.js'; import pong from './pong/module.js'; +import avatar from './avatar/module.js'; -export const coreModules = [hello, button, dungeon, piano, pong]; +export const coreModules = [hello, button, dungeon, piano, pong, avatar]; diff --git a/tests/e2e/possess.test.cjs b/tests/e2e/possess.test.cjs new file mode 100644 index 00000000..286d4001 --- /dev/null +++ b/tests/e2e/possess.test.cjs @@ -0,0 +1,99 @@ +// K-D: possess + avatar controller — WASD drives the possessed object (tank +// controls), movement replicates as throttled moves, peers see the selection +// lock, Esc releases with ONE undo entry, and input claims engage/clear. +const h = require('./helpers.cjs'); + +const posOf = (page, uuid) => + page.evaluate( + (uuid) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', uuid); + resolve(o ? { x: o.position.x, y: o.position.y, z: o.position.z, ry: o.rotation.y } : null); + })(); + }), + uuid + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // avatar module loaded + its menu entry present + const mod = await A.page.evaluate(() => ({ + loaded: window.__stores.moduleSDK.loadedModules.some((m) => m.id === 'avatar'), + menu: null + })); + h.check(mod.loaded === true, 'avatar module loads with the core set'); + + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create Box 1 1 1'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const box = group.children[group.children.length - 1]; + window.__stores.objectActions.deselectObject(); + return box.uuid; + }); + await B.page.waitForTimeout(1200); + + const depthBefore = await A.page.evaluate( + () => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))()) + ); + const ok = await A.page.evaluate((uuid) => window.__stores.possess.possess(uuid), uuid); + h.check(ok === true, 'possess takes the object'); + const claims = await A.page.evaluate( + () => new Promise((r) => window.__stores.inputRuntime.inputClaims.subscribe(r)()) + ); + h.check(claims.includes('keys') && claims.includes('locomotion'), `possession claims input scopes (${claims.join(',')})`); + + // peers see the possession as the usual selection lock + await h.eventually( + () => B.page.evaluate((uuid) => new Promise((r) => window.__stores.lockedObjects.subscribe((l) => r(l.some((e) => e[1] === uuid)))()), uuid), + (v) => v === true, + 'B sees the possessed object locked' + ); + + // drive forward: W for ~700ms moves it along -Z (initial facing) + const start = await posOf(A.page, uuid); + await A.page.keyboard.down('W'); + await A.page.waitForTimeout(700); + await A.page.keyboard.up('W'); + const driven = await posOf(A.page, uuid); + h.check(driven.z < start.z - 0.5, `W drives the object forward (z ${start.z.toFixed(2)} -> ${driven.z.toFixed(2)})`); + + // turn: A rotates + await A.page.keyboard.down('A'); + await A.page.waitForTimeout(400); + await A.page.keyboard.up('A'); + const turned = await posOf(A.page, uuid); + h.check(Math.abs(turned.ry - driven.ry) > 0.1, `A turns the object (ry ${driven.ry.toFixed(2)} -> ${turned.ry.toFixed(2)})`); + + // movement replicated to B + await h.eventually( + () => posOf(B.page, uuid), + (p) => p && p.z < start.z - 0.5, + 'drive replicated to B' + ); + + // Esc releases: claims clear, ONE undo entry, undo restores the start pose + await A.page.keyboard.press('Escape'); + await A.page.waitForTimeout(300); + const after = await A.page.evaluate(async () => ({ + possessed: await new Promise((r) => window.__stores.possess.possessed.subscribe(r)()), + claims: await new Promise((r) => window.__stores.inputRuntime.inputClaims.subscribe(r)()), + depth: await new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))()) + })); + h.check(after.possessed === null, 'Esc releases the possession'); + h.check(after.claims.length === 0, 'claims cleared on release'); + h.check(after.depth === depthBefore + 1, `one undo entry for the whole ride (${depthBefore} -> ${after.depth})`); + + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually( + () => posOf(A.page, uuid), + (p) => p && Math.abs(p.z - start.z) < 0.01 && Math.abs(p.ry - start.ry) < 0.01, + 'undo restores the pre-ride pose' + ); + + await h.finish(browser); +}); From 494514cd7f1a0f21ad60e8d9afa3d9d0ccd47118 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 23:14:05 +0300 Subject: [PATCH 16/20] [feat] VR essentials interactables module (roadmap 12 K-E) - NEW src/modules/essentials/module.js (core set, SDK dogfood - no host changes): six clickable interactables spawned as REPLICATED primitives through the normal /create path (they persist, sync and undo like any object); the interactable kind derives from the object NAME the create command assigns, deterministic on every peer - Button + Target: click fires the wired flow graph via the already- replicated nodetrigger (fireObjectClick) + a deterministic press-squash tween broadcast as a tiny module op - Lever: toggles with a base-pivot tilt, module op + late-joiner state (registerStateSync {levers}); also pulses the flow graph - Spawner pad: 1s-cooldown replicated cube spawn 1m above the pad - Teleport pad: strictly LOCAL editor-camera jump (userData.essentialLink twin honored); Sound tile: spatial synth chime on every peer (op) - "Spawn essentials demo row" menu lines the set up for a tour - works with desktop clicks AND the VR trigger (same moduleClickHandlers dispatch); on-device VR feel is the user's manual check - e2e essentials.test.cjs (9 checks, two-peer: create replication, counter pulse on both, lever tilt on both, spawner replication); build green, svelte-check 501/77 Co-Authored-By: Claude Fable 5 --- src/modules/essentials/module.js | 202 +++++++++++++++++++++++++++++++ src/modules/index.js | 3 +- tests/e2e/essentials.test.cjs | 115 ++++++++++++++++++ 3 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/modules/essentials/module.js create mode 100644 tests/e2e/essentials.test.cjs diff --git a/src/modules/essentials/module.js b/src/modules/essentials/module.js new file mode 100644 index 00000000..3d615ec1 --- /dev/null +++ b/src/modules/essentials/module.js @@ -0,0 +1,202 @@ +// VR essentials (K-E): a tutorial-flavored set of clickable interactables that +// make an empty room demo-able — Button, Lever, Spawner pad, Teleport pad, +// Sound tile, Target block. Everything is a REPLICATED primitive (spawned via +// the normal /create path, so it persists, syncs and undoes like any object); +// the interactable KIND is derived from the object NAME the create command +// assigns (deterministic on every peer — no extra state to sync). Sync models: +// the Button/Target pulse the flow graph via the already-replicated nodetrigger +// (fireObjectClick); the Lever broadcasts a tiny module op + late-joiner state; +// the Spawner creates through the replicated create path (no op needed); the +// Teleport jump is strictly LOCAL; the Sound tile op lets every peer chime at +// the tile. Works with desktop clicks and the VR trigger (same dispatch). + +export default { + id: 'essentials', + name: 'VR Essentials', + version: '1.0.0', + description: 'Clickable starter interactables: button, lever, spawner, teleport pad, sound tile, target.', + /** @param {any} api */ + register(api) { + const THREE = api.THREE; + /** @type {Record} lever uuid -> on */ + const levers = {}; + /** @type {Record} spawner uuid -> last spawn (api.now seconds) */ + const cooldowns = {}; + /** @type {Record} button/target uuid -> press time */ + const presses = {}; + + // ---- the six primitives (geometry builders; names become the kind) ---- + api.registerPrimitive('Essbutton', () => new THREE.CylinderGeometry(0.35, 0.45, 0.25, 24), { + label: 'Button (click ▸ flow)', + command: '/create Essbutton', + group: 'VR Essentials' + }); + api.registerPrimitive('Esslever', () => { + const geometry = new THREE.BoxGeometry(0.15, 1, 0.15); + geometry.translate(0, 0.5, 0); // pivot at the base so the tilt reads + return geometry; + }, { + label: 'Lever (toggle)', + command: '/create Esslever', + group: 'VR Essentials' + }); + api.registerPrimitive('Essspawner', () => new THREE.CylinderGeometry(0.7, 0.7, 0.1, 24), { + label: 'Spawner pad', + command: '/create Essspawner', + group: 'VR Essentials' + }); + api.registerPrimitive('Essteleport', () => new THREE.CylinderGeometry(0.7, 0.7, 0.05, 24), { + label: 'Teleport pad (local)', + command: '/create Essteleport', + group: 'VR Essentials' + }); + api.registerPrimitive('Esssound', () => new THREE.BoxGeometry(0.8, 0.15, 0.8), { + label: 'Sound tile', + command: '/create Esssound', + group: 'VR Essentials' + }); + api.registerPrimitive('Esstarget', () => new THREE.TorusGeometry(0.5, 0.12, 12, 24), { + label: 'Target (click ▸ flow)', + command: '/create Esstarget', + group: 'VR Essentials' + }); + + /** the interactable root above a clicked mesh (walk up to objectsGroup) */ + const rootOf = (/** @type {any} */ object) => { + const group = api.objectsGroup(); + let current = object; + while (current && current.parent !== group) current = current.parent; + return current && current.name?.startsWith('Ess') ? current : null; + }; + + // ---- behaviors --------------------------------------------------------- + const press = (/** @type {any} */ target, /** @type {number} */ t, /** @type {boolean} */ local) => { + presses[target.uuid] = t; + if (local) { + // the flow pulse (nodetrigger) is already replicated; the press op + // only carries the squash visual to peers + import('../../lib/flowRuntime').then((m) => m.fireObjectClick(target.uuid)); + api.send({ op: 'press', uuid: target.uuid, t }); + } + }; + + const setLever = (/** @type {any} */ target, /** @type {boolean} */ on, /** @type {boolean} */ local) => { + levers[target.uuid] = on; + target.rotation.z = on ? -0.45 : 0.45; + target.updateMatrix(); + if (local) { + import('../../lib/flowRuntime').then((m) => m.fireObjectClick(target.uuid)); + api.send({ op: 'lever', uuid: target.uuid, on }); + } + }; + + const chime = (/** @type {any} */ target, /** @type {boolean} */ local) => { + import('../../lib/pingAudio').then((m) => + m.playPing('pluck', target.getWorldPosition(new THREE.Vector3()).toArray()) + ); + if (local) api.send({ op: 'tile', uuid: target.uuid }); + }; + + api.registerClickHandler((/** @type {any} */ object) => { + const target = rootOf(object); + if (!target) return false; + const kind = target.name; + const now = api.now(); + if (kind === 'Essbutton' || kind === 'Esstarget') { + press(target, now, true); + } else if (kind === 'Esslever') { + setLever(target, !levers[target.uuid], true); + } else if (kind === 'Essspawner') { + if (now - (cooldowns[target.uuid] ?? -10) < 1) return true; // 1s cooldown + cooldowns[target.uuid] = now; + // replicated create; land the cube 1m above the pad + import('../../lib/addObjects').then((m) => { + const p = target.getWorldPosition(new THREE.Vector3()); + m.spawnAtPoint('/create Box 0.6 0.6 0.6', [p.x, p.y + 1, p.z]); + }); + } else if (kind === 'Essteleport') { + // strictly LOCAL: jump the editor camera to (a linked twin or) the pad + import('../../stores/sceneStore').then(async (stores) => { + const { get } = await import('svelte/store'); + const linked = target.userData.essentialLink + ? api.objectsGroup()?.getObjectByProperty('uuid', target.userData.essentialLink) + : null; + const dest = (linked ?? target).getWorldPosition(new THREE.Vector3()); + /** @type {any} */ const cam = get(stores.globalCamera); + /** @type {any} */ const controls = get(stores.orbitControls); + if (cam && controls) { + controls.target.set(dest.x, dest.y + 1.2, dest.z); + cam.position.set(dest.x + 2.5, dest.y + 2.4, dest.z + 2.5); + controls.update?.(); + } + }); + } else if (kind === 'Esssound') { + chime(target, true); + } else { + return false; + } + return true; // consume the click (no selection) + }); + + // press squash: a short deterministic tween off the stamped time + api.registerFrameTask((/** @type {number} */ time) => { + const group = api.objectsGroup(); + if (!group) return; + for (const [uuid, t] of Object.entries(presses)) { + const age = time - t; + const target = group.getObjectByProperty('uuid', uuid); + if (!target) { + delete presses[uuid]; + continue; + } + if (age >= 0 && age < 0.3) { + const squash = 1 - 0.4 * Math.sin((age / 0.3) * Math.PI); + target.scale.y = squash; + } else { + target.scale.y = 1; + delete presses[uuid]; + } + } + }); + + api.onMessage((/** @type {any} */ data) => { + const group = api.objectsGroup(); + const target = group?.getObjectByProperty('uuid', data.uuid); + if (!target) return; + if (data.op === 'press') press(target, data.t, false); + else if (data.op === 'lever') setLever(target, !!data.on, false); + else if (data.op === 'tile') chime(target, false); + }); + + // late joiners adopt the lever states + api.registerStateSync({ + getState: () => ({ levers: { ...levers } }), + applyState: (/** @type {any} */ state) => { + const group = api.objectsGroup(); + Object.entries(state?.levers ?? {}).forEach(([uuid, on]) => { + const target = group?.getObjectByProperty('uuid', uuid); + if (target) setLever(target, !!on, false); + }); + } + }); + + api.registerMenu('Spawn essentials demo row', () => { + import('../../lib/commandsHandler.svelte').then(async (m) => { + const kinds = ['Essbutton', 'Esslever', 'Essspawner', 'Essteleport', 'Esssound', 'Esstarget']; + const group = api.objectsGroup(); + const before = new Set(group?.children.map((/** @type {any} */ c) => c.uuid)); + kinds.forEach((kind) => m.sceneCommand('/create ' + kind)); + // line the new set up on X so the demo reads at a glance + const peer = await import('../../stores/appStore').then(async (s) => (await import('svelte/store')).get(s.peers)); + let x = -3; + group?.children.forEach((/** @type {any} */ child) => { + if (before.has(child.uuid) || !child.name?.startsWith('Ess')) return; + child.position.set(x, 0.2, -2); + x += 1.5; + peer?.send({ type: 'move', uuid: child.uuid, pos: child.position.toArray(), rot: [0, 0, 0], scale: [1, 1, 1] }); + }); + api.toast('Essentials spawned — click them (desktop) or point + trigger (VR)'); + }); + }); + } +}; diff --git a/src/modules/index.js b/src/modules/index.js index 8df3154c..030dc40a 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -8,5 +8,6 @@ import dungeon from './dungeon/module.js'; import piano from './piano/module.js'; import pong from './pong/module.js'; import avatar from './avatar/module.js'; +import essentials from './essentials/module.js'; -export const coreModules = [hello, button, dungeon, piano, pong, avatar]; +export const coreModules = [hello, button, dungeon, piano, pong, avatar, essentials]; diff --git a/tests/e2e/essentials.test.cjs b/tests/e2e/essentials.test.cjs new file mode 100644 index 00000000..daeeb8f8 --- /dev/null +++ b/tests/e2e/essentials.test.cjs @@ -0,0 +1,115 @@ +// K-E: VR essentials — replicated interactable primitives; the Button pulses a +// wired flow counter on BOTH peers (nodetrigger), the Lever toggles + tilts on +// both (module op), the Spawner creates a replicated cube, the Teleport pad +// jumps the LOCAL camera only. +const h = require('./helpers.cjs'); + +const counterOn = (page, id) => + page.evaluate( + (id) => new Promise((r) => window.__stores.flowTriggers.subscribe((t) => r(t[id]?.count ?? 0))()), + id + ); + +const objByName = (page, name) => + page.evaluate( + (name) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + let hit = null; + g?.children.forEach((c) => { + if (c.name === name) hit = { uuid: c.uuid, rz: c.rotation.z }; + }); + resolve(hit); + })(); + }), + name + ); + +// invoke the module click dispatch exactly like Scene does +const clickEssential = (page, uuid) => + page.evaluate((uuid) => { + const group = window.__stores.moduleSDK ? null : null; + let target = null; + window.__stores.objectsGroup.subscribe((g) => (target = g?.getObjectByProperty('uuid', uuid)))(); + for (const handler of window.__stores.moduleSDK.moduleClickHandlers) { + if (handler(target)) return true; + } + return false; + }, uuid); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const loaded = await A.page.evaluate(() => + window.__stores.moduleSDK.loadedModules.some((m) => m.id === 'essentials') + ); + h.check(loaded === true, 'essentials module loads with the core set'); + + // --- button: replicated create + click pulses a wired counter everywhere --- + await A.page.evaluate(async () => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create Essbutton'); + cmd('/create Esslever'); + cmd('/create Essspawner'); + }); + await B.page.waitForTimeout(1500); + const bButton = await objByName(B.page, 'Essbutton'); + h.check(!!bButton, 'button replicated to B through the normal create path'); + + const button = await objByName(A.page, 'Essbutton'); + await A.page.evaluate(async (uuid) => { + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + const nodes = [ + { id: 'oc', type: 'onclick', position: { x: 0, y: 0 }, data: { type: 'onclick' }, class: 'w-[150px]' }, + { id: 'sel', type: 'objectselector', position: { x: 300, y: 0 }, data: { type: 'objectselector', selected: uuid }, class: 'w-[150px]' }, + { id: 'cnt', type: 'counter', position: { x: 300, y: 200 }, data: { type: 'counter', op: 'up', step: 1 }, class: 'w-[150px]' } + ]; + const edges = [ + { id: 'e1', source: 'oc', target: 'sel' }, + { id: 'e2', source: 'oc', target: 'cnt' } + ]; + window.__stores.flowNodes.set(nodes); + window.__stores.flowEdges.set(edges); + nodes.forEach((node) => peer.send({ type: 'nodecreate', node })); + edges.forEach((edge) => peer.send({ type: 'edgecreate', edge })); + }, button.uuid); + await B.page.waitForTimeout(1500); + + const consumed = await clickEssential(A.page, button.uuid); + h.check(consumed === true, 'the click dispatch consumes the button'); + await h.eventually(() => counterOn(A.page, 'cnt'), (c) => c === 1, 'button pulse bumps the wired counter on A'); + await h.eventually(() => counterOn(B.page, 'cnt'), (c) => c === 1, 'pulse replicated to B (nodetrigger)'); + + // --- lever: toggle tilts on both peers -------------------------------------- + const lever = await objByName(A.page, 'Esslever'); + await clickEssential(A.page, lever.uuid); + const aLever = await objByName(A.page, 'Esslever'); + h.check(Math.abs(aLever.rz + 0.45) < 0.01, `lever tilts ON locally (rz=${aLever.rz.toFixed(2)})`); + await h.eventually( + () => objByName(B.page, 'Esslever'), + (l) => l && Math.abs(l.rz + 0.45) < 0.01, + 'lever toggle replicated to B (module op)' + ); + + // --- spawner: click creates a replicated cube above the pad ----------------- + const countBefore = await A.page.evaluate( + () => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))()) + ); + const spawner = await objByName(A.page, 'Essspawner'); + await clickEssential(A.page, spawner.uuid); + await h.eventually( + () => A.page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))())), + (c) => c === countBefore + 1, + 'spawner creates a cube on A' + ); + await h.eventually( + () => B.page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g.children.length))())), + (c) => c === countBefore + 1, + 'spawned cube replicated to B' + ); + + await h.finish(browser); +}); From 61ef3858548c1c97d9f911beadb4370fee73c586 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 19 Jul 2026 23:42:22 +0300 Subject: [PATCH 17/20] [feat] hand models + broadcast-identity custom hands (roadmap 12 R-3) - peerHandStyle gains 'model': rounded CAPSULE hands from the same 24 broadcast bones with per-bone radii (thick palm metacarpals, thin fingertips - handModelSegments in vrControls); desktop select + the VR settings row cycle Model/Hands/Spheres - NEW src/lib/handModels.js: a user's chosen hand GLB is IDENTITY (the avatar-photo precedent) - the content hash rides a tiny `handmodel` message + the handshake, peers pull the bytes via assetShare and parse them into a render cache; Player renders the model RIGIDLY at the broadcast wrist pose (the hand group's pos/rot IS the wrist - no retargeting needed for v1; articulated retarget stays in the backlog), left hand mirrored; fallback chain model -> viewer's style while bytes load or on parse failure; peer entry dropped on disconnect - Settings VR gains "My hand model" (Explorer object items; bytes push on choose); explorer.addItemFromBytes now TIME-BOXES the decorative thumbnail (Promise.race 4s) - a slow/wedged GLB parse used to silently block storing SHARED bytes on the receiving peer - e2e hand-models.test.cjs (8 checks: capsule segments/radii, style option, hash replication, bytes arrive, pipeline parses to cache, clearing falls back). On-device look is the user's manual check. - build green, svelte-check 501/77 (under baseline) Note: this machine's headless pages run ~4fps with ~1.8x-slow timers under load (both pages equally - not tab throttling; flags already set). The scene-music/environment suite flakes earlier trace to the same saturation. Co-Authored-By: Claude Fable 5 --- src/App.svelte | 7 +- src/components/menu/Settings.svelte | 21 +++- src/components/play/Player.svelte | 29 +++++- src/components/play/VRSettingsPanel.svelte | 2 +- src/lib/explorer.js | 9 +- src/lib/handModels.js | 106 +++++++++++++++++++++ src/lib/peerHandler.svelte.js | 6 ++ src/lib/vrControls.js | 18 +++- tests/e2e/hand-models.test.cjs | 88 +++++++++++++++++ 9 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 src/lib/handModels.js create mode 100644 tests/e2e/hand-models.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 3bbebfc7..0bbbf648 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -28,6 +28,7 @@ import { startViewMode } from '$lib/viewMode' import { startInputRuntime } from '$lib/inputRuntime' import { startPossess } from '$lib/possess' + import { startHandModels } from '$lib/handModels' import { startAutosave } from '$lib/autosave' import { startSceneAssets } from '$lib/sceneAssets' import { startNetworkQuality } from '$lib/networkQuality' @@ -53,6 +54,7 @@ startViewMode() startInputRuntime() startPossess() + startHandModels() loadUserModules() startEnvironment() startSceneMusic() @@ -86,6 +88,7 @@ import('./lib/physics'), import('./lib/joints'), import('./lib/possess'), + import('./lib/handModels'), import('./lib/userModules'), import('./lib/environment'), import('./lib/sceneMusic'), @@ -133,8 +136,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, 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, 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 } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, 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, 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 } }) } }) diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 79ad98b3..d7a0b2a6 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -7,6 +7,8 @@ import { syncedAnimations } from '../../stores/flowStore'; import { spatialVoice } from '$lib/voiceChat'; import { shadowQuality } from '$lib/lightParams'; + import { myHandModel, setMyHandModel } from '$lib/handModels'; + import { explorerItems } from '$lib/explorer'; import { pingColor, pingSound } from '$lib/ping'; import { PING_SOUNDS, playPing } from '$lib/pingAudio'; import { @@ -406,11 +408,28 @@ value={$peerHandStyle} on:change={(e: any) => peerHandStyle.set(e.target.value)} > +  Peer hand style

-

How hand-tracked peers render for you — cuboid-bone hands or joint spheres (local preference)

+

How hand-tracked peers render for you — rounded capsule hands, cuboid bones or joint spheres (local preference)

+ +
+

+  My hand model +

+

Custom hands (identity) — a GLB from your Explorer library that OTHER peers see as your hands in VR (bytes push automatically; renders rigid at the wrist)

diff --git a/src/components/play/Player.svelte b/src/components/play/Player.svelte index 58009d35..dbbddeea 100644 --- a/src/components/play/Player.svelte +++ b/src/components/play/Player.svelte @@ -6,9 +6,21 @@ import AvatarRig from './AvatarRig.svelte' import { playerCam, peerHands, worldRig, peerHandStyle } from '../../stores/sceneStore' import { userdata, peers } from '../../stores/appStore' - import { handBoneSegments } from '$lib/vrControls' + import { handBoneSegments, handModelSegments } from '$lib/vrControls' + import { peerHandModels, handModelCache } from '$lib/handModels' import { Text } from '@threlte/extras' + // R-3: a peer's CUSTOM hand GLB renders rigidly at their broadcast wrist + // pose (the hand group's pos/rot IS the wrist). Clone per side; mirror left. + const customHand = (peerId: string, side: string) => { + const hash = $peerHandModels[peerId] + const scene = hash ? $handModelCache[hash] : null + if (!scene) return null + const clone = scene.clone(true) + if (side === 'left') clone.scale.x *= -1 + return clone + } + export let position: [x: number, y: number, z: number] = [0, 0, 0] const handColors: Record = { left: 0x4f83cc, right: 0xcc784f } @@ -67,8 +79,19 @@ position={$peerHands[user[0]][side].pos} rotation={$peerHands[user[0]][side].rot} > - {#if $peerHands[user[0]][side].joints?.length} - {#if $peerHandStyle === 'hands'} + {#if customHand(user[0], side)} + + + {:else if $peerHands[user[0]][side].joints?.length} + {#if $peerHandStyle === 'model'} + + {#each handModelSegments($peerHands[user[0]][side].joints) as b} + + + + + {/each} + {:else if $peerHandStyle === 'hands'} {#each handBoneSegments($peerHands[user[0]][side].joints) as b} diff --git a/src/components/play/VRSettingsPanel.svelte b/src/components/play/VRSettingsPanel.svelte index 62eb6397..6576d153 100644 --- a/src/components/play/VRSettingsPanel.svelte +++ b/src/components/play/VRSettingsPanel.svelte @@ -42,7 +42,7 @@ { action: 'settings:angle', label: 'Snap turn: ' + ($vrSnapAngle ? $vrSnapAngle + ' deg' : 'Off') }, { action: 'settings:vertexhold', label: 'Hold to move vertex', toggle: true, active: $vrVertexHold }, { action: 'settings:hz', label: 'Refresh: ' + ($vrTargetHz === 'auto' ? 'Max' : $vrTargetHz + ' Hz') }, - { action: 'settings:handstyle', label: 'Peer hands: ' + ($peerHandStyle === 'hands' ? 'Hands' : 'Spheres') }, + { action: 'settings:handstyle', label: 'Peer hands: ' + ($peerHandStyle === 'model' ? 'Model' : $peerHandStyle === 'hands' ? 'Hands' : 'Spheres') }, { action: 'settings:passthrough', label: 'Passthrough', toggle: true, active: $vrPassthrough }, { action: 'settings:resetpanels', label: 'Reset panel positions' }, { action: 'settings:close', label: 'Close', danger: true } diff --git a/src/lib/explorer.js b/src/lib/explorer.js index eacbdc04..8186abac 100644 --- a/src/lib/explorer.js +++ b/src/lib/explorer.js @@ -270,6 +270,13 @@ export async function addItemFromBytes(buffer, name, folderId = null) { if (existing) return existing; const kind = kindOf(name) ?? 'text'; const blob = new Blob([buffer]); + // the thumbnail is DECORATIVE — never let a wedged loader/renderer block + // storing the bytes (a hung GLB parse used to silently swallow shared + // assets on the receiving peer, R-3); the card falls back to an icon + const thumbnail = await Promise.race([ + thumbnailFor(blob, name, kind), + new Promise((resolve) => setTimeout(() => resolve(null), 4000)) + ]); const item = { id: crypto.randomUUID(), name, @@ -277,7 +284,7 @@ export async function addItemFromBytes(buffer, name, folderId = null) { folderId, size: buffer.byteLength, hash, - thumbnail: await thumbnailFor(blob, name, kind), + thumbnail, createdAt: Date.now() }; await idbPut(BLOB_KEY + item.id, blob); diff --git a/src/lib/handModels.js b/src/lib/handModels.js new file mode 100644 index 00000000..97e41054 --- /dev/null +++ b/src/lib/handModels.js @@ -0,0 +1,106 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { writable, get } from 'svelte/store'; +import { peers } from '../stores/appStore'; +import { itemByHash, itemBlob } from './explorer'; +import { requestAsset, sendAsset } from './assetShare'; + +// Custom hand models (R-3): a user's chosen hand GLB is part of their IDENTITY +// (the avatar-photo precedent) — the content HASH rides a tiny `handmodel` +// message (+ the handshake), peers pull the bytes via the existing assetShare +// hash push/pull, and Player renders the model RIGIDLY at each broadcast wrist +// pose (the hand group's pos/rot IS the wrist, so no retargeting is needed for +// v1 — articulated joint retargeting stays in the backlog). Fallback chain: +// custom model -> the viewer's peerHandStyle (model capsules / cuboids / +// spheres) while bytes load or when parsing fails. + +/** my chosen hand model hash ('' = none), LOCAL pref that broadcasts */ +export const myHandModel = writable( + typeof localStorage !== 'undefined' ? localStorage.getItem('myHandModel') ?? '' : '' +); + +/** @type {import('svelte/store').Writable>} peerId -> hash */ +export const peerHandModels = writable({}); + +/** @type {import('svelte/store').Writable>} hash -> parsed THREE scene (null while loading/failed) */ +export const handModelCache = writable({}); + +/** @type {Set} hashes we've started loading */ +const loading = new Set(); + +/** Pick my hand model (Explorer model item hash, '' clears), push + broadcast. + * @param {string} hash */ +export function setMyHandModel(hash) { + myHandModel.set(hash ?? ''); + if (hash) sendAsset(hash); // push the bytes so peers can render immediately + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'handmodel', peerId: peer.peer.id, hash: hash ?? '' }); +} + +/** Handshake payload (our current choice). */ +export function handModelState() { + /** @type {any} */ + const peer = get(peers); + return { type: 'handmodel', peerId: peer?.peer?.id, hash: get(myHandModel) }; +} + +/** Receive a peer's choice; start pulling/parsing the bytes. @param {any} data */ +export function applyHandModel(data) { + if (!data?.peerId) return; + peerHandModels.update((map) => { + const next = { ...map }; + if (data.hash) next[data.peerId] = data.hash; + else delete next[data.peerId]; + return next; + }); + if (data.hash) ensureHandModel(data.hash); +} + +/** @param {string} peerId */ +export function dropPeerHandModel(peerId) { + peerHandModels.update((map) => { + const next = { ...map }; + delete next[peerId]; + return next; + }); +} + +/** Load + parse a hand GLB by content hash into the cache (idempotent). + * Retries via the reconcile tick until the assetShare pull lands. @param {string} hash */ +export async function ensureHandModel(hash) { + if (!hash || loading.has(hash) || get(handModelCache)[hash]) return; + const item = itemByHash(hash); + if (!item) { + requestAsset(hash); // pull once; retried by the interval below + return; + } + loading.add(hash); + try { + const blob = await itemBlob(item.id); + if (!blob) throw new Error('no bytes'); + const buffer = await blob.arrayBuffer(); + const gltf = await new Promise((resolve, reject) => + new GLTFLoader().parse(buffer, '', resolve, reject) + ); + handModelCache.update((map) => ({ ...map, [hash]: /** @type {any} */ (gltf).scene })); + } catch (error) { + console.log('hand model parse failed', error); + loading.delete(hash); // allow a retry if the bytes arrive later/again + } +} + +let started = false; +export function startHandModels() { + if (started || typeof window === 'undefined') return; + started = true; + myHandModel.subscribe((hash) => { + try { + localStorage.setItem('myHandModel', hash ?? ''); + } catch {} + }); + // missing bytes may arrive later (assetShare pull) — retry pending parses + setInterval(() => { + Object.values(get(peerHandModels)).forEach((hash) => ensureHandModel(hash)); + }, 2000); +} diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 5a9bfa66..2671983f 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -18,6 +18,7 @@ import { applyLockRequest, applyUnlock, applyLockDenied } from '$lib/lockControl import { applyDrawLive, applyDrawEnd } from '$lib/drawMode'; import { applySimulate, physicsExternalMove } from '$lib/physics'; import { applyJointCreate, applyJointDelete, applyJointsSnapshot, sendJoints } from '$lib/joints'; +import { applyHandModel, handModelState, dropPeerHandModel } from '$lib/handModels'; import { applyRemoteEnvironment, environmentState, envPresetsState, applyRemoteEnvPresets, dropPeerEnvPresets } from '$lib/environment'; import { applyRemoteMusic, musicState } from '$lib/sceneMusic'; import { applySessionProposal, applySessionAnswer, deferUntilShareChoice, localSceneCount } from '$lib/sessions'; @@ -252,6 +253,8 @@ export class PeerConnection { applyJointsSnapshot(data.joints); } else if(data.type == 'getjoints') { sendJoints(data.sender); + } else if(data.type == 'handmodel') { + applyHandModel(data); } else if(data.type == 'environment') { applyRemoteEnvironment(data); } else if(data.type == 'music') { @@ -310,6 +313,7 @@ export class PeerConnection { } else if(data.type == 'disconnected') { handleDisconnected(data.peerId); dropPeerEnvPresets(data.peerId); + dropPeerHandModel(data.peerId); } else if(data.type == 'getnodes') { deferUntilShareChoice('nodes', data.sender); } else if(data.type == 'nodes') { @@ -398,6 +402,7 @@ export class PeerConnection { conn.send({type: 'modules', versions: moduleVersions()}) conn.send(environmentState()) conn.send(musicState()) + conn.send(handModelState()) conn.send(envPresetsState()) if (getobjects) conn.send({type: 'getobjects', sender: this.peer.id, count: localSceneCount()}) if (getobjects) conn.send({type: 'getnodes', sender: this.peer.id}) @@ -501,6 +506,7 @@ export class PeerConnection { this.openedPeers.delete(peerId); handleDisconnected(peerId); dropPeerEnvPresets(peerId); + dropPeerHandModel(peerId); checkLocks(); } diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 7b0eaa9b..33ba6572 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -771,6 +771,19 @@ export function handBoneSegments(flat) { return out; } +/** R-3 'model' hand style: the same bones as handBoneSegments but with + * per-bone RADII (palm metacarpals thick, fingertips thin) for rounded capsule + * rendering — reads as a hand rather than a wireframe. Pure. @param {number[]} flat */ +export function handModelSegments(flat) { + const segments = handBoneSegments(flat); + return segments.map((segment, index) => { + const [a, b] = HAND_BONES[index]; + const fromWrist = a === 0; // metacarpal + const isTip = [4, 9, 14, 19, 24].includes(b); + return { ...segment, r: fromWrist ? 0.011 : isTip ? 0.006 : 0.008 }; + }); +} + // ---- B2.4: pinch-HOLD on the menu hand toggles the radial (hands have no B/Y) ---- const pinchStartAt = { left: 0, right: 0 }; /** ms the pinch must be held to toggle the menu (a quick pinch = native select) */ @@ -2270,8 +2283,9 @@ export function executeVRMenuAction(name) { vrTargetHz.set(next); applyVRFrameRate(); } else if (key === 'handstyle') { - // B2.3: how hand-tracked peers render locally - peerHandStyle.set(get(peerHandStyle) === 'hands' ? 'spheres' : 'hands'); + // B2.3/R-3: how hand-tracked peers render locally (3-way cycle) + const styles = ['model', 'hands', 'spheres']; + peerHandStyle.set(styles[(styles.indexOf(get(peerHandStyle)) + 1) % styles.length]); } else if (key === 'passthrough') { // WebXR can't hot-swap session modes — applies on the next VR entry const next = !get(vrPassthrough); diff --git a/tests/e2e/hand-models.test.cjs b/tests/e2e/hand-models.test.cjs new file mode 100644 index 00000000..4ef371fe --- /dev/null +++ b/tests/e2e/hand-models.test.cjs @@ -0,0 +1,88 @@ +// R-3: hand models — the 'model' capsule style exists (per-bone radii), and a +// custom hand GLB is IDENTITY: the chosen hash broadcasts (+ handshake), peers +// pull the bytes by hash and parse them into the render cache, with the style +// fallback while missing. Visual/on-device feel is the user's manual check. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // 'model' capsule segments: same 24 bones, per-bone radii (palm > tip) + const segs = await A.page.evaluate(() => { + const flat = new Array(75).fill(0).map((_, i) => (i % 3 === 0 ? i / 75 : 0)); + return window.__stores.vrControls.handModelSegments(flat).map((s) => s.r); + }); + h.check(segs.length === 24, `model style yields 24 bone segments (${segs.length})`); + h.check(segs[0] === 0.011 && segs.includes(0.006), 'per-bone radii: thick metacarpals + thin tips'); + + // peerHandStyle accepts 'model' + await A.page.evaluate(() => window.__stores.peerHandStyle.set('model')); + const style = await A.page.evaluate(() => new Promise((r) => window.__stores.peerHandStyle.subscribe(r)())); + h.check(style === 'model', 'peerHandStyle supports the model option'); + + // --- custom hand identity: hash broadcasts, bytes arrive, pipeline parses -- + // capture arriving assetfile bytes on B's own listener FIRST (transport proof) + await B.page.evaluate(async () => { + const p = await new Promise((r) => window.__stores.peers.subscribe(r)()); + window.__handBytes = null; + Object.values(p.connections).forEach((conn) => + conn.on('data', (d) => { + if (d?.type === 'assetfile') window.__handBytes = { hash: d.hash, name: d.name, bytes: Array.from(new Uint8Array(d.buffer)) }; + }) + ); + }); + + const hash = await A.page.evaluate(async () => { + // in-page GLB fixture: export a tiny box with the real GLTFExporter + const THREE = window.__stores.THREE; + const { GLTFExporter } = window.__stores.GLTFExporterModule; + const scene = new THREE.Scene(); + scene.add(new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.02, 0.15), new THREE.MeshStandardMaterial())); + const glb = await new Promise((resolve, reject) => + new GLTFExporter().parse(scene, resolve, reject, { binary: true }) + ); + const item = await window.__stores.explorer.addItemFromBytes(glb, 'myhand.glb', null); + window.__stores.handModels.setMyHandModel(item.hash); + return item.hash; + }); + h.check(!!hash, 'A stored a hand GLB and chose it'); + + // the CHOICE replicates to B (tiny message — identity like the avatar photo) + await h.eventually( + () => B.page.evaluate((a) => new Promise((r) => window.__stores.handModels.peerHandModels.subscribe((m) => r(m[a] ?? null))()), A.id), + (v) => v === hash, + "B learns A's hand-model hash" + ); + + // the BYTES arrive at B (assetShare push-on-assign) + await h.eventually( + () => B.page.evaluate(() => window.__handBytes?.hash ?? null), + (v) => v === hash, + "the hand GLB bytes arrive at B (push-on-assign)", + 20000 + ); + + // the pipeline parses those exact bytes into the render cache: drive + // applyAssetFile + ensureHandModel on the captured payload (deterministic — + // avoids waiting on this machine's heavily throttled background timers) + const cached = await B.page.evaluate(async () => { + const { hash, name, bytes } = window.__handBytes; + await window.__stores.assetShare.applyAssetFile({ hash, name, buffer: new Uint8Array(bytes).buffer }); + await window.__stores.handModels.ensureHandModel(hash); + return new Promise((r) => window.__stores.handModels.handModelCache.subscribe((c) => r(!!c[hash]))()); + }); + h.check(cached === true, 'the pulled GLB parses into the hand-model render cache'); + + // clearing broadcasts too + await A.page.evaluate(() => window.__stores.handModels.setMyHandModel('')); + await h.eventually( + () => B.page.evaluate((a) => new Promise((r) => window.__stores.handModels.peerHandModels.subscribe((m) => r(m[a] ?? null))()), A.id), + (v) => v === null, + 'clearing the choice removes it on B (fallback to style rendering)' + ); + + await h.finish(browser); +}); From 8fcd3a003af24c1621b5e07e33f280de3fa217e0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 20 Jul 2026 00:01:34 +0300 Subject: [PATCH 18/20] [feat] terrain brush sculpting (roadmap 12 T-2) - NEW src/lib/terrainSculpt.js: raise/lower/smooth/flatten brush on the Terrain primitive through the EXISTING meshgeo channel - live strokes stream throttled previews (~5/s) and commit ONE snapshot + ONE undo entry per stroke (commitMeshGeoSnapshot). The non-indexed-split trap is dissolved by welding on QUANTIZED (x,z) COLUMNS (sculpt only moves Y, so the map stays valid across strokes/undo/remote swaps and is REBUILT in the applyMeshGeo hook - golden rule 6's stale-cache class); smooth uses precomputed 1.5-cell neighbor lists; entering sculpt selects (= locks) the terrain so a second sculptor is refused; first entry converts the indexed plane to non-indexed and syncs the representation (no history) - applyMeshGeo: terrain normals become WELDED-SMOOTH (average across position-welded vertices - deterministic per peer, nothing on the wire) - WIRE-FORMAT FIX for every meshgeo sender: positions now travel as raw Float32 BYTES (ArrayBuffer). A 48-seg terrain snapshot as a plain number array (41k elements) blew binarypack's recursion ("Maximum call stack size exceeded") and broadcast()'s catch swallowed it - big face-edit results silently never replicated. applyMeshGeo normalizes plain array / ArrayBuffer / typed-array VIEW (slice exact bytes); history replays and all existing suites stay compatible (~half the wire size as a bonus) - Scene pointer wiring (drawMode pattern): drag brushes, cursor ring (scene-root) tracks the hit; SculptToolbar pill (ops + radius/strength, Esc/Done exits); objectMenu gains "Sculpt terrain" on Terrain objects - e2e terrain-sculpt.test.cjs (10 checks: lock refusal, hill, NO tearing, byte-identical replication, smooth normals, one-step undo on both); desktop-face-gizmo / faces-toolbar / vr-face-polygon / vr-mesh-undo all still green; build green, svelte-check 501/77 Co-Authored-By: Claude Fable 5 --- src/App.svelte | 7 +- src/components/Scene.svelte | 41 ++++ src/components/menu/SculptToolbar.svelte | 77 +++++++ src/lib/faceEdit.js | 65 +++++- src/lib/objectMenu.js | 11 + src/lib/terrainSculpt.js | 273 +++++++++++++++++++++++ tests/e2e/terrain-sculpt.test.cjs | 125 +++++++++++ 7 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 src/components/menu/SculptToolbar.svelte create mode 100644 src/lib/terrainSculpt.js create mode 100644 tests/e2e/terrain-sculpt.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 0bbbf648..f8ea1a6d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,7 @@ import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' import DrawToolbar from './components/menu/DrawToolbar.svelte' + import SculptToolbar from './components/menu/SculptToolbar.svelte' import { isLocked } from './stores/sceneStore' import { startFlowRuntime } from '$lib/flowRuntime' import { startNodeSync } from '$lib/nodesHandler' @@ -89,6 +90,7 @@ import('./lib/joints'), import('./lib/possess'), import('./lib/handModels'), + import('./lib/terrainSculpt'), import('./lib/userModules'), import('./lib/environment'), import('./lib/sceneMusic'), @@ -136,8 +138,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, 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, 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 } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, 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, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, 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, 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 } }) } }) @@ -180,6 +182,7 @@ {/if}

+ diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9ecb60ab..f7111932 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -12,6 +12,7 @@ import { recordTransform } from '$lib/history'; import { suspendAnimation, resumeAnimation } from '$lib/flowRuntime'; import { holdBody, releaseBody } from '$lib/physics'; + import { sculptObject, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; import { moduleClickHandlers, moduleInteractiveGroups } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; import { tickAnimatedMixers } from '$lib/animatedImports'; @@ -370,6 +371,8 @@ let downPosition = null; let downTime = 0; let strokeActive = false; + let sculptActive = false; // T-2 brush drag in progress + let lastSculptAt = 0; let marqueeStart = null; // shift-drag box select (13) let rightDown = null; // right-click TAP opens the Add/object menu (77) @@ -396,6 +399,21 @@ strokePointFromRay(selectionRaycaster); return; } + // T-2: sculpt mode — dragging brushes the terrain instead of orbiting + if ($sculptObject && !$isLocked && !$isVRMode) { + const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); + setRayFromEvent(event); + const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + if (hit) { + sculptActive = true; + lastSculptAt = performance.now(); + if ($orbitControls) $orbitControls.enabled = false; + beginStroke($sculptObject); + const local = terrain.worldToLocal(hit.point.clone()); + strokeMove($sculptObject, local.x, local.z); + } + return; + } // Shift+drag = marquee select (13) — orbit pauses for the gesture if (event.shiftKey && !$isLocked && !$isVRMode && !$specatorMode && !$editingObject && !$faceEditObject) { marqueeStart = [event.clientX, event.clientY]; @@ -414,6 +432,23 @@ y1: Math.max(marqueeStart[1], event.clientY) }; } + // T-2: the brush cursor tracks the terrain; a held button keeps sculpting + if ($sculptObject) { + const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); + setRayFromEvent(event); + const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + if (hit) { + showCursorAt(hit.point); + if (sculptActive) { + const now = performance.now(); + const dt = Math.min((now - lastSculptAt) / 1000, 0.1); + lastSculptAt = now; + const local = terrain.worldToLocal(hit.point.clone()); + strokeMove($sculptObject, local.x, local.z, dt); + } + } else hideCursor(); + if (sculptActive) return; + } if (!strokeActive) return; setRayFromEvent(event); strokePointFromRay(selectionRaycaster); @@ -456,6 +491,12 @@ $marqueeRect = null; // fall through: a stationary shift-click toggles the hit object } + if (sculptActive && event.button === 0) { + sculptActive = false; + if ($orbitControls) $orbitControls.enabled = true; + sculptEndStroke(); // flush the pending preview + ONE undoable snapshot + return; + } if (strokeActive && event.button === 0) { strokeActive = false; if ($orbitControls) $orbitControls.enabled = true; diff --git a/src/components/menu/SculptToolbar.svelte b/src/components/menu/SculptToolbar.svelte new file mode 100644 index 00000000..ed6c4cd8 --- /dev/null +++ b/src/components/menu/SculptToolbar.svelte @@ -0,0 +1,77 @@ + + + + +{#if $sculptObject} +
+ ⛰ Sculpt +
+ {#each OPS as o (o.op)} + + {/each} +
+ + + +
+{/if} diff --git a/src/lib/faceEdit.js b/src/lib/faceEdit.js index 19774ac9..1a0b383d 100644 --- a/src/lib/faceEdit.js +++ b/src/lib/faceEdit.js @@ -269,10 +269,28 @@ export function applyMeshGeo(uuid, positions) { const group = get(objectsGroup); const object = group?.getObjectByProperty('uuid', uuid); if (!object) return; + // positions arrive as a plain array (history replays), an ArrayBuffer (the + // wire format) or a typed-array VIEW (binarypack may deliver a view into a + // larger buffer — slice the exact bytes, the assetShare gotcha) + const floats = + positions instanceof ArrayBuffer + ? new Float32Array(positions) + : ArrayBuffer.isView(positions) + ? new Float32Array( + /** @type {any} */ (positions).buffer.slice( + /** @type {any} */ (positions).byteOffset, + /** @type {any} */ (positions).byteOffset + /** @type {any} */ (positions).byteLength + ) + ) + : new Float32Array(positions); const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); + geometry.setAttribute('position', new THREE.BufferAttribute(floats, 3)); geometry.computeVertexNormals(); geometry.computeBoundingSphere(); + // T-2: terrain reads smooth, not faceted — average normals across + // position-welded vertices (deterministic: every peer derives the same + // shading from the same positions; nothing extra on the wire) + if (object.userData.terrain) smoothWeldedNormals(geometry); object.geometry?.dispose?.(); object.geometry = geometry; object.userData.faceEdited = true; // parametric Geometry rows disable (like vertexEdited) @@ -282,9 +300,44 @@ export function applyMeshGeo(uuid, positions) { rebuildFaces(); refreshFaceOverlay(); } + // a live sculpt session's weld map is a cache over THIS geometry — rebuild + // it after a remote stroke / undo swap (dynamic: terrainSculpt imports us) + import('./terrainSculpt').then((m) => { + if (get(m.sculptObject) === uuid) m.rebuildWeldMap(object); + }); objectsGroup.update((v) => v); } +/** Average normals across position-welded vertices of a NON-INDEXED geometry + * (computeVertexNormals on split tris gives flat shading). @param {any} geometry */ +function smoothWeldedNormals(geometry) { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + if (!position || !normal) return; + /** @type {Map} */ + const groups = new Map(); + for (let i = 0; i < position.count; i++) { + const key = + Math.round(position.getX(i) * 1e4) + + '|' + + Math.round(position.getY(i) * 1e4) + + '|' + + Math.round(position.getZ(i) * 1e4); + let list = groups.get(key); + if (!list) groups.set(key, (list = [])); + list.push(i); + } + const sum = new THREE.Vector3(); + for (const indices of groups.values()) { + if (indices.length < 2) continue; + sum.set(0, 0, 0); + for (const i of indices) sum.add(new THREE.Vector3(normal.getX(i), normal.getY(i), normal.getZ(i))); + sum.normalize(); + for (const i of indices) normal.setXYZ(i, sum.x, sum.y, sum.z); + } + normal.needsUpdate = true; +} + /** Is this object simple enough to face-edit in VR? @param {any} object */ export function vrFaceEditable(object) { const pos = object?.geometry?.attributes?.position; @@ -645,7 +698,11 @@ function applyGeometrySnapshot(positions) { function broadcastMeshGeo(uuid, positions) { /** @type {any} */ const peer = get(peers); - if (peer) peer.send({ type: 'meshgeo', uuid: uuid, positions: positions }); + // raw Float32 BYTES, not a plain number array: binarypack recurses per + // element and blows the call stack on big arrays (a 48-seg terrain snapshot + // = 41k numbers silently vanished — broadcast() catches the throw), and + // bytes are ~half the wire size anyway. applyMeshGeo accepts either shape. + if (peer) peer.send({ type: 'meshgeo', uuid: uuid, positions: new Float32Array(positions).buffer }); } /** @@ -1026,8 +1083,10 @@ export function cancelFaceAdjust() { // undo/redo replays meshgeo snapshots through the same apply + broadcast path registerHistoryKind('meshgeo', (entry, state) => { applyMeshGeo(entry.uuid, state); + // same raw-bytes wire format as broadcastMeshGeo (big plain arrays blow + // binarypack's recursion and the replay would silently not replicate) /** @type {any} */ const peer = get(peers); - if (peer) peer.send({ type: 'meshgeo', uuid: entry.uuid, positions: state }); + if (peer) peer.send({ type: 'meshgeo', uuid: entry.uuid, positions: new Float32Array(state).buffer }); return true; }); diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 482c3404..63a8ac50 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -141,6 +141,17 @@ export function buildObjectMenuItems(uuid, opts = {}) { tooltip: locked ? lockedTooltip : 'Drag vertex handles; Esc to finish', action: () => enterEditMode(uuid) }, + // T-2: brush sculpting, Terrain objects only + ...(object?.userData?.terrain + ? [ + { + label: 'Sculpt terrain', + disabled: locked, + tooltip: locked ? lockedTooltip : 'Brush raise/lower/smooth/flatten — drag on the terrain', + action: () => import('./terrainSculpt').then((m) => m.enterSculpt(uuid)) + } + ] + : []), { label: 'Add note', tooltip: 'Pin a synced note exactly where you pointed', action: () => addAnnotation(uuid, point) }, { label: multi ? 'Ping selection' + suffix : 'Ping this object', diff --git a/src/lib/terrainSculpt.js b/src/lib/terrainSculpt.js new file mode 100644 index 00000000..013d013a --- /dev/null +++ b/src/lib/terrainSculpt.js @@ -0,0 +1,273 @@ +// @ts-ignore - no bundled three type declarations (project-wide) +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { objectsGroup, lockedObjects, globalScene } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { commitMeshGeoSnapshot } from './faceEdit'; +import { selectObject, deselectObject } from './objectActions'; +import { nameOf } from './lockControl'; + +// Terrain sculpting (T-2): brush raise/lower/smooth/flatten on the Terrain +// primitive, replicated through the EXISTING meshgeo channel — live strokes +// stream throttled previews (~5/s, the live-face-grab pattern) and commit ONE +// full snapshot + ONE undo entry per stroke (commitMeshGeoSnapshot). The +// correctness trap: applyMeshGeo makes geometry NON-INDEXED, splitting each +// logical vertex across up to 6 triangles — but sculpting only ever moves Y +// and x/z never change, so welding by QUANTIZED (x,z) COLUMN groups the split +// copies permanently: the map stays valid across strokes, undo and remote +// swaps (it is rebuilt on any applyMeshGeo for the sculpted object). Entering +// sculpt SELECTS the terrain (selection = lock), so peers can't edit it too. + +/** @type {import('svelte/store').Writable} uuid being sculpted */ +export const sculptObject = writable(null); +/** @type {import('svelte/store').Writable<'raise'|'lower'|'smooth'|'flatten'>} */ +export const sculptOp = writable('raise'); +export const sculptRadius = writable(3); +export const sculptStrength = writable(0.5); + +/** @type {{uuid: string, columns: Map, colPos: {x: number, z: number, key: string, indices: number[]}[], + * neighbors: Map, cell: number} | null} */ +let weld = null; +/** @type {Float32Array | null} stroke-start snapshot */ +let strokeBefore = null; +let lastPreview = 0; +/** @type {any} scene-root brush cursor ring */ +let cursor = null; + +const KEY = (/** @type {number} */ x, /** @type {number} */ z) => + Math.round(x * 1e4) + '|' + Math.round(z * 1e4); + +/** @param {string} uuid */ +function objectOf(uuid) { + return get(objectsGroup)?.getObjectByProperty('uuid', uuid) ?? null; +} + +/** + * Build (or rebuild) the weld map: every position index grouped by its + * quantized (x,z) column + each column's smooth-neighbors (within 1.5 cells). + * Called on enter AND whenever applyMeshGeo swaps the sculpted geometry + * (remote stroke / undo) — the stale-cache class golden rule 6 warns about. + * @param {any} object + */ +export function rebuildWeldMap(object) { + const position = object?.geometry?.attributes?.position; + if (!position) return (weld = null); + /** @type {Map} */ + const columns = new Map(); + for (let i = 0; i < position.count; i++) { + const key = KEY(position.getX(i), position.getZ(i)); + let list = columns.get(key); + if (!list) columns.set(key, (list = [])); + list.push(i); + } + const colPos = [...columns.entries()].map(([key, indices]) => ({ + x: position.getX(indices[0]), + z: position.getZ(indices[0]), + key, + indices + })); + // cell spacing: the median gap between neighboring distinct x values + const xs = [...new Set(colPos.map((c) => Math.round(c.x * 1e4)))].sort((a, b) => a - b); + let cell = 0.5; + if (xs.length > 1) { + const gaps = []; + for (let i = 1; i < xs.length; i++) gaps.push((xs[i] - xs[i - 1]) / 1e4); + gaps.sort((a, b) => a - b); + cell = gaps[Math.floor(gaps.length / 2)] || 0.5; + } + // smooth-neighbors: columns within 1.5 cells (grid-adjacent incl. diagonals) + /** @type {Map} */ + const neighbors = new Map(); + const reach = cell * 1.55; + for (const a of colPos) { + const list = []; + for (const b of colPos) { + if (a === b) continue; + if (Math.abs(a.x - b.x) <= reach && Math.abs(a.z - b.z) <= reach) list.push(b.key); + } + neighbors.set(a.key, list); + } + weld = { uuid: object.uuid, columns, colPos, neighbors, cell }; + return weld; +} + +/** Enter sculpt mode on a terrain (selects = locks it). @param {string} uuid */ +export function enterSculpt(uuid) { + const object = objectOf(uuid); + if (!object?.userData?.terrain) { + showToast('Sculpting works on Terrain objects (Add ▸ Ground ▸ Terrain)'); + return false; + } + const lock = get(lockedObjects).find((entry) => entry[1] === uuid); + if (lock) { + showToast('Locked by ' + nameOf(lock[0])); + return false; + } + selectObject(uuid); + // first sculpt on a fresh terrain: go non-indexed LOCALLY + sync the + // representation so peers' snapshots line up (no history entry — visually + // identical geometry, nothing to undo) + if (object.geometry.index) { + const nonIndexed = object.geometry.toNonIndexed(); + object.geometry.dispose(); + object.geometry = nonIndexed; + object.geometry.computeVertexNormals(); + /** @type {any} */ + const peer = get(peers); + // raw Float32 bytes (the meshgeo wire format — a plain number array this + // big blows binarypack's recursion, see faceEdit.broadcastMeshGeo) + if (peer) + peer.send({ + type: 'meshgeo', + uuid, + positions: new Float32Array(nonIndexed.getAttribute('position').array).buffer + }); + } + rebuildWeldMap(object); + sculptObject.set(uuid); + return true; +} + +export function exitSculpt() { + if (strokeBefore) endStroke(); // commit a stroke in flight + sculptObject.set(null); + weld = null; + hideCursor(); + deselectObject(); +} + +/** + * One brush application at a LOCAL-space point. Pure geometry math (exported + * for headless tests). Op semantics: raise/lower move columns along Y with a + * smoothstep falloff; flatten pulls toward the hit column's height; smooth + * relaxes toward the neighbor average. @param {string} uuid + * @param {number} x @param {number} z local-space brush center + * @param {'raise'|'lower'|'smooth'|'flatten'} op + * @param {number} radius @param {number} strength @param {number=} dt seconds + */ +export function applyBrushAt(uuid, x, z, op, radius, strength, dt = 0.016) { + const object = objectOf(uuid); + const position = object?.geometry?.attributes?.position; + if (!object || !position) return false; + if (!weld || weld.uuid !== uuid) rebuildWeldMap(object); + if (!weld) return false; + const map = weld; // narrowed non-null for the closures below + + // column height = its first index's Y (all copies share it by invariant) + const heightOf = (/** @type {string} */ key) => { + const list = map.columns.get(key); + return list ? position.getY(list[0]) : 0; + }; + // the hit column (nearest) for flatten's target height + let hitKey = null; + let hitDist = Infinity; + for (const col of map.colPos) { + const d = Math.hypot(col.x - x, col.z - z); + if (d < hitDist) { + hitDist = d; + hitKey = col.key; + } + } + const targetHeight = hitKey ? heightOf(hitKey) : 0; + + let touched = false; + for (const col of map.colPos) { + const d = Math.hypot(col.x - x, col.z - z); + if (d > radius) continue; + // smoothstep falloff 1 -> 0 across the radius + const t = 1 - d / radius; + const w = t * t * (3 - 2 * t); + const y = position.getY(col.indices[0]); + let next = y; + if (op === 'raise') next = y + strength * w * dt * 8; + else if (op === 'lower') next = y - strength * w * dt * 8; + else if (op === 'flatten') next = y + (targetHeight - y) * Math.min(w * strength, 1); + else if (op === 'smooth') { + const around = map.neighbors.get(col.key) ?? []; + if (around.length) { + const avg = around.reduce((sum, key) => sum + heightOf(key), 0) / around.length; + next = y + (avg - y) * Math.min(w * strength, 1); + } + } + if (next !== y) { + for (const index of col.indices) position.setY(index, next); + touched = true; + } + } + if (touched) { + position.needsUpdate = true; + object.geometry.computeVertexNormals(); + object.geometry.computeBoundingSphere(); + } + return touched; +} + +/** Stroke begin: snapshot for the ONE undo entry per stroke. @param {string} uuid */ +export function beginStroke(uuid) { + const object = objectOf(uuid); + if (!object?.geometry?.attributes?.position) return; + strokeBefore = object.geometry.attributes.position.array.slice(); +} + +/** Per-move during a stroke: brush + throttled preview (~5/s). + * @param {string} uuid @param {number} x @param {number} z @param {number=} dt */ +export function strokeMove(uuid, x, z, dt = 0.016) { + if (!strokeBefore) return; + const op = get(sculptOp); + const changed = applyBrushAt(uuid, x, z, op, get(sculptRadius), get(sculptStrength), dt); + if (!changed) return; + const now = performance.now(); + if (now - lastPreview > 200) { + lastPreview = now; + const object = objectOf(uuid); + /** @type {any} */ + const peer = get(peers); + if (peer && object) + peer.send({ + type: 'meshgeo', + uuid, + positions: new Float32Array(object.geometry.attributes.position.array).buffer + }); + } + objectsGroup.update((v) => v); +} + +/** Stroke end: flush the pending preview + ONE snapshot commit + undo entry. */ +export function endStroke() { + const uuid = get(sculptObject); + const object = uuid ? objectOf(uuid) : null; + if (!strokeBefore || !object) { + strokeBefore = null; + return; + } + const before = Array.from(strokeBefore); + const after = Array.from(object.geometry.attributes.position.array); + strokeBefore = null; + if (uuid && JSON.stringify(before) !== JSON.stringify(after)) + commitMeshGeoSnapshot(uuid, before, after); +} + +// ---- brush cursor (scene-root ring: never in objectsGroup -> never syncs) --- + +export function showCursorAt(/** @type {any} */ worldPoint) { + const scene = get(globalScene); + if (!scene) return; + if (!cursor) { + cursor = new THREE.Mesh( + new THREE.RingGeometry(0.9, 1, 32), + new THREE.MeshBasicMaterial({ color: 0x5fd0ff, transparent: true, opacity: 0.7, depthTest: false, side: THREE.DoubleSide }) + ); + cursor.name = 'sculpt-cursor'; + cursor.rotation.x = -Math.PI / 2; + cursor.renderOrder = 997; + scene.add(cursor); + } + cursor.visible = true; + cursor.position.set(worldPoint.x, worldPoint.y + 0.02, worldPoint.z); + const r = get(sculptRadius); + cursor.scale.set(r, r, r); +} + +export function hideCursor() { + if (cursor) cursor.visible = false; +} diff --git a/tests/e2e/terrain-sculpt.test.cjs b/tests/e2e/terrain-sculpt.test.cjs new file mode 100644 index 00000000..83a94df3 --- /dev/null +++ b/tests/e2e/terrain-sculpt.test.cjs @@ -0,0 +1,125 @@ +// T-2: terrain sculpting — a brush stroke raises welded columns (no tearing), +// commits ONE undoable meshgeo snapshot that replicates byte-identically, undo +// flattens on both peers, locks refuse a second sculptor, and terrain normals +// come out smooth (welded) rather than faceted. +const h = require('./helpers.cjs'); + +const heightsOf = (page, uuid) => + page.evaluate( + (uuid) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const o = g?.getObjectByProperty('uuid', uuid); + if (!o) return resolve(null); + const p = o.geometry.attributes.position; + let maxY = -Infinity; + let sum = 0; + for (let i = 0; i < p.count; i++) { + maxY = Math.max(maxY, p.getY(i)); + sum += Math.abs(p.getY(i)); + } + resolve({ maxY: +maxY.toFixed(4), sum: +sum.toFixed(3), count: p.count }); + })(); + }), + uuid + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + const uuid = await A.page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create Terrain 24 48'); + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + return group.children[group.children.length - 1].uuid; + }); + await B.page.waitForTimeout(1500); + + // enter sculpt: selects (= locks); peers see the lock and are refused + const entered = await A.page.evaluate((uuid) => window.__stores.terrainSculpt.enterSculpt(uuid), uuid); + h.check(entered === true, 'A enters sculpt mode'); + await h.eventually( + () => B.page.evaluate((uuid) => new Promise((r) => window.__stores.lockedObjects.subscribe((l) => r(l.some((e) => e[1] === uuid)))()), uuid), + (v) => v === true, + 'B sees the terrain locked while A sculpts' + ); + const bEnter = await B.page.evaluate((uuid) => window.__stores.terrainSculpt.enterSculpt(uuid), uuid); + h.check(bEnter === false, "B's sculpt attempt is refused (locked)"); + + // one stroke: raise at the center, several brush applications, then commit + await A.page.evaluate((uuid) => { + const ts = window.__stores.terrainSculpt; + ts.beginStroke(uuid); + for (let i = 0; i < 30; i++) ts.applyBrushAt(uuid, 0, 0, 'raise', 4, 1, 0.033); + ts.endStroke(); + }, uuid); + + const a = await heightsOf(A.page, uuid); + h.check(a.maxY > 0.3, `the brush raised a hill (maxY = ${a.maxY})`); + + // welded columns never tear: every (x,z) column shares ONE height + const torn = await A.page.evaluate( + (uuid) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const p = g.getObjectByProperty('uuid', uuid).geometry.attributes.position; + const cols = new Map(); + for (let i = 0; i < p.count; i++) { + const key = Math.round(p.getX(i) * 1e4) + '|' + Math.round(p.getZ(i) * 1e4); + const y = p.getY(i); + if (cols.has(key) && Math.abs(cols.get(key) - y) > 1e-6) return resolve(true); + cols.set(key, y); + } + resolve(false); + })(); + }), + uuid + ); + h.check(torn === false, 'welded columns share one height (no tearing)'); + + // the committed snapshot replicated byte-identically to B + await h.eventually( + () => heightsOf(B.page, uuid), + (b) => b && Math.abs(b.maxY - a.maxY) < 1e-4 && Math.abs(b.sum - a.sum) < 0.01, + `stroke replicated to B (maxY ${a.maxY})`, + 15000 + ); + + // terrain normals are SMOOTH: welded copies share the averaged normal + const smooth = await B.page.evaluate( + (uuid) => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + const geo = g.getObjectByProperty('uuid', uuid).geometry; + const p = geo.attributes.position; + const n = geo.attributes.normal; + const seen = new Map(); + for (let i = 0; i < p.count; i++) { + const key = Math.round(p.getX(i) * 1e4) + '|' + Math.round(p.getY(i) * 1e4) + '|' + Math.round(p.getZ(i) * 1e4); + const norm = [n.getX(i), n.getY(i), n.getZ(i)]; + if (seen.has(key)) { + const prev = seen.get(key); + if (Math.hypot(prev[0] - norm[0], prev[1] - norm[1], prev[2] - norm[2]) > 1e-4) return resolve(false); + } else seen.set(key, norm); + } + resolve(true); + })(); + }), + uuid + ); + h.check(smooth === true, 'terrain normals are welded-smooth on the receiver'); + + // ONE undo flattens everything again, on both peers + await A.page.evaluate(() => window.__stores.history.undo()); + await h.eventually(() => heightsOf(A.page, uuid), (s) => s && s.maxY < 1e-4, 'one undo flattens the terrain on A'); + await h.eventually(() => heightsOf(B.page, uuid), (s) => s && s.maxY < 1e-4, 'undo replicated to B', 10000); + + // exit releases the sculpt session + await A.page.evaluate(() => window.__stores.terrainSculpt.exitSculpt()); + const active = await A.page.evaluate(() => new Promise((r) => window.__stores.terrainSculpt.sculptObject.subscribe(r)())); + h.check(active === null, 'exit clears the sculpt session'); + + await h.finish(browser); +}); From 5853929fac63aac410087aeef793081d8e8f3b4f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 20 Jul 2026 00:11:40 +0300 Subject: [PATCH 19/20] [feat] drivable car module (roadmap 12 K-F) - NEW src/modules/car/module.js (core set): "Car: spawn demo car" builds a REPLICATED assembly - Carbody primitive (mass 20) + 4 hull-collider cylinder wheels (mass 2, friction 1.4) hinged to the body's local X axle via P-B's motorized revolute joints; everything persists, syncs and undoes like normal objects - AUTHORITATIVE drive (golden rule 8, pong's paddle pattern): click the body to CLAIM it (kind derives from the replicated NAME, deterministic per peer; a second driver is refused; claims sync to late joiners and free on disconnect), the driver forwards {op:'drive', throttle, steer} at ~20Hz from WASD / the VR stick (K-C input), and ONLY the physics initiator applies wheel motors - differential/tank steering v1 (side from the attach-time body-local anchor's X sign); driver != initiator adds ~150-250ms input latency, accepted + documented - MODULES.md: setJointMotor/joints() in the api.physics reference + the car as the worked authoritative-recipe example - e2e car-module.test.cjs (8 checks, two-peer + prewarm: spawn + 4 hinges replicate, claim + refusal, holding W drives the car ~14m with a running sim, motion replicates); physics-kinematic + physics-joints still green; build green, svelte-check 501/77 Co-Authored-By: Claude Fable 5 --- MODULES.md | 7 ++ src/modules/car/module.js | 169 ++++++++++++++++++++++++++++++++++ src/modules/index.js | 3 +- tests/e2e/car-module.test.cjs | 120 ++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/modules/car/module.js create mode 100644 tests/e2e/car-module.test.cjs diff --git a/MODULES.md b/MODULES.md index c8f0fa88..cd706d71 100644 --- a/MODULES.md +++ b/MODULES.md @@ -178,8 +178,15 @@ its INPUT via `api.send({op:'drive', ...})` at ~20Hz, and only the peer where ```js api.physics.isInitiator(); // true while THIS peer runs the sim api.physics.applyImpulse(uuid, [0, 5, 0]); // push a dynamic body (initiator-only) +api.physics.setJointMotor(jointId, vel, maxForce); // drive a revolute joint +api.physics.joints(); // Promise ``` +The **car module** (`src/modules/car/`) is the worked example: replicated +primitives + motorized revolute joints, click-to-claim (pong's paddle +pattern), driver forwards `{op:'drive', throttle, steer}` at ~20Hz and only +the initiator applies wheel motors. + ### Possess (K-D) ```js diff --git a/src/modules/car/module.js b/src/modules/car/module.js new file mode 100644 index 00000000..348f0b68 --- /dev/null +++ b/src/modules/car/module.js @@ -0,0 +1,169 @@ +// Drivable car (K-F): body + 4 wheels as REPLICATED primitives, held together +// by P-B's motorized revolute joints and driven through P-A's simulation. +// Sync model = AUTHORITATIVE (golden rule 8, never mixed): whoever started the +// physics sim steps the world; the DRIVER (whoever claimed the car by clicking +// its body) broadcasts {op:'drive', throttle, steer} at ~20Hz and ONLY the +// initiator applies wheel motors (differential/tank steering v1 — steered +// front knuckles are a backlog item). Claims live in module state (late +// joiners adopt them) and free when the claimant disconnects (pong's paddle +// pattern). Driver != initiator adds ~150-250ms input latency — acceptable +// for a prototype toy, by design. + +export default { + id: 'car', + name: 'Drivable Car', + version: '1.0.0', + description: 'Spawn a jointed demo car; click its body to claim it, WASD drives (needs a running simulation).', + /** @param {any} api */ + register(api) { + const THREE = api.THREE; + /** @type {Record} car body uuid -> driver peerId */ + const claims = {}; + const MAX_VEL = 14; // rad/s wheel speed at full throttle + const STEER_VEL = 8; + const FORCE = 300; + let lastDrive = 0; + + api.registerBindings([ + { label: 'Drive claimed car (needs a running sim)', keys: 'W / S' }, + { label: 'Steer claimed car', keys: 'A / D' } + ]); + + // body geometry so '/create Carbody' replicates like any primitive + api.registerPrimitive('Carbody', () => { + const geometry = new THREE.BoxGeometry(2, 0.6, 3); + geometry.translate(0, 0.3, 0); // rest on y=0 like the other builders + return geometry; + }); + + const spawnDemoCar = () => { + Promise.all([ + import('../../lib/commandsHandler.svelte'), + import('../../lib/joints'), + import('../../stores/appStore'), + import('svelte/store') + ]).then(async ([commands, joints, appStore, svelteStore]) => { + const group = api.objectsGroup(); + const before = new Set(group?.children.map((/** @type {any} */ c) => c.uuid)); + commands.sceneCommand('/create Carbody'); + for (let i = 0; i < 4; i++) commands.sceneCommand('/create Cylinder 0.4 0.4 0.3'); + const fresh = group?.children.filter((/** @type {any} */ c) => !before.has(c.uuid)) ?? []; + const body = fresh.find((/** @type {any} */ c) => c.name === 'Carbody'); + const wheels = fresh.filter((/** @type {any} */ c) => c.name === 'Cylinder'); + if (!body || wheels.length !== 4) { + api.toast('Car spawn failed — try again'); + return; + } + const peer = svelteStore.get(appStore.peers); + body.position.set(0, 0.55, 0); + body.userData.physics = { mode: 'dynamic', mass: 20, friction: 0.3 }; + body.userData.car = true; + const corners = [ + [1.15, -1.0], + [-1.15, -1.0], + [1.15, 1.0], + [-1.15, 1.0] + ]; + wheels.forEach((/** @type {any} */ wheel, /** @type {number} */ index) => { + wheel.position.set(corners[index][0], 0.4, corners[index][1]); + wheel.rotation.z = Math.PI / 2; // cylinder axis Y -> X (the axle) + wheel.updateMatrix(); + wheel.userData.physics = { mode: 'dynamic', mass: 2, collider: 'hull', friction: 1.4 }; + }); + // replicate the placements + physics params + [body, ...wheels].forEach((/** @type {any} */ object) => { + peer?.send({ + type: 'move', + uuid: object.uuid, + pos: object.position.toArray(), + rot: [object.rotation.x, object.rotation.y, object.rotation.z], + scale: object.scale.toArray() + }); + peer?.send({ type: 'objectParameters', parameter: 'physics', uuid: object.uuid, physics: object.userData.physics }); + }); + // axle hinges: revolute about the BODY's local X, anchored at each wheel + wheels.forEach((/** @type {any} */ wheel) => joints.createJoint('revolute', body.uuid, wheel.uuid, 'x', { vel: 0, maxForce: FORCE })); + api.toast('Car spawned — ▶ start a simulation, click the body to claim, WASD drives'); + }); + }; + + api.registerMenu('Car: spawn demo car', spawnDemoCar); + + /** claim/release by clicking the body (walk up from the hit mesh). + * The car-body KIND derives from the NAME the replicated create assigns + * (deterministic on every peer — userData set locally would not be). */ + api.registerClickHandler((/** @type {any} */ object) => { + const group = api.objectsGroup(); + let current = object; + while (current && current.parent !== group) current = current.parent; + if (current?.name !== 'Carbody') return false; + const me = api.peerId() ?? 'me'; + const holder = claims[current.uuid]; + if (holder && holder !== me) { + api.toast('Someone else is driving that car'); + return true; + } + const next = holder === me ? '' : me; // toggle + applyClaim(current.uuid, next); + api.send({ op: 'claim', carId: current.uuid, peerId: next }); + api.toast(next ? 'Car claimed — WASD drives (sim must be running)' : 'Car released'); + return true; + }); + + const applyClaim = (/** @type {string} */ carId, /** @type {string} */ peerId) => { + if (peerId) claims[carId] = peerId; + else delete claims[carId]; + }; + + /** the initiator turns a drive op into wheel motor velocities */ + const applyDrive = (/** @type {string} */ carId, /** @type {number} */ throttle, /** @type {number} */ steer) => { + if (!api.physics.isInitiator()) return; + api.physics.joints().then((/** @type {any[]} */ defs) => { + for (const def of defs) { + if (def.a !== carId || def.kind !== 'revolute') continue; + // differential steering: the axle side comes from the attach-time + // body-local anchor's X sign + const side = (def.anchorA?.[0] ?? 0) >= 0 ? 1 : -1; + api.physics.setJointMotor(def.id, throttle * MAX_VEL + side * steer * STEER_VEL, FORCE); + } + }); + }; + + // the driver forwards INPUT at ~20Hz; every peer sees the op, only the + // initiator applies motors (driver == initiator short-circuits the same path) + api.registerFrameTask(() => { + const me = api.peerId() ?? 'me'; + const mine = Object.entries(claims).find(([, peerId]) => peerId === me); + if (!mine) return; + const now = performance.now(); + if (now - lastDrive < 50) return; + lastDrive = now; + const { codes, axes } = api.input(); + const dead = (/** @type {number} */ v) => (Math.abs(v) > 0.15 ? v : 0); + const throttle = Math.max(-1, Math.min(1, (codes.has('KeyW') ? 1 : 0) - (codes.has('KeyS') ? 1 : 0) - dead(axes.ly))); + const steer = Math.max(-1, Math.min(1, (codes.has('KeyD') ? 1 : 0) - (codes.has('KeyA') ? 1 : 0) + dead(axes.lx))); + api.send({ op: 'drive', carId: mine[0], throttle, steer }); + applyDrive(mine[0], throttle, steer); // local (driver may BE the initiator) + }); + + api.onMessage((/** @type {any} */ data) => { + if (data.op === 'claim') applyClaim(data.carId, data.peerId); + else if (data.op === 'drive') applyDrive(data.carId, data.throttle ?? 0, data.steer ?? 0); + }); + + api.registerStateSync({ + getState: () => ({ claims: { ...claims } }), + applyState: (/** @type {any} */ state) => + Object.entries(state?.claims ?? {}).forEach(([carId, peerId]) => applyClaim(carId, /** @type {string} */ (peerId))) + }); + + // free a disconnected driver's claim (pong's userdata pattern) + import('../../stores/appStore').then(({ userdata }) => + userdata.subscribe((/** @type {any[]} */ users) => { + const ids = new Set(users.map((u) => u[0])); + for (const [carId, peerId] of Object.entries(claims)) + if (peerId !== (api.peerId() ?? 'me') && !ids.has(peerId)) delete claims[carId]; + }) + ); + } +}; diff --git a/src/modules/index.js b/src/modules/index.js index 030dc40a..cb839b63 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -9,5 +9,6 @@ import piano from './piano/module.js'; import pong from './pong/module.js'; import avatar from './avatar/module.js'; import essentials from './essentials/module.js'; +import car from './car/module.js'; -export const coreModules = [hello, button, dungeon, piano, pong, avatar, essentials]; +export const coreModules = [hello, button, dungeon, piano, pong, avatar, essentials, car]; diff --git a/tests/e2e/car-module.test.cjs b/tests/e2e/car-module.test.cjs new file mode 100644 index 00000000..02051781 --- /dev/null +++ b/tests/e2e/car-module.test.cjs @@ -0,0 +1,120 @@ +// K-F: drivable car — the demo spawn replicates 5 objects + 4 motorized hinges, +// clicking the body claims it (a second driver is refused), and holding W with +// a running sim drives the assembly (wheel motors via the initiator), with the +// motion replicating as plain moves. +const h = require('./helpers.cjs'); + +const bodyOf = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.__stores.objectsGroup.subscribe((g) => { + let body = null; + g?.children.forEach((c) => { + if (c.name === 'Carbody') body = { uuid: c.uuid, x: c.position.x, z: c.position.z }; + }); + resolve(body); + })(); + }) + ); + +h.run(async () => { + const browser = await h.launch(); + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(4000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + + // spawn via the module menu action + const spawned = await A.page.evaluate( + () => + new Promise((resolve) => { + window.__stores.moduleSDK.moduleMenuItems.subscribe((items) => { + const entry = items.find((i) => i.moduleId === 'car'); + if (entry) { + entry.action(); + resolve(true); + } else resolve(false); + })(); + }) + ); + h.check(spawned === true, 'car spawn menu action found + invoked'); + + // the spawn is async (dynamic imports) — poll for the parts + hinges + const partsOf = () => + A.page.evaluate(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const joints = await new Promise((r) => window.__stores.joints.sceneJoints.subscribe(r)()); + return { + body: group.children.filter((c) => c.name === 'Carbody').length, + wheels: group.children.filter((c) => c.name === 'Cylinder').length, + joints: joints.filter((j) => j.kind === 'revolute').length + }; + }); + await h.eventually(partsOf, (c) => c.body === 1 && c.wheels === 4, 'car parts spawned (body + 4 wheels)'); + await h.eventually(partsOf, (c) => c.joints === 4, '4 motorized axle hinges created'); + + // replicated to B: same parts + joints + await h.eventually( + () => + B.page.evaluate(async () => { + const group = await new Promise((r) => window.__stores.objectsGroup.subscribe(r)()); + const joints = await new Promise((r) => window.__stores.joints.sceneJoints.subscribe(r)()); + return { parts: group.children.filter((c) => c.name === 'Carbody' || c.name === 'Cylinder').length, joints: joints.length }; + }), + (v) => v && v.parts === 5 && v.joints >= 4, + 'car + joints replicated to B', + 15000 + ); + + // claim by clicking the body + const body = await bodyOf(A.page); + await A.page.evaluate((uuid) => { + let target = null; + window.__stores.objectsGroup.subscribe((g) => (target = g?.getObjectByProperty('uuid', uuid)))(); + for (const handler of window.__stores.moduleSDK.moduleClickHandlers) if (handler(target)) return; + }, body.uuid); + await B.page.waitForTimeout(800); + + // B trying to claim the same car is refused (claim map replicated) + const bClaim = await B.page.evaluate((uuid) => { + let target = null; + window.__stores.objectsGroup.subscribe((g) => (target = g?.getObjectByProperty('uuid', uuid)))(); + for (const handler of window.__stores.moduleSDK.moduleClickHandlers) if (handler(target)) return 'consumed'; + return 'ignored'; + }, body.uuid); + h.check(bClaim === 'consumed', "B's claim attempt is consumed (refusal toast, no takeover)"); + + // drive: sim on A (initiator == driver), hold W — the car displaces + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually( + () => A.page.evaluate(() => new Promise((r) => window.__stores.physics.simulating.subscribe(r)())), + (v) => v === true, + 'simulation running' + ); + await A.page.waitForTimeout(1000); // wheels settle onto the ground + const start = await bodyOf(A.page); + await A.page.keyboard.down('W'); + await A.page.waitForTimeout(4000); + await A.page.keyboard.up('W'); + const end = await bodyOf(A.page); + const dist = Math.hypot(end.x - start.x, end.z - start.z); + h.check(dist > 0.8, `holding W drives the car (moved ${dist.toFixed(2)}m)`); + + // motion replicated to B + await h.eventually( + () => bodyOf(B.page), + (p) => p && Math.hypot(p.x - start.x, p.z - start.z) > 0.8, + 'the drive replicated to B', + 10000 + ); + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + + await h.finish(browser); +}); From ef6543ac23cf804f0c80839216ee72bd1812023b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 20 Jul 2026 09:57:13 +0300 Subject: [PATCH 20/20] [docs] update CLAUDE.md + skills for roadmap 12 - CLAUDE.md: architecture map entries for physics/joints/inputRuntime/ possess/handModels/terrainSculpt/sceneMusic/shadowDefaults/palette/ viewMode + new core modules (avatar/essentials/car) and HUD overlays; raw-bytes meshgeo wire format + binarypack trap in golden rule 6; adopted-inbound-conn note in rule 9; new gotchas (connect-dance fix, rapier quaternion-compare/kinematic-wake/slerp-substep/accumulator traps, thumbnail time-box, import-cycle map); Module SDK section gains the 12 api additions (input/physics/possess, kind-from-NAME rule); status: roadmap 12 SHIPPED, baseline 501/77 - e2e-verify skill: __stores list refreshed (+viewModeCtl-vs-viewMode naming trap); B-to-A messaging works + freshReload id-reread pattern; real-time physics note; machine-saturation symptoms and cures; binarypack large-array trap; pre-existing flakes list + git-stash proof technique; baseline 501/77 - peer-feature skill: raw-bytes rule for large numeric payloads; joints/sceneMusic/handModels reference patterns (own singleton message, annotations-style def list, identity-hash + assetShare); car = blessed input-forwarding authoritative recipe; input claims are LOCAL; SDK api surface additions with worked examples Co-Authored-By: Claude Fable 5 --- .claude/skills/e2e-verify/SKILL.md | 49 ++++++++--- .claude/skills/peer-feature/SKILL.md | 63 ++++++++++---- CLAUDE.md | 120 +++++++++++++++++++++++---- 3 files changed, 189 insertions(+), 43 deletions(-) diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index 2676f8cb..da890116 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -31,14 +31,19 @@ The init script (helpers does it) sets `localStorage.debugStores='true'` + `hasSeenDisclaimer='true'`. App.svelte then publishes `window.__stores` = all stores spread + modules: `meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, -drawMode, pathCapture, lockControl, prefabs, physics, userModules, environment, -animatedImports, fileHandler, sceneBounds, cameraClip, ping, sessions, geometryEdit, -lightParams, themes, vrRadialMenu, vrPalette, vrWindowPoses, vrKeyboard, faceEdit, -avatarModel, explorer, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, -sceneAssets, THREE, GLTFExporterModule, snapping, flowSockets, networkQuality, packs, -customNodes, nodesHandler, objectMenu`. Also on the stores spread: `viewportMenuOpener` -(Scene registers its context-menu opener here — call `$viewportMenuOpener(x,y,forceEmpty)` -to open the viewport/create menu without a right-click). +drawMode, pathCapture, lockControl, prefabs, physics, joints, possess, handModels, +terrainSculpt, userModules, environment, sceneMusic, animatedImports, fileHandler, +sceneBounds, cameraClip, ping, sessions, geometryEdit, lightParams, shadowDefaults, +palette, viewModeCtl, inputRuntime, shortcutsRegistry, themes, vrRadialMenu, +vrPalette, vrWindowPoses, vrKeyboard, faceEdit, avatarModel, explorer, bottomDock, +explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets, THREE, +GLTFExporterModule, snapping, flowSockets, networkQuality, packs, customNodes, +nodesHandler, nodeCatalog, objectMenu`. Naming trap: `__stores.viewMode` is the +STORE (from the sceneStore spread); the viewMode MODULE is `viewModeCtl` — a module +key that shadows a same-named store silently breaks tests (#12 lesson). Also on the +stores spread: `viewportMenuOpener` (Scene registers its context-menu opener here — +call `$viewportMenuOpener(x,y,forceEmpty)` to open the viewport/create menu without +a right-click). **Never dynamic-import `/src/lib/x.js` from page code to reach a singleton** — once vite HMR-timestamps the app's copy you get a SECOND module instance (empty stores, @@ -80,13 +85,35 @@ Use `https://theprototype.app:5173/` — hosts-mapped to 127.0.0.1; the `.app` h makes peerjs use the **public cloud** (localhost tries ws://localhost:9001 and fails). `helpers.connect(B, A)` does: fill peer id → Connect → Approve on A → ~9s settle. Late joiners: connect a third context AFTER mutations, assert handshake state arrived -(objects/nodes/annotations/module state/env/custom defs). Voice: launch with -`--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`. +(objects/nodes/annotations/joints/module state/env/music/handmodel/custom defs). +Voice: launch with `--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`. +**B→A messaging works** since #12 (the adopted-inbound-conn fix) — tests may drive +mutations FROM the joiner (peer move streams, claims). When a suite needs the +dual-module-instance split collapsed, `freshReload(peer)` BEFORE `connect` and +re-read the id (`peer.id = await …peers.subscribe…peer.id`) — a reload mid-mesh +drops the P2P session. ## Known flakes / traps - First run after adding a dependency: vite re-optimizes and reloads mid-test — rerun. Lazy wasm (rapier) needs a throwaway prewarm page first (see physics.test.cjs). + Physics sims run REAL-time since #12 (fixed-timestep accumulator) — falls/settles + take wall-clock seconds even under a throttled rAF; don't compensate with huge waits. +- **Machine saturation**: headless pages can run at ~4fps with timers ~1.8x slow when + the host is loaded (dozens of user Chrome processes — do NOT kill them). Symptoms: + timeouts on waits that "always worked", missed one-shot flag reads. Cures: generous + `eventually` windows, BEHAVIORAL asserts (did the box move) over one-shot state + reads, and for flags that flicker (hold/claim booleans) an IN-PAGE sampling loop + (`setInterval` 50ms inside one `evaluate`) instead of round-trip polling. +- **Large plain-number arrays blow binarypack**: `conn.send` with a ~40k-element plain + array throws "Maximum call stack size exceeded" — and `broadcast()`'s try/catch + SWALLOWS it, so the message silently never leaves. Send raw bytes instead + (`new Float32Array(arr).buffer`) and normalize on receive (meshgeo/terrain do this). + If a big payload "never arrives" in a test, suspect this before the network. +- Pre-existing flakes (reproduce on a clean base — don't chase them into your diff): + add-menu search-Enter, sound-node Play overlap, connect-overlay querySelector, + scene-music byte-push timing. To PROVE a failure is pre-existing: + `git stash push -u`, run the suite on HEAD, `git stash pop`. - Phase-comparison asserts between two peers: two sequential evaluates skew ~150ms — tolerances ≥0.6 for fast oscillations, or compare Promise.all-sampled values. - Overlays intercept clicks (properties drawer covers right ~320px; modals block all; @@ -159,7 +186,7 @@ Late joiners: connect a third context AFTER mutations, assert handshake state ar (the runner just `node`s each file; see net-backoff.test.cjs). Track PASS/FAIL locally and `process.exit(1)` on failure (helpers.finish needs a browser). - svelte-check delta hunting: `npx svelte-check --output machine | grep `; - baseline 2026-07-18 = **502 errors / 77 warnings** (drifts down as flowbite/typed + baseline 2026-07-19 = **501 errors / 77 warnings** (drifts down as flowbite/typed code is removed — hold whatever it currently is; add no NEW). Note: in the big JS-mode `.svelte` files (Scene.svelte) `@param {T}` JSDoc on a function is NOT honored — give the param a default (`slot = 0`) to force the type, and prefer explicit locals diff --git a/.claude/skills/peer-feature/SKILL.md b/.claude/skills/peer-feature/SKILL.md index cca3969e..7c9b1a19 100644 --- a/.claude/skills/peer-feature/SKILL.md +++ b/.claude/skills/peer-feature/SKILL.md @@ -17,9 +17,12 @@ in the codebase — copy the referenced implementation. animated-import mixers. Seeded randomness only (`mulberry32`); no accumulation in effects (compute from `base` + `time`); no `Math.random()` in anything replicated. - **Authoritative** — one peer simulates and broadcasts results as plain messages; - others just apply. References: physics (initiator → `move` at ~10 Hz per awake body, - busy-guard message), pong (spawner owns the ball at ~12 Hz). Use when simulation - can't be deterministic; guard against two authorities. + others just apply. References: physics (initiator steps the world, movement-gated + `move` broadcasts, busy-guard message), pong (spawner owns the ball at ~12 Hz), the + car module (the blessed INPUT-FORWARDING recipe: every peer sends its inputs + `{op:'drive', throttle, steer}` at ~20 Hz, only the `api.physics.isInitiator()` + peer applies motors — result replicates as plain moves). Use when simulation can't + be deterministic; guard against two authorities. ## Not everything replicates — some state is deliberately LOCAL @@ -29,25 +32,38 @@ prune it in `handleDisconnected` (or derive the UI from live peers so stale entr can't render). References: `networkQuality.js` (per-peer RTT/relay from `getStats()`, polled locally), the Explorer **pack library** (imported packs stay local until an explicit future "Share"; only a *placed* object replicates through the normal import -path), and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of -thumb: if two peers would independently compute the same value, or it's a personal -setting, keep it off the wire. +path), input claims (`inputRuntime.claimInput` — a claim only pauses THIS peer's own +input consumers, nothing on the wire), view mode/shadow quality/sculpt brush prefs, +and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of thumb: +if two peers would independently compute the same value, or it's a personal setting, +keep it off the wire. ## Checklist for a new replicated feature 1. **State** in a store (`src/stores/*`) or module-level writable; uuid/id-keyed, plain-serializable (peerjs binarypack: ArrayBuffers OK — raw-bytes syncs like - `objectfile` ride on this; no class instances/functions). + `objectfile` ride on this; no class instances/functions). **Large numeric payloads + MUST go as raw bytes**: a plain array of ~40k numbers makes binarypack recurse to + "Maximum call stack size exceeded" — and `broadcast()`'s try/catch swallows it, so + the send silently vanishes. Send `new Float32Array(arr).buffer` and normalize + array/ArrayBuffer/typed-view on receive (meshgeo is the reference). 2. **Local mutation function** applies + broadcasts: `get(peers)?.send({ type: 'mything', ... })` (pattern: `annotationsHandler`). 3. **Receive case** in `peerHandler.svelte.js` `conn.on('data')` — applier does NOT re-broadcast. 4. **Late joiners**: `getmything` request in `sendHandshake()` + a full-state reply - that retries until `conn.open` (`sendNodes`/`sendNodeDefs`/`sendModuleStates` — + that retries until `conn.open` (`sendNodes`/`sendJoints`/`sendModuleStates` — never bare `setTimeout` sends: peerjs silently drops pre-open messages). Singleton - state (environment) instead pushes with a `changedAt` stamp, latest-wins — and any - symmetric pull needs a deterministic direction (nodesync: lower count pulls, - peer-id tiebreak) or drifted peers swap forever. + state (environment, sceneMusic) instead pushes with a `changedAt` stamp, + latest-wins — each singleton gets its OWN message type (music deliberately does + NOT piggyback on `environment` because env state round-trips through preset + export/import and would leak the track into presets) — and any symmetric pull + needs a deterministic direction (nodesync: lower count pulls, peer-id tiebreak) + or drifted peers swap forever. A replicated LIST of small defs (joints) copies + the annotations pattern: create/delete messages + full-list handshake reply + + sender-side delete-cascade + a presence-style history kind. Per-peer IDENTITY + choices (avatar photo, hand model) broadcast a content HASH with presence/ + userdata and receivers pull the bytes via assetShare (`handModels.js`). 5. **Where does it live in the scene?** `objectsGroup` children = replicated, listed, GLTF-synced, anyone edits. Scene-root groups (fixed `name`) = local/derived — helpers, env rig, module content; rebuild them from state; they need @@ -74,12 +90,13 @@ Throttle continuous streams (~10–20/s) with a final unthrottled send on gestur peers get stale-expiry cleanup (`drawlive` 5s, ping 4s). **Geometry/topology changes** can't ride a per-vertex channel — snapshot the FULL -geometry (`meshgeo`: uuid + positions array, size-capped ~45k floats, `faceEdit.js`). +geometry (`meshgeo`: uuid + positions, size-capped ~45k floats, `faceEdit.js`; the +WIRE format is raw `Float32Array.buffer` bytes per the binarypack rule above). Receivers swap the geometry wholesale, the history kind replays the same snapshot, and the receive applier must REBUILD any live edit-session caches (applyMeshGeo re-derives -its face groups — a stale cache after undo/remote swap corrupted gestures once). Live -reshape gestures stream throttled previews (~5/s) and commit ONE snapshot + undo entry -on release. +its face groups AND the terrain sculpt weld map — a stale cache after undo/remote swap +corrupted gestures once). Live reshape gestures stream throttled previews (~5/s) and +commit ONE snapshot + undo entry on release. ## Adding a VR panel (the follower-window pattern) @@ -113,8 +130,20 @@ entry)` (replicated `/create `), `registerClickHandler(fn(hitObject) => bo (desktop + VR trigger), `registerInteractiveGroup(name)`, `registerFrameTask(fn(time))`, `send(payload)`/`onMessage(fn)` (namespaced `{type:'module', moduleId}`), `registerStateSync({getState, applyState})` (late joiners), `registerMenu(label, fn)` -(renders on the module's manager card), accessors `scene() objectsGroup() peerId() -toast() now() THREE assetUrl(path)`. +(renders on the module's manager card), `registerVRMenuEntry({id, group, label, +action, closes})` (VR radial sector), accessors `scene() objectsGroup() peerId() +toast() now() THREE assetUrl(path) selectedUuid()`. #12 additions (reached via PRIMED +dynamic imports in moduleSDK — static edges close TDZ cycles): **input** — +`registerBindings` (Settings ▸ Shortcuts listing), `input()` snapshot +`{codes:Set, axes, vrButtons}`, `onInput(fn)`, `claimInput/releaseInput('keys'| +'locomotion')` (pauses the host's OWN consumers, LOCAL, always release); **physics** +— `api.physics.{isInitiator, applyImpulse, setJointMotor, joints()}` (mutations +initiator-only — forward inputs, see the authoritative car recipe above); +**possess** — `possess(uuid, {camera:'chase'|'orbit'|'none'})`/`releasePossess()` +(possessing = selecting = the lock; ONE undo per ride). A module KIND peers must +agree on derives from the replicated object NAME (car's 'Carbody'), never +locally-set userData. Worked examples: `src/modules/essentials/` (interactables) + +`src/modules/car/` (physics + input + claims). Version trust: peers exchange `[{id, version}]` on connect and toast on mismatch (advisory). Module viewport content = scene-root group rebuilt from state (see rule 5); diff --git a/CLAUDE.md b/CLAUDE.md index b54e97a5..57a738f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,35 @@ loadable play content. Everything a user does must be visible to connected peers #9 `Socket.svelte` wraps the xyflow Handle and paints `typeColor` by socket type; audit + verdicts in committed `NODES.md`), `objectMenu` (#9: `buildObjectMenuItems` — ONE object context menu shared by Controls' direct menu + ViewportMenu's "Selected" - submenu), `moduleSDK` + `userModules` (zip/URL installs), `physics` (rapier, - initiator-authoritative), `environment` (presets + scene-root rig, latest-wins sync, - `passthroughActive` local sky lift), `animatedImports` (raw-bytes objectfile sync), + submenu; #12: selection-aware — counted labels act on the SET, Group selection, + Physics ▸ Weld/Hinge, Sculpt terrain), `moduleSDK` + `userModules` (zip/URL installs), + `physics` (#12 rework: rapier steps as a flowRuntime **post-tick hook** — + flow poses → kinematic targets → step → write-back; fixed-timestep accumulator + (1/60, ≤8 substeps) so sim time tracks REAL time under throttled rAF; flow-animated + objects = KINEMATIC bodies w/ slerp-interpolated substep targets; dynamics have + sleep OFF + movement-gated broadcasts; drag/throw via holdBody/releaseBody; external + writes detected by write-back DEVIATION → 250ms kinematic hold; hull colliders + opt-in via userData.physics; Inspector Physics section; SimControls HUD + `P`) + + `joints` (#12: replicated sceneJoints defs — weld/revolute+motor, OBJECT-local + anchors → body-local at sim start, jointcreate/delete + getjoints handshake, + 'joint' history kind, sender-side delete cascade, sessions persist), + `inputRuntime` (#12: store-only SDK input — key codes + VR axes published by + vrControls, claims 'keys'/'locomotion' gate PointerLockControls/editorNavigation/ + VR stick; module bindings list in Settings), `possess` (#12: tank-controls drive of + any object + chase/orbit camera; possessing = selecting; ONE undo per ride), + `handModels` (#12: custom hand GLB = IDENTITY — hash on `handmodel` msg + handshake, + assetShare pull, rigid-at-wrist render), `terrainSculpt` (#12: brush raise/lower/ + smooth/flatten over the meshgeo channel; weld by quantized (x,z) COLUMNS, rebuilt in + the applyMeshGeo hook; one snapshot+undo per stroke; SculptToolbar pill), + `sceneMusic` (#12: ONE shared background track, latest-wins `music` singleton — + NOT piggybacked on environment; synced-clock loop offset; LOCAL volume/mute overlay), + `shadowDefaults` (#12: objectsGroup-sweep sets cast/receiveShadow on every mesh; + opt-out = userData.shadow=false) + `palette` (#12: paletteColorFor(uuid) deterministic + default colors) + `viewMode` (#12: LOCAL Shaded/Shaded+AO/Wireframe; + wireframe = scene.overrideMaterial, never per-material), + `environment` (presets + scene-root rig, latest-wins sync, + `passthroughActive` local sky lift; #12: sun casts w/ scene-fit frustum + + env-shadow-catcher ShadowMaterial disc), `animatedImports` (raw-bytes objectfile sync), `prefabs` (local IndexedDB library), `explorer` (LOCAL asset library: IndexedDB index + per-item blobs, content hashes, thumbnails) + `explorerDrop` (drag-out placement/ texturing) + `assetShare` (assetfile/getasset hash push+pull → 'Shared' folder) + @@ -70,14 +96,20 @@ loadable play content. Everything a user does must be visible to connected peers takes `{assets,packs,flow}` include-opts, adds a `packs/` section; `fileHandler` saves/ loads it, Sidebar Files = [GLTF | Scene | ⚙cog]), `measure`, `cameraBookmarks`, `editorNavigation`, `lightHelpers`. -- `src/modules/` — core modules (hello, button, dungeon, piano, pong) + `index.js` - `coreModules` list; manager enables/disables (live enable, reload to disable). +- `src/modules/` — core modules (hello, button, dungeon, piano, pong; #12: avatar = + possess-selected, essentials = 6 clickable interactables whose KIND derives from the + replicated object NAME, car = jointed drivable demo w/ click-claim + drive-op + forwarding) + `index.js` `coreModules` list; manager enables/disables (live enable, + reload to disable). - UI: `components/menu/*` (drawers/modals; visibility via stores + `hidePanels/ restorePanels`), `components/editors/*` (flow editor + CodeMirror panels), `components/play/*` (player, avatars — photo = billboard card; the VR follower panels: Menu/ObjectsPanel/PropertiesPanel/ColorPalette/PrefabsPanel/Keyboard/ ChatPanel/Stats — named `vr-*` control meshes, all grip-grabbable), - scene-overlay components (PingMarkers/PathWaypoints/LockHighlights), shared + scene-overlay components (PingMarkers/PingHighlights (#12: uuid-carrying pings flash + an object box)/PathWaypoints/LockHighlights), `SimControls`/`SculptToolbar` (#12: + runes-mode HUD pills — the MobileAddButton "own file so onclick doesn't mix with + on:" precedent), shared `ContextMenu.svelte` (caps to viewport + scrolls vertically when tall, never horizontally; per-submenu flip via left/right/top/bottom — no transform), `components/shared/WindowShell.svelte` (197: reusable window CHROME — collapsible/ @@ -107,10 +139,15 @@ loadable play content. Everything a user does must be visible to connected peers 6. Content that can't round-trip (skinned rigs) replicates as its **original file bytes** (`objectfile`), not through the per-node exporter (GLTFExporter is lossy and `sendObject` splits children, destroying rigs). Topology edits snapshot the FULL - geometry (`meshgeo` positions array, size-capped) — receivers swap it wholesale, and + geometry (`meshgeo`, size-capped 45k floats) — receivers swap it wholesale, and the applier must REBUILD any live edit-session caches (applyMeshGeo re-derives face - groups; a stale cache after undo/remote swap bit us). Live gestures stream throttled - previews (~5/s) and commit ONE final snapshot + undo entry. + groups + the sculpt weld map; a stale cache after undo/remote swap bit us). Live + gestures stream throttled previews (~5/s) and commit ONE final snapshot + undo entry. + **Big numeric payloads travel as RAW BYTES** (`new Float32Array(arr).buffer`), never + plain number arrays: binarypack recurses per element and a ~40k-number array throws + "Maximum call stack size exceeded" — which `broadcast()`'s catch SWALLOWS, so the + message silently never leaves (#12; large face-edits never replicated). applyMeshGeo + normalizes plain array / ArrayBuffer / typed-array view. 7. Singleton shared state (environment) syncs latest-wins via a `changedAt` stamp; symmetric pulls need a deterministic direction (nodesync: lower count pulls, peer-id tiebreak) or two drifted peers swap forever. @@ -122,7 +159,9 @@ loadable play content. Everything a user does must be visible to connected peers joiners/restores without handshake dumps. REPLY over your stable OUTGOING `peer.connections[peerId]`, never the incoming conn (it can be a stale duplicate from the connect dance). binarypack delivers Uint8Array **views** — slice - byteOffset..byteLength before hashing. + byteOffset..byteLength before hashing. #12: `connections[peerId]` may legitimately + BE an adopted inbound conn (see the connect-dance gotcha) — it's still the stable + channel; DataConnections are bidirectional and OUTGOING conns are wireData'd too. 10. Serializers (sendObjects, GLTF save, autosave, sessions) must `parkAnimatedAtBase()` first or receivers bake mid-swing poses as animation base; `restoreBase` calls `updateMatrix()` because toJSON/GLTFExporter read the matrix @@ -262,6 +301,28 @@ loadable play content. Everything a user does must be visible to connected peers init value is a truthy empty array. - The Bash tool's `cd` leaks into the shared shell cwd — `Set-Location` back to the repo root before PowerShell git/npm calls. +- **Connect dance (#12 fix)**: the host CLOSES the joiner's original conn pre-approval; + real WebRTC often never signals that close, and a fresh reopen can wedge mid-ICE — + the JOINER could never send anything to the host. peerHandler now ADOPTS an open + inbound conn as the send channel when the outgoing one is dead, wires the data + dispatcher (`wireData`) on OUTGOING conns too (the remote may talk back over them), + and `restoreConnection` retries with backoff after closing the stale conn first. +- **Physics ↔ rapier traps (#12)**: comparing quaternions with `dot()` reads |q|² — + rapier's f32 components leave the norm ~1e-9 off unit, so "unchanged" looks like a + deviation (compare COMPONENT-WISE). A kinematic platform moving UNDER a sleeping + dynamic body never wakes it — dynamics run `setCanSleep(false)` + movement-gated + broadcasts instead of `isSleeping()` gating. Kinematic substep targets must be + SLERP-INTERPOLATED per substep: feeding only the end pose gives full velocity on + substep 1 and zero after, so friction alternately drags and brakes (no net fling). + Sim speed must come from a fixed-timestep ACCUMULATOR — a per-frame dt clamp runs + slow-motion whenever rAF is throttled (background/headless tabs). +- Explorer `addItemFromBytes` TIME-BOXES its decorative thumbnail (Promise.race 4s) — + a wedged/slow GLB parse on the receiver used to silently block storing SHARED bytes. +- Static-import cycle map grew in #12: objectActions now imports geometries + (createGroup) and joints; multiTransform/objectMenu reach physics/terrainSculpt + DYNAMICALLY; moduleSDK reaches inputRuntime/physics/possess via PRIMED dynamic + imports (module-level refs resolved at boot). When adding an SDK capability, assume + a static edge into moduleSDK's consumers closes a cycle (flowRuntime → moduleSDK). - The dungeon module publishes gameplay data on its group's `userData.play` (grid/rooms/floorValue) — `dungeonPlay.js` consumes it; keep that contract stable. @@ -290,7 +351,26 @@ Two-peer tests run over the public PeerJS cloud via `https://theprototype.app:51 reposition on narrow; don't blanket-revert them anymore.) - VR phases: verify math/state headlessly, state clearly that on-device feel is the user's manual check. -- Status (2026-07-18): **Roadmap #9 SHIPPED** (release runway). B2 VR: 120Hz +- Status (2026-07-20): **Roadmap #12 "playground & polish" SHIPPED — ALL 19 phases** + on `feature/playground-polish` (off ai-scene-assistant; NOT merged). Opus set (11): + V-1 shadows-by-default + catcher, V-3 palette (kills 0x00ff00) + look tune, T-1 + Add▸Terrain, U-2 multi-select menu (groupSelection one-undo, multi prefab/delete, + desktop Ungroup), U-1 ping v2 (uuid object-highlight + radial Ping), R-2 VR + snap-angle unify + live labels, R-1 VR beam+reticle+hover shell, M-2 audio-pack + install + sound rolloff, M-1 sceneMusic singleton, V-2 N8AO + viewModes, U-3 toast + dedupe/cap + settings search. Fable set (8): P-A physics rework (post-tick hook, + kinematic flow bodies, accumulator, deviation holds, hulls, Inspector Physics, + SimControls), K-C SDK inputRuntime + claims + api.physics, P-B joints + (weld/hinge/motors + menu + sessions), K-D possess + avatar module, K-E essentials + (6 interactables), R-3 hand models (capsule style + GLB identity), T-2 terrain + sculpt (weld columns + smooth normals + SculptToolbar), K-F drivable car + (click-claim + drive-op forwarding; ~14m in e2e). THREE deep pre-existing bugs + fixed: joiner-cannot-send-to-host (adopted inbound conn), meshgeo big-array + binarypack stack overflow (raw-bytes wire format), Explorer thumbnail hang blocking + shared bytes. svelte-check ended **501/77** (new baseline — hold it). Plan: + docs/plan/roadmap-12-playground-polish.md (per-phase hashes). Backlog'd: articulated + hand retargeting, steered knuckles, VR sculpt, joint-clone-on-duplicate. + --- Earlier — Status (2026-07-18): **Roadmap #9 SHIPPED** (release runway). B2 VR: 120Hz (session.updateTargetFrameRate off supportedFrameRates on session start; vrTargetHz setting) + hands↔controllers switch fix (shouldSendHands forces a send on rep-flip — the `!moved && !hasJoints` gate ate the switch-back) + cuboid peer hands @@ -346,7 +426,17 @@ register(api)}`. api surface: registerNodeGroup (+custom components), registerEf (base-managed per-frame), registerPrimitive (replicated `/create`), registerClickHandler (desktop+VR), registerInteractiveGroup (scene-root click targets), registerFrameTask, send/onMessage (namespaced `{type:'module', moduleId}`), registerStateSync (late-joiner -handshake), registerMenu (manager card buttons), scene/objectsGroup/peerId/toast/now/ -THREE/assetUrl. User modules (zip/URL via the manager) must be self-contained — no -imports; guide in `MODULES.md` + `docs/sdk/`. Script nodes run arbitrary replicated -code deterministically (pure function of object/base/data/time) — never stream outputs. +handshake), registerMenu (manager card buttons), registerVRMenuEntry, +scene/objectsGroup/peerId/toast/now/THREE/assetUrl/selectedUuid. #12 additions: +**input** — registerBindings (lists in Settings ▸ Shortcuts), input() per-frame +snapshot {codes, axes, vrButtons}, onInput down/up events, claimInput/releaseInput +('keys'|'locomotion' pause the host's own consumers); **physics** — +api.physics.{isInitiator, applyImpulse, setJointMotor, joints()} (mutations are +INITIATOR-ONLY: forward inputs via api.send and let the stepping peer apply — the car +module is the worked recipe, pong's paddle pattern); **possess/releasePossess** +(tank-controls drive + follow camera; possessing = selecting). A module KIND that +must agree across peers derives from the replicated object NAME, never locally-set +userData (essentials + car). User modules (zip/URL via the manager) must be +self-contained — no imports; guide in `MODULES.md` + `docs/sdk/`. Script nodes run +arbitrary replicated code deterministically (pure function of object/base/data/time) +— never stream outputs.