From 2e84c189015fc93ab01a720fc8b3e586b8c1deb7 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 5 Sep 2026 21:14:44 +0300 Subject: [PATCH 01/17] [feat] 24-A A1: the knock - a hand or a walking player hits a dynamic body Core could grab and throw, and a node could push a body, but nothing let a hand or a walking player HIT an object and have it leave at the speed it was hit - VR hands were grab-only, On Impact only sees falling contacts, and the character capsule never touches anything. The knock is that one seam, and both roadmap 24 games (Stars Room, VR Football) stand on it. - knockMath.js (a LEAF: THREE + throwVelocity) - the probe ring, the sphere-vs-bounding-sphere contact test with the approach speed along the normal, the response v' = v_body + n * approach * gain (an infinite-mass hand), the spin off the TANGENTIAL slip (a sphere contact is always central, so the plan's normal-impulse cross product is zero by construction), one clamp (clampThrow, then the scene's maxSpeed below it), and the one-knock- per-pass cooldown with a 60 ms hysteresis on the way back in. - knock.js (the runtime, playInteract's shape) - VR hands through a seam Scene passes in (handSnapshot; knock.js never imports vrControls), the desktop camera as a 0.35 m head probe, both carried into the objects group's frame so a bent VR rig cannot put a hand and a ball in two spaces; candidates from listPhysicsObjects (refreshed every 200 ms for spawned bodies) minus what this peer carries; body velocity exact off the initiator's rapier body, a pose ring off the move stream elsewhere; the `hit` message from the HITTER whoever it is; the non-initiator's local PREDICTION behind knock.predict, withdrawn after 400 ms if authority never confirms it; the hit log (last per body + a ring of 32, runtime state, no history kind, no handshake reply) and registerHitListener, the seam A2's onhit and api.onHit hang on; feedProbe, the test hook that sweeps a probe on its own clock. - physics.applyHit - the throw's sibling: initiator-only, clampThrow plus the scene's maxSpeed, CCD over 5 m/s, a held body refuses; bodyVelocityOf. - scenePhysics gains the ADDITIVE `knock` nested block, enabled:false by default, so Towers and every saved scene are byte-identical (the suite's counterfactual). Zero new singleton, zero handshake work. - `hit` is CONTENT: gateable by canApply like `throw`, ROOM_SCOPED like `move`, `by` stamped from the connection, never the payload. knock-physics (77 checks, node-pure + one page + two peers): monotonic in probe speed, gain, maxSpeed, receding/resting/slow do nothing, one per pass, held bodies skipped, the log, the block-off counterfactual, the play and sim gates, the wire crossing with the same stamp on both logs, prediction on and off, the capability gate dropping it and the prediction reverting, the initiator's own knock reaching the other log. scene-physics-state grows the block's defaults, clamps, merge and the pre-knock-file restore. STATUS-24a.md carries the as-built and the one finding worth a follow-up: a late joiner is never told a sim is running (`simulate` goes out at start/stop, not in the handshake), so its probes - and play-mode grab - stay down until the sim restarts. Pre-existing; the suite starts the sim after the join. svelte-check 362/47 (the pristine baseline, re-measured here first). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H2ike21X4j6x1eKZTXJjia --- src/App.svelte | 5 +- src/components/Scene.svelte | 13 +- src/lib/knock.js | 587 +++++++++++++++++++++++++ src/lib/knockMath.js | 230 ++++++++++ src/lib/peerHandler.svelte.js | 15 +- src/lib/peerScenes.js | 2 +- src/lib/physics.js | 51 ++- src/lib/playInteract.js | 6 + src/lib/scenePhysics.js | 30 +- src/lib/throwVelocity.js | 4 +- src/lib/vrControls.js | 5 + tests/e2e/knock-physics.test.cjs | 559 +++++++++++++++++++++++ tests/e2e/scene-physics-state.test.cjs | 50 +++ 13 files changed, 1548 insertions(+), 9 deletions(-) create mode 100644 src/lib/knock.js create mode 100644 src/lib/knockMath.js create mode 100644 tests/e2e/knock-physics.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 118d45a5..4c6c9791 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -265,6 +265,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/scenePhysics'), import('./lib/playInteract'), import('./lib/moveSmoothing'), + import('./lib/knock'), import('./lib/playSettings'), import('./lib/colliderSpec'), import('./lib/colliderHelpers'), @@ -368,8 +369,8 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/inviteLinks'), import('./lib/helperLayer'), import('./lib/explorerClipboard') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib } }) } }) diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 44a07b66..10d75f2c 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -28,7 +28,8 @@ import { holdBody, releaseBody } from '$lib/physics'; import { sculptObject, enterSculpt, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; import { sceneHits } from '$lib/scenePick'; - import { startPlayInteract, tickPlayInteract, stopPlayInteract } from '$lib/playInteract'; + import { startPlayInteract, tickPlayInteract, stopPlayInteract, carriedUuid } from '$lib/playInteract'; + import { startKnock, tickKnock, stopKnock } from '$lib/knock'; import { tickMoveSmoothing } from '$lib/moveSmoothing'; import { moduleClickHandlers, moduleInteractiveGroups, fireClickMiss } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; @@ -49,7 +50,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid } from '$lib/vrControls'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -260,6 +261,9 @@ // 21-B B3: play-mode grab/carry. The ray is NDC (0,0) every frame, so it // belongs in the frame loop rather than on a pointer event. tickPlayInteract(delta, camera.current); + // 24-A A1: the knock probes (hands in VR, the camera on desktop) against every + // dynamic body — inert unless the scene's knock block is on and a sim runs + tickKnock(performance.now(), camera.current); // 21-B: ease between a remote peer's ~10 Hz physics poses (no-op unless a // remote peer is simulating and something is mid-ease) tickMoveSmoothing(); @@ -1239,6 +1243,10 @@ // 21-B B3: play mode's own input path. Registered HERE, below every `let` // its closure reads (runModuleClickHandlers among them) — the TDZ rule. startPlayInteract({ moduleHitTest: runModuleClickHandlers }); + // 24-A A1: the knock's feeds. The VR hand poses come from vrControls through + // this seam rather than an import (knock.js stays off vrControls' 3500 lines), + // and the two "what am I holding" reads keep a probe off its own carried object. + startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()] }); xrControllers.forEach((controller) => { controller.addEventListener('select', onXRSelect); @@ -1251,6 +1259,7 @@ return () => { offEditResume(); // #20 P5 stopPlayInteract(); // 21-B B3 (releases any carried body with zero velocity) + stopKnock(); // 24-A A1 element.removeEventListener('pointerdown', onPointerDown); element.removeEventListener('contextmenu', onContextMenu); window.removeEventListener('pointerup', onPointerUp); diff --git a/src/lib/knock.js b/src/lib/knock.js new file mode 100644 index 00000000..3fec1ac4 --- /dev/null +++ b/src/lib/knock.js @@ -0,0 +1,587 @@ +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { isLocked, isVRMode, objectsGroup } from '../stores/sceneStore'; +import { peers } from '../stores/appStore'; +import { sceneKnock } from './scenePhysics'; +import { + listPhysicsObjects, + bodyVelocityOf, + applyHit, + simulating, + remoteSimulating, + isInitiator +} from './physics'; +import { velocityFromSamples } from './throwVelocity'; +import { + HEAD_PROBE_RADIUS, + PREDICT_MAX_MS, + BODY_WINDOW_MS, + createProbe, + pushSample, + probeVelocity, + probePosition, + contactOf, + knockResponse, + cooldownStep, + markSpent, + pruneContacts, + localBoundsOf, + radiusScaleOf +} from './knockMath'; + +export { + HEAD_PROBE_RADIUS, + PREDICT_MAX_MS, + contactOf, + knockResponse, + cooldownStep, + markSpent, + createProbe, + pushSample, + localBoundsOf +} from './knockMath'; + +// 24-A A1: THE KNOCK, the runtime half. +// +// A player's hand (VR controller) or body (the desktop camera) is a PROBE SPHERE; +// when it overlaps a dynamic body while approaching it, the body's velocity gains the +// probe's approach speed along the contact normal, clamped, once per pass — and every +// peer sees the same result because the knock travels as an exact velocity message to +// the physics initiator, the way a throw already does (B5). The arithmetic lives in +// knockMath.js; this file is what the arithmetic is fed with and what it produces: +// +// feeds — the VR hands (handSnapshot, passed in by Scene: this module does not +// import vrControls) and the desktop camera, each in the OBJECTS GROUP's +// frame so a bent VR world rig cannot put a hand and a ball in two +// different spaces; plus `feedProbe`, the test hook that drives a probe +// through a body at exact speeds with its own clock. +// candidates — `listPhysicsObjects()` filtered to mode 'dynamic' (refreshed every +// 200 ms, the spawner creates bodies mid-run), minus whatever THIS peer is +// carrying; bounds cached per object until its shape or scale changes. +// body speed — exact off the initiator's rapier body; off the initiator, a ring of the +// poses this peer renders (the ~10 Hz move stream, eased by moveSmoothing), +// which is the same approximation the `velocity` node documents. +// the wire — `{type:'hit', uuid, linvel, angvel, point, speed, at, probe}` from the +// HITTER, whoever it is. The initiator applies its own hit straight into +// the body and still broadcasts, so every peer's log converges; a +// non-initiator broadcasts and PREDICTS (below). `by` is never carried: +// the receiver stamps `conn.peer`, the physicsExternalMove rule. +// prediction — the riskiest part of A1, behind `knock.predict`: the non-initiator +// advances the rendered object along the hit velocity until the next +// `move` for that uuid arrives (peerHandler calls endKnockPrediction, and +// moveSmoothing then eases from the predicted pose onto authority). A +// prediction authority never confirms — the body was held, the hit was +// dropped by a capability gate — is WITHDRAWN after PREDICT_MAX_MS, back +// to the pose it started from, rather than left stranded. +// the log — `hitLog`: the last hit per body plus a ring of the last 32, RUNTIME +// state (no history kind, no handshake reply: a late joiner starts empty +// and football's lastTouch rides its module's own registerStateSync). A2 +// hangs `onhit` and `api.onHit` on registerHitListener. +// +// The gate is playInteract's: probes exist only while this peer is IN PLAY (desktop +// pointer lock, or presenting in VR — VR has no isLocked), the scene's knock block is +// enabled, and a simulation runs somewhere. Off, nothing here runs and nothing here is +// on the wire, which is the counterfactual knock-physics measures. + +/** how often the dynamic-body set is re-derived (ms) */ +const CANDIDATE_MS = 200; +/** the hit ring the log keeps */ +const RECENT_HITS = 32; +/** samples kept per body for the off-initiator velocity estimate */ +const BODY_SAMPLES = 8; + +/** @type {Map} */ +const probes = new Map(); +/** @type {((hand: 'left'|'right') => any) | null} */ +let hands = null; +/** @type {(() => (string | null)[]) | null} */ +let heldUuids = null; +let started = false; +/** @type {Map} */ +const boundsCache = new Map(); +/** @type {{at: number, uuids: Set}} */ +let candidates = { at: -Infinity, uuids: new Set() }; +/** @type {Map} per-body pose rings (non-initiator) */ +const bodyTracks = new Map(); +/** @type {Map} */ +const predictions = new Map(); +/** @type {Set<(hit: KnockHit, local: boolean) => void>} */ +const hitListeners = new Set(); +/** @type {Map} the last hit per body */ +const lastHits = new Map(); +/** @type {KnockHit[]} */ +const recentHits = []; +let lastStamp = 0; +let sentCount = 0; + +/** + * @typedef {{uuid: string, linvel: number[], angvel: number[], point: number[], + * speed: number, at: number, by: string, probe: string}} KnockHit + */ + +/** bumps on every logged hit, so a derived view can react to a plain Map */ +export const hitTick = writable(0); +/** true while probes are armed (play + enabled + a sim somewhere) — debug/UI only */ +export const knockActive = writable(false); + +const _pos = new THREE.Vector3(); +const _quat = new THREE.Quaternion(); +const _groupQuat = new THREE.Quaternion(); +const _centre = new THREE.Vector3(); +const _bodyVel = new THREE.Vector3(); +const _bodyAng = new THREE.Vector3(); +const _zero = new THREE.Vector3(); + +/** the gate — playInteract's, plus the block switch and the VR half */ +function armed() { + const cfg = get(sceneKnock); + if (!cfg?.enabled) return false; + if (!get(simulating) && !get(remoteSimulating)) return false; + return get(isLocked) === true || get(isVRMode) === true; +} + +/** @param {number} now @param {any} group */ +function dynamicSet(now, group) { + if (now - candidates.at < CANDIDATE_MS && candidates.at <= now) return candidates.uuids; + /** @type {Set} */ + const set = new Set(); + for (const row of listPhysicsObjects()) if (row.mode === 'dynamic') set.add(row.uuid); + candidates = { at: now, uuids: set }; + // a body that left the scene takes its bookkeeping with it — pruned with the object + for (const uuid of [...boundsCache.keys()]) if (!set.has(uuid)) boundsCache.delete(uuid); + for (const uuid of [...bodyTracks.keys()]) if (!set.has(uuid)) bodyTracks.delete(uuid); + for (const uuid of [...lastHits.keys()]) + if (!group?.getObjectByProperty('uuid', uuid)) lastHits.delete(uuid); + for (const probe of probes.values()) pruneContacts(probe, set); + return set; +} + +/** + * A body's centre (in the objects group's frame) and scaled radius. Cached by a key + * that names the shape and the scale, so a spawned star costs one computation and a + * rescaled crate costs one more — never one per frame. + * @param {any} object + */ +function boundsOf(object) { + const key = `${object.geometry?.uuid ?? object.children.length}|${object.scale.x}|${object.scale.y}|${object.scale.z}`; + let entry = boundsCache.get(object.uuid); + if (!entry || entry.key !== key) { + const local = localBoundsOf(object); + entry = { key, center: local.center, radius: local.radius * radiusScaleOf(object) }; + boundsCache.set(object.uuid, entry); + } + object.updateMatrix(); + return { centre: _centre.copy(entry.center).applyMatrix4(object.matrix), radius: entry.radius }; +} + +/** Off the initiator, one observed pose per frame into the body's ring. ALWAYS on the + * page clock, whatever clock the probe that asked is on: a synthetic sweep (feedProbe) + * pushing its own `t` here would interleave two clocks in one ring, and the estimate + * would read a negative dt as a 20 m/s body outrunning every hand. + * @param {any} object */ +function trackBody(object) { + const now = performance.now(); + let ring = bodyTracks.get(object.uuid); + if (!ring) { + ring = []; + bodyTracks.set(object.uuid, ring); + } + ring.push({ t: now, pos: object.position.clone() }); + while (ring.length > BODY_SAMPLES) ring.shift(); + while (ring.length > 2 && now - ring[0].t > BODY_WINDOW_MS) ring.shift(); +} + +/** exact on the initiator, a move-stream estimate elsewhere @param {string} uuid */ +function bodyVelocity(uuid) { + const exact = isInitiator() ? bodyVelocityOf(uuid) : null; + if (exact) { + _bodyVel.fromArray(exact.linvel); + _bodyAng.fromArray(exact.angvel); + return { linvel: _bodyVel, angvel: _bodyAng, held: exact.held }; + } + const ring = bodyTracks.get(uuid); + if (ring && ring.length >= 2) _bodyVel.copy(velocityFromSamples(ring).linvel); + else _bodyVel.set(0, 0, 0); + _bodyAng.set(0, 0, 0); + return { linvel: _bodyVel, angvel: _bodyAng, held: false }; +} + +/** @param {string} id @param {number} radius */ +function probeFor(id, radius) { + let probe = probes.get(id); + if (!probe) { + probe = createProbe(id, radius); + probes.set(id, probe); + } + probe.radius = radius; + return probe; +} + +/** + * A hand or the camera, in WORLD, into the objects group's frame and onto its probe. + * The group is the rapier world's frame (every body is a top-level child), so a VR + * world rig that is bent or scaled cannot put the hand in one space and the ball in + * another; on desktop and in an unbent rig this is the identity. + * @param {string} id @param {number} radius @param {any} group + * @param {number[]} worldPos @param {number[] | null} worldQuat @param {number} now + */ +function feedWorldPose(id, radius, group, worldPos, worldQuat, now) { + _pos.fromArray(worldPos); + group.worldToLocal(_pos); + let quat = null; + if (worldQuat) { + group.getWorldQuaternion(_groupQuat).invert(); + quat = _quat.fromArray(worldQuat).premultiply(_groupQuat); + } + const probe = probeFor(id, radius); + probe.external = false; + pushSample(probe, _pos, quat, now); + return probe; +} + +/** + * Run the contact test for one probe against every candidate. Returns how many hits + * fired and, for the debug view, what it found. + * @param {import('./knockMath').Probe} probe @param {number} now @param {any} group + * @param {Set} dyn + */ +function evaluateProbe(probe, now, group, dyn) { + const cfg = get(sceneKnock); + const pPos = probePosition(probe); + if (!pPos) return { hits: 0, overlaps: 0 }; + const pVel = probeVelocity(probe); + const held = new Set((heldUuids?.() ?? []).filter(Boolean)); + let hits = 0; + let overlaps = 0; + for (const object of group.children) { + const uuid = object.uuid; + if (!dyn.has(uuid) || held.has(uuid)) continue; + const bounds = boundsOf(object); + const body = bodyVelocity(uuid); + if (body.held) continue; // somebody is carrying it: knocking it would fight their hold + const contact = contactOf(pPos, probe.radius, pVel, bounds.centre, bounds.radius, body.linvel); + const may = cooldownStep(probe, uuid, contact.overlap, now); + if (!contact.overlap) continue; + overlaps++; + // resting (s ~ 0) or receding / being outrun (s < 0): nothing, and the pair + // stays ARMED — a hand parked inside a ball that then shoves it still knocks + if (!may || contact.approach <= cfg.minSpeed) continue; + const response = knockResponse({ + bodyVel: body.linvel, + bodyAngvel: body.angvel, + probeVel: pVel, + n: contact.n, + approach: contact.approach, + bodyRadius: bounds.radius, + gain: cfg.gain, + spin: cfg.spin, + maxSpeed: cfg.maxSpeed + }); + const point = bounds.centre.clone().addScaledVector(contact.n, -bounds.radius); + if (fireKnock(probe, object, contact.approach, point, response)) { + markSpent(probe, uuid); + hits++; + } + } + return { hits, overlaps }; +} + +/** + * The hit leaves here. Initiator: into the body first, and a refusal (held, gone) + * sends nothing and spends nothing. Otherwise: onto the wire, predicted locally when + * the block says so. Either way it is logged HERE too — the sender never receives + * its own broadcast. + * @param {import('./knockMath').Probe} probe @param {any} object @param {number} speed + * @param {THREE.Vector3} point @param {{linvel: THREE.Vector3, angvel: THREE.Vector3}} response + */ +function fireKnock(probe, object, speed, point, response) { + /** @type {any} */ + const peer = get(peers); + const me = peer?.peer?.id ?? ''; + // monotonic per sender: two knocks in one millisecond must not share a stamp, + // because A2 keys the `onhit` pulse by it + lastStamp = Math.max(Date.now(), lastStamp + 1); + /** @type {KnockHit} */ + const hit = { + uuid: object.uuid, + linvel: response.linvel.toArray(), + angvel: response.angvel.toArray(), + point: point.toArray(), + speed, + at: lastStamp, + by: me, + probe: probe.id + }; + if (isInitiator()) { + if (!applyHit(hit)) return false; + } else if (get(sceneKnock).predict) { + startPrediction(object, response.linvel); + } + if (peer) { + sentCount++; + peer.send({ + type: 'hit', + uuid: hit.uuid, + linvel: hit.linvel, + angvel: hit.angvel, + point: hit.point, + speed: hit.speed, + at: hit.at, + probe: hit.probe + }); + } + noteHit(hit, true); + return true; +} + +/** @param {KnockHit} hit @param {boolean} local */ +function noteHit(hit, local) { + lastHits.set(hit.uuid, hit); + recentHits.push(hit); + while (recentHits.length > RECENT_HITS) recentHits.shift(); + hitTick.update((n) => n + 1); + for (const fn of hitListeners) { + try { + fn(hit, local); + } catch (error) { + console.log('knock: hit listener failed', error); + } + } +} + +/** @param {any} v @returns {number[]} */ +function arr3(v) { + if (Array.isArray(v)) return [Number(v[0]) || 0, Number(v[1]) || 0, Number(v[2]) || 0]; + return [0, 0, 0]; +} + +/** + * The receive side of `hit` (peerHandler, beside `throw`): the LOG half. The body half + * is physics.applyHit, called by peerHandler on its own line so the two stay + * independent of each other's outcome — every peer logs every hit it is shown, + * including the initiator when its body refused (a held crate), because a log that + * only the initiator edits would disagree with every other peer's. `by` is the + * connection's peer, never the payload's. + * @param {any} data @param {string} fromPeer + */ +export function noteRemoteHit(data, fromPeer) { + if (!data || typeof data.uuid !== 'string') return false; + const group = get(objectsGroup); + if (!group?.getObjectByProperty('uuid', data.uuid)) return false; + /** @type {KnockHit} */ + const hit = { + uuid: data.uuid, + linvel: arr3(data.linvel), + angvel: arr3(data.angvel), + point: arr3(data.point), + speed: Number.isFinite(Number(data.speed)) ? Number(data.speed) : 0, + at: Number.isFinite(Number(data.at)) ? Number(data.at) : Date.now(), + by: fromPeer ?? '', + probe: typeof data.probe === 'string' ? data.probe : '' + }; + noteHit(hit, false); + return true; +} + +/** A2's seam: `(hit, local) => void`, returns the unsubscribe. + * @param {(hit: KnockHit, local: boolean) => void} fn */ +export function registerHitListener(fn) { + hitListeners.add(fn); + return () => { + hitListeners.delete(fn); + }; +} + +/** the last hit a body took, or null @param {string} uuid */ +export function lastHitOf(uuid) { + return lastHits.get(uuid) ?? null; +} + +/** a copy of the log: the last hit per LIVE body, and the recent ring */ +export function hitLogSnapshot() { + const group = get(objectsGroup); + /** @type {Record} */ + const last = {}; + for (const [uuid, hit] of lastHits) + if (group?.getObjectByProperty('uuid', uuid)) last[uuid] = { ...hit }; + return { last, recent: recentHits.map((hit) => ({ ...hit })) }; +} + +// ---- prediction (non-initiator) ---------------------------------------------- + +/** @param {any} object @param {THREE.Vector3} linvel */ +function startPrediction(object, linvel) { + const now = performance.now(); + predictions.set(object.uuid, { + vel: linvel.clone(), + from: { pos: object.position.clone(), quat: object.quaternion.clone() }, + startedAt: now, + lastTick: now + }); +} + +/** @param {number} now @param {any} group */ +function tickPredictions(now, group) { + if (predictions.size === 0) return; + for (const [uuid, prediction] of [...predictions.entries()]) { + const object = group.getObjectByProperty('uuid', uuid); + if (!object) { + predictions.delete(uuid); + continue; + } + if (now - prediction.startedAt > PREDICT_MAX_MS) { + // authority never confirmed it: put the object back where it was + object.position.copy(prediction.from.pos); + object.quaternion.copy(prediction.from.quat); + predictions.delete(uuid); + continue; + } + const dt = Math.min(0.1, Math.max(0, (now - prediction.lastTick) / 1000)); + prediction.lastTick = now; + object.position.addScaledVector(prediction.vel, dt); + } + objectsGroup.update((value) => value); +} + +/** peerHandler, on every incoming `move`: authority has spoken for this body, so the + * prediction ends and moveSmoothing eases from wherever it left the object. + * @param {string} uuid */ +export function endKnockPrediction(uuid) { + return predictions.delete(uuid); +} + +// ---- the per-frame tick ----------------------------------------------------------- + +function reset() { + for (const probe of probes.values()) { + if (probe.external) continue; + probe.samples = []; + probe.contacts.clear(); + } + predictions.clear(); + bodyTracks.clear(); +} + +/** + * Per frame, from Scene's useTask (the tickPlayInteract slot). `now` is the page + * clock; an EXTERNAL probe (feedProbe) is driven by its feeder with its own clock and + * is skipped here, which is what keeps a synthetic sweep deterministic. + * @param {number} now @param {any} camera the active camera (desktop head probe) + */ +export function tickKnock(now, camera) { + if (!started) return; + const active = armed(); + if (get(knockActive) !== active) knockActive.set(active); + if (!active) { + if (probes.size || predictions.size) reset(); + return; + } + const group = get(objectsGroup); + if (!group) return; + group.updateWorldMatrix(true, false); + const cfg = get(sceneKnock); + if (get(isVRMode)) { + for (const hand of /** @type {const} */ (['left', 'right'])) { + const snap = hands?.(hand); + // an untracked hand feeds nothing; a GRIPPED hand is carrying, not knocking + if (!snap?.position || snap.gripped) { + probes.get(hand)?.samples.splice(0); + continue; + } + feedWorldPose(hand, cfg.radius, group, snap.position, snap.quaternion ?? null, now); + } + } else if (camera && get(isLocked) === true) { + camera.getWorldPosition(_pos); + camera.getWorldQuaternion(_quat); + feedWorldPose('head', HEAD_PROBE_RADIUS, group, _pos.toArray(), _quat.toArray(), now); + } + const dyn = dynamicSet(now, group); + tickPredictions(now, group); + if (!isInitiator()) for (const object of group.children) if (dyn.has(object.uuid)) trackBody(object); + for (const probe of probes.values()) { + if (probe.external) continue; + if (probe.samples.length < 2) continue; + evaluateProbe(probe, now, group, dyn); + } +} + +/** + * THE TEST HOOK: drive a probe through a body at an exact speed, with the caller's + * clock. Each call pushes one sample and runs the contact test for THAT probe alone, + * so a suite can sweep in a tight synchronous loop and read the body's velocity on the + * very next line. `pos` is in the objects group's frame (= world on desktop). The play + * gate still applies — a probe fed while the block is off proves the counterfactual. + * @param {string} id @param {number[]} pos @param {number} t ms + * @param {{quat?: number[], radius?: number}} [opts] + * @returns {{hits: number, overlaps: number, armed: boolean}} + */ +export function feedProbe(id, pos, t, opts = {}) { + const radius = opts.radius ?? get(sceneKnock)?.radius ?? 0.12; + const probe = probeFor(id, radius); + probe.external = true; + _pos.fromArray(arr3(pos)); + const quat = opts.quat ? _quat.fromArray(opts.quat) : null; + pushSample(probe, _pos, quat, t); + if (!armed()) return { hits: 0, overlaps: 0, armed: false }; + const group = get(objectsGroup); + if (!group) return { hits: 0, overlaps: 0, armed: true }; + group.updateWorldMatrix(true, false); + const dyn = dynamicSet(t, group); + if (!isInitiator()) for (const object of group.children) if (dyn.has(object.uuid)) trackBody(object); + const result = evaluateProbe(probe, t, group, dyn); + return { ...result, armed: true }; +} + +/** drop a test probe (and its cooldown state) @param {string} id */ +export function dropProbe(id) { + return probes.delete(id); +} + +/** + * Wire the feeds. Called from Scene's onMount beside startPlayInteract — BELOW every + * `let` its closures read (the TDZ rule). + * @param {{hands?: (hand: 'left'|'right') => any, heldUuids?: () => (string | null)[]}} [options] + */ +export function startKnock(options = {}) { + if (started || typeof window === 'undefined') return () => {}; + started = true; + hands = options.hands ?? null; + heldUuids = options.heldUuids ?? null; + return stopKnock; +} + +export function stopKnock() { + if (!started) return; + started = false; + hands = null; + heldUuids = null; + probes.clear(); + predictions.clear(); + bodyTracks.clear(); + boundsCache.clear(); + candidates = { at: -Infinity, uuids: new Set() }; + knockActive.set(false); +} + +/** test/debug view */ +export function knockDebug() { + return { + started, + active: get(knockActive), + sent: sentCount, + probes: [...probes.values()].map((probe) => ({ + id: probe.id, + radius: probe.radius, + external: probe.external, + samples: probe.samples.length, + contacts: [...probe.contacts.entries()].map(([uuid, state]) => ({ + uuid, + spent: state.spent, + out: state.outSince != null + })) + })), + predictions: [...predictions.keys()], + dynamic: [...candidates.uuids], + hits: recentHits.length + }; +} diff --git a/src/lib/knockMath.js b/src/lib/knockMath.js new file mode 100644 index 00000000..2c6bf633 --- /dev/null +++ b/src/lib/knockMath.js @@ -0,0 +1,230 @@ +import * as THREE from 'three'; +// the `.js` is load-bearing: knock-physics imports this file straight into node, +// where a bare specifier does not resolve (vite accepts either) +import { velocityFromSamples, clampThrow, MAX_LINVEL } from './throwVelocity.js'; + +// 24-A A1: THE KNOCK, the pure half. +// +// A player's hand (a VR controller) or body (the desktop camera) is a PROBE SPHERE. +// When it overlaps a dynamic body while approaching it, the body's velocity gains +// the probe's approach speed along the contact normal, clamped, once per pass. +// This file is the arithmetic of that sentence and nothing else: THREE + +// throwVelocity, no stores, no scene, no wire — so the numbers a game feels are +// testable with no browser (the throwVelocity.test precedent), and knock.js (the +// runtime: feeds, candidates, the message, the log) is the only consumer. +// +// WHY A SPHERE-VS-SPHERE TEST AGAINST REPLICATED POSES, and not a rapier hand body: +// only the initiator has a rapier world (roadmap 24 F1). A kinematic hand body would +// knock correctly for the initiator and for nobody else, and a second path built from +// presence poses on the initiator would feel one presence interval late. The +// collectible module proved the alternative one domain over — a radius test against +// a replicated pose, "no sensor, no physics body and no initiator". So the test runs +// on the peer whose hand it is, against the poses that peer already renders, and the +// RESULT replicates as an exact velocity (the B5 `throw` model). The fidelity cost is +// stated plainly: the overlap is sphere-vs-bounding-sphere — exact for balls and +// stars, approximate for boxes. + +/** the desktop probe: the character capsule's 0.3 + a margin (F9) */ +export const HEAD_PROBE_RADIUS = 0.35; +/** how far back the velocity ring reaches (ms) — a hand at 60 Hz gives ~6 samples */ +export const PROBE_WINDOW_MS = 100; +/** the ring never holds more than this, whatever the frame rate */ +export const PROBE_SAMPLES = 6; +/** a probe must be OUT of a body's sphere this long before it may knock it again */ +export const REARM_MS = 60; +/** a non-initiator's prediction that authority never confirms is withdrawn after this */ +export const PREDICT_MAX_MS = 400; +/** a body's own velocity estimate off the move stream looks this far back (ms) */ +export const BODY_WINDOW_MS = 200; + +/** + * @typedef {{spent: boolean, outSince: number | null}} ContactState + * @typedef {{id: string, radius: number, external: boolean, lastAt: number, + * samples: {t: number, pos: THREE.Vector3, quat: THREE.Quaternion | null}[], + * contacts: Map}} Probe + */ + +/** @param {string} id @param {number} radius @returns {Probe} */ +export function createProbe(id, radius) { + return { id, radius, external: false, lastAt: 0, samples: [], contacts: new Map() }; +} + +/** + * Push one pose into the probe's ring. Trims by COUNT and by WINDOW, keeping at + * least two samples so a slow page (a headless tab at 2.5 fps) still has a + * velocity to read — over a longer window, which is the honest number there. + * @param {Probe} probe @param {THREE.Vector3} pos @param {THREE.Quaternion | null} quat + * @param {number} t ms + */ +export function pushSample(probe, pos, quat, t) { + probe.samples.push({ t, pos: pos.clone(), quat: quat ? quat.clone() : null }); + probe.lastAt = t; + while (probe.samples.length > PROBE_SAMPLES) probe.samples.shift(); + while (probe.samples.length > 2 && t - probe.samples[0].t > PROBE_WINDOW_MS) probe.samples.shift(); +} + +/** The probe's velocity over its ring — the throw estimator, so the MIN_DT guard + * and the magnitude clamp come for free. Zero with fewer than two samples. + * @param {Probe} probe */ +export function probeVelocity(probe) { + return velocityFromSamples(probe.samples).linvel; +} + +/** @param {Probe} probe @returns {THREE.Vector3 | null} the newest sample's position */ +export function probePosition(probe) { + const last = probe.samples[probe.samples.length - 1]; + return last ? last.pos : null; +} + +const _d = new THREE.Vector3(); +const _rel = new THREE.Vector3(); + +/** + * The contact test. `n` points FROM the probe INTO the body (it is the direction a + * knock pushes), `approach` is how fast the probe closes on the body along it — + * positive = closing, ~0 = resting, negative = receding or being outrun. + * + * With the two centres coincident there is no normal to speak of; the probe's own + * direction of travel stands in, and a probe that is not moving cannot approach + * anything, so `approach` is 0 there. + * @param {THREE.Vector3} probePos @param {number} probeRadius @param {THREE.Vector3} probeVel + * @param {THREE.Vector3} bodyCentre @param {number} bodyRadius @param {THREE.Vector3} bodyVel + * @returns {{overlap: boolean, distance: number, n: THREE.Vector3, approach: number}} + */ +export function contactOf(probePos, probeRadius, probeVel, bodyCentre, bodyRadius, bodyVel) { + _d.subVectors(bodyCentre, probePos); + const distance = _d.length(); + const overlap = distance < probeRadius + bodyRadius; + const n = new THREE.Vector3(); + if (distance > 1e-6) n.copy(_d).divideScalar(distance); + else if (probeVel.lengthSq() > 1e-12) n.copy(probeVel).normalize(); + else return { overlap, distance, n: n.set(0, 1, 0), approach: 0 }; + _rel.subVectors(probeVel, bodyVel); + return { overlap, distance, n, approach: _rel.dot(n) }; +} + +/** + * The response: v' = v_body + n * approach * gain. The hand is treated as INFINITE + * MASS with no restitution, so a puffy star and a football both leave at hand + * speed along the normal — mass still matters afterwards, through damping and + * every collision that follows. + * + * SPIN: a sphere-vs-sphere contact is ALWAYS central, so the "off-centre offset" + * that curls a ball is not the normal push (r_contact x delta-v is zero by + * construction there) — it is the TANGENTIAL slip of the hand across the surface. + * The surface point at -n*r is dragged with that slip: omega += spin * (r_c x v_t) / r^2. + * A probe brushing up the left side of a ball spins it about -z, which is the + * direction that carries that surface point upward with the hand (checked in + * knock-physics section 0). + * + * Then ONE clamp: clampThrow (the throw's own ceiling, MAX_LINVEL/MAX_ANGVEL) and + * the scene's `maxSpeed` BELOW it, so a game can keep a ball hittable. + * @param {{bodyVel: THREE.Vector3, bodyAngvel: THREE.Vector3, probeVel: THREE.Vector3, + * n: THREE.Vector3, approach: number, bodyRadius: number, + * gain: number, spin: number, maxSpeed: number}} args + * @returns {{linvel: THREE.Vector3, angvel: THREE.Vector3}} + */ +export function knockResponse(args) { + const { bodyVel, bodyAngvel, probeVel, n, approach, bodyRadius, gain, spin, maxSpeed } = args; + const linvel = bodyVel.clone().addScaledVector(n, approach * gain); + const angvel = bodyAngvel.clone(); + if (spin > 0 && bodyRadius > 1e-4) { + const rel = probeVel.clone().sub(bodyVel); + const tangential = rel.addScaledVector(n, -rel.dot(n)); + const rc = n.clone().multiplyScalar(-bodyRadius); + angvel.add(rc.cross(tangential).multiplyScalar(spin / (bodyRadius * bodyRadius))); + } + const clamped = clampThrow(linvel, angvel); + const cap = Math.min(Number.isFinite(maxSpeed) ? maxSpeed : MAX_LINVEL, MAX_LINVEL); + if (clamped.linvel.length() > cap) clamped.linvel.setLength(cap); + return clamped; +} + +/** + * ONE KNOCK PER PASS. Per (probe, body): after a hit the pair is SPENT, and it + * re-arms only once the probe has been OUT of the body's sphere for REARM_MS — + * a follow-through that stays inside the ball adds nothing, and a pose that + * flickers out and back inside the hysteresis has not left. Returns whether a + * hit MAY fire this tick (the caller still needs overlap + approach). + * @param {Probe} probe @param {string} uuid @param {boolean} overlap @param {number} now + */ +export function cooldownStep(probe, uuid, overlap, now) { + let state = probe.contacts.get(uuid); + if (!state) { + state = { spent: false, outSince: null }; + probe.contacts.set(uuid, state); + } + if (!overlap) { + if (state.outSince == null) state.outSince = now; + return false; + } + if (state.spent) { + if (state.outSince != null && now - state.outSince >= REARM_MS) { + state.spent = false; + state.outSince = null; + return true; + } + state.outSince = null; // re-entered too soon, or never left: leave again + return false; + } + state.outSince = null; + return true; +} + +/** A hit fired for this pair: spend it. @param {Probe} probe @param {string} uuid */ +export function markSpent(probe, uuid) { + const state = probe.contacts.get(uuid); + if (state) { + state.spent = true; + state.outSince = null; + } +} + +/** Forget pairs whose body is gone. @param {Probe} probe @param {Set} live */ +export function pruneContacts(probe, live) { + for (const uuid of [...probe.contacts.keys()]) if (!live.has(uuid)) probe.contacts.delete(uuid); +} + +const _box = new THREE.Box3(); +const _childBox = new THREE.Box3(); +const _rel4 = new THREE.Matrix4(); +const _inv = new THREE.Matrix4(); +const _sphere = new THREE.Sphere(); + +/** + * A body's bounding sphere in its OWN local frame (before its scale): a mesh's + * geometry sphere, or the union box of a group's meshes each carried into the + * group's frame. `radius` is unscaled — the caller multiplies by the object's + * largest scale component and carries `center` through `object.matrix`, which is + * what makes one cached answer good for every frame until the shape changes. + * @param {any} object + * @returns {{center: THREE.Vector3, radius: number}} + */ +export function localBoundsOf(object) { + const geometry = object?.geometry; + if (geometry) { + if (!geometry.boundingSphere) geometry.computeBoundingSphere(); + const sphere = geometry.boundingSphere; + return { center: sphere.center.clone(), radius: sphere.radius }; + } + _box.makeEmpty(); + object.updateWorldMatrix(true, true); + _inv.copy(object.matrixWorld).invert(); + object.traverse((/** @type {any} */ child) => { + if (!child.geometry) return; + if (!child.geometry.boundingBox) child.geometry.computeBoundingBox(); + _rel4.multiplyMatrices(_inv, child.matrixWorld); + _childBox.copy(child.geometry.boundingBox).applyMatrix4(_rel4); + _box.union(_childBox); + }); + if (_box.isEmpty()) return { center: new THREE.Vector3(), radius: 0.5 }; + _box.getBoundingSphere(_sphere); + return { center: _sphere.center.clone(), radius: _sphere.radius }; +} + +/** The scale factor a local radius takes into the parent frame. @param {any} object */ +export function radiusScaleOf(object) { + const s = object?.scale; + if (!s) return 1; + return Math.max(Math.abs(s.x), Math.abs(s.y), Math.abs(s.z)) || 1; +} diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 743be46a..3dad91e9 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -39,8 +39,9 @@ import { applyModuleMessage, moduleVersions, checkModuleVersions, checkPeerAppVe import { APP_VERSION, COMMIT_SHA } from '$lib/version.js'; import { applyLockRequest, applyUnlock, applyLockDenied } from '$lib/lockControl'; import { applyDrawLive, applyDrawEnd } from '$lib/drawMode'; -import { applySimulate, physicsExternalMove, applyThrow } from '$lib/physics'; +import { applySimulate, physicsExternalMove, applyThrow, applyHit } from '$lib/physics'; import { noteRemoteMove } from '$lib/moveSmoothing'; +import { noteRemoteHit, endKnockPrediction } from '$lib/knock'; import { applyJointCreate, applyJointDelete, applyJointsSnapshot, sendJoints } from '$lib/joints'; import { applyAnimData, applyAnimPlay, applyAnimationsSnapshot, sendAnimations } from '$lib/animationPreview'; import { applyHandModel, handModelState, dropPeerHandModel } from '$lib/handModels'; @@ -522,6 +523,10 @@ export class PeerConnection { // the pose BEFORE the write, so a remote physics stream can be eased // across rather than stepped through (moveSmoothing; ~10 Hz on the wire // looked like 10 fps on the watching peer) + // 24-A A1: authority has spoken for this body, so a knock prediction of + // ours ends HERE — before the pose below is captured, so the ease starts + // from where the prediction left the object, not from where the hit found it + endKnockPrediction(data.uuid); const movedObject = get(objectsGroup)?.getObjectByProperty('uuid', data.uuid); const movedFrom = movedObject ? { pos: movedObject.position.clone(), quat: movedObject.quaternion.clone() } @@ -537,6 +542,14 @@ export class PeerConnection { // B5: a peer's EXACT release. Initiator-only, never re-broadcast — // the flight itself replicates through the existing move stream. applyThrow(data); + } else if(data.type == 'hit') { + // 24-A A1: a peer's hand (or head) KNOCKED a body — the throw's sibling. + // The initiator puts the velocity into the body (clamped again, never + // re-broadcast: the flight rides the move stream); EVERY peer logs it, + // stamped with the connection's peer, never the payload's. CONTENT, so it + // is gateable by canApply like `throw` and ROOM_SCOPED like `move`. + applyHit(data); + noteRemoteHit(data, conn.peer); } else if(data.type == 'simulate') { applySimulate(data); } else if(data.type == 'jointcreate') { diff --git a/src/lib/peerScenes.js b/src/lib/peerScenes.js index b0a06263..b5ba2289 100644 --- a/src/lib/peerScenes.js +++ b/src/lib/peerScenes.js @@ -579,7 +579,7 @@ export function roomsOfSession(map, mine, host) { export const ROOM_SCOPED = new Set([ // object lifecycle & geometry 'create', 'light', 'group', 'object', 'objectfile', 'duplicate', 'delete', 'name', - 'move', 'throw', 'simulate', 'color', 'objectParameters', 'geometry', 'lighttarget', + 'move', 'throw', 'hit', 'simulate', 'color', 'objectParameters', 'geometry', 'lighttarget', 'verts', 'meshgeo', 'uvpaint', 'uvpaintend', 'splineedit', 'drawlive', 'drawend', 'clearscene', 'loading', // flow diff --git a/src/lib/physics.js b/src/lib/physics.js index 002e4cff..a797fc8b 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -21,7 +21,8 @@ import { sceneGravity, scenePhysicsGround, scenePhysicsBounds, - scenePhysicsDefaults + scenePhysicsDefaults, + sceneKnock } from './scenePhysics'; import { velocityFromSamples, clampThrow, MAX_LINVEL, MAX_ANGVEL } from './throwVelocity'; // B7: spawned objects are swept when the run ends. transientObjects is a LEAF (the two @@ -1240,6 +1241,54 @@ export function applyThrow(data) { return true; } +/** + * 24-A A1: a hand (or a walking player) KNOCKED a dynamic body — the throw's sibling. + * + * Same authority rule as applyThrow: the INITIATOR applies it and nobody re-broadcasts, + * because the flight itself replicates through the ordinary move stream. Unlike a + * throw there is no pose to reseat — the hitter never moved the object, so the body + * keeps its own position and only its VELOCITY changes (add-on-top semantics were + * resolved by the sender: `linvel` is the absolute result). The vectors go through + * the SAME clampThrow as every release, plus the scene's own `knock.maxSpeed` — the + * config is shared latest-wins, so the initiator can hold the cap it authored without + * trusting the sender's number (F2: never trust the sender's numbers). + * + * A HELD body refuses: knocking a crate somebody is carrying would fight their hold, + * and an EXTERNAL hold means a peer's move stream owns the pose right now. The return + * is the body's answer only; the hit LOG is knock.js's business and is written on every + * peer whether or not a body took it (convergence over refusal, see noteRemoteHit). + * @param {any} data {uuid, linvel, angvel} + */ +export function applyHit(data) { + if (!world || !get(simulating)) return false; + const entry = bodies.find((e) => e.object.uuid === data?.uuid && e.mode === 'dynamic'); + if (!entry || entry.hold) return false; + const v = clampThrow(data.linvel, data.angvel); + const cap = Math.min(get(sceneKnock)?.maxSpeed ?? MAX_LINVEL, MAX_LINVEL); + if (v.linvel.length() > cap) v.linvel.setLength(cap); + entry.body.setLinvel({ x: v.linvel.x, y: v.linvel.y, z: v.linvel.z }, true); + entry.body.setAngvel({ x: v.angvel.x, y: v.angvel.y, z: v.angvel.z }, true); + if (v.linvel.length() > 5) entry.body.enableCcd(true); // B4: a fast body must not tunnel + return true; +} + +/** + * A1: a dynamic body's EXACT velocity, for the knock's approach test on the peer that + * steps the world. Null off the initiator (there is no body) — knock.js then falls + * back to its own estimate off the poses it renders. `held` lets the probe skip a body + * somebody is carrying without a second lookup. + * @param {string} uuid + * @returns {{linvel: number[], angvel: number[], held: boolean} | null} + */ +export function bodyVelocityOf(uuid) { + if (!world) return null; + const entry = bodies.find((e) => e.object.uuid === uuid && e.mode === 'dynamic'); + if (!entry) return null; + const l = entry.body.linvel(); + const a = entry.body.angvel(); + return { linvel: [l.x, l.y, l.z], angvel: [a.x, a.y, a.z], held: !!entry.hold }; +} + const FIXED_DT = 1 / 60; const MAX_SUBSTEPS = 8; diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js index 236f79ea..9c0305df 100644 --- a/src/lib/playInteract.js +++ b/src/lib/playInteract.js @@ -426,6 +426,12 @@ export function stopPlayInteract() { window.removeEventListener('wheel', onWheel, { capture: true }); } +/** 24-A A1: the object the crosshair is carrying, or null — the knock's head probe + * skips it for the same reason the VR probe skips a gripped object. */ +export function carriedUuid() { + return grab?.object?.uuid ?? null; +} + /** test/debug view */ export function playInteractDebug() { return { diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 08b8ebe8..cc6ff5e2 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -29,6 +29,15 @@ export const DEFAULT_SCENE_PHYSICS = Object.freeze({ ccd: false, timeScale: 1, play: { interaction: 'grab', grounded: false, simOnPlay: false }, + // 24-A A1: THE KNOCK — a hand or a walking player hitting a dynamic body. An + // ADDITIVE nested block rather than a fourth `play.interaction`, because grab and + // knock coexist (grip grabs, an open controller knocks). `enabled: false` by default + // is the whole compatibility story: Towers and every saved scene behave + // byte-identically, which knock-physics asserts as its counterfactual. `maxSpeed` + // clamps BELOW throwVelocity's MAX_LINVEL (20) so a game can keep a ball hittable; + // `predict` is the non-initiator's local prediction (riskiest part of A1, so it is + // one switch away from off). + knock: { enabled: false, gain: 1, maxSpeed: 12, minSpeed: 0.3, radius: 0.12, spin: 0.5, predict: true }, changedAt: 0 }); @@ -87,6 +96,7 @@ export function normalizeScenePhysics(raw) { const materialRaw = source.material && typeof source.material === 'object' ? source.material : {}; const dampingRaw = source.damping && typeof source.damping === 'object' ? source.damping : {}; const playRaw = source.play && typeof source.play === 'object' ? source.play : {}; + const knockRaw = source.knock && typeof source.knock === 'object' ? source.knock : {}; /** @type {any} */ const state = { gravity: num(source.gravity, -20, 5, d.gravity), @@ -135,6 +145,21 @@ export function normalizeScenePhysics(raw) { }, ['interaction', 'grounded', 'simOnPlay'] ), + // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — + // this module is store-only and the response clamps through clampThrow anyway + knock: withUnknown( + knockRaw, + { + enabled: bool(knockRaw.enabled, d.knock.enabled), + gain: num(knockRaw.gain, 0, 5, d.knock.gain), + maxSpeed: num(knockRaw.maxSpeed, 0.5, 20, d.knock.maxSpeed), + minSpeed: num(knockRaw.minSpeed, 0, 5, d.knock.minSpeed), + radius: num(knockRaw.radius, 0.02, 1, d.knock.radius), + spin: num(knockRaw.spin, 0, 2, d.knock.spin), + predict: bool(knockRaw.predict, d.knock.predict) + }, + ['enabled', 'gain', 'maxSpeed', 'minSpeed', 'radius', 'spin', 'predict'] + ), changedAt: typeof source.changedAt === 'number' ? source.changedAt : 0 }; return withUnknown(source, state, [ @@ -146,6 +171,7 @@ export function normalizeScenePhysics(raw) { 'ccd', 'timeScale', 'play', + 'knock', 'changedAt', 'type' // the wire envelope's own field, never state ]); @@ -164,6 +190,8 @@ export const scenePhysicsGround = derived(scenePhysicsState_, (s) => s.ground); export const scenePhysicsBounds = derived(scenePhysicsState_, (s) => s.bounds); /** play-mode block ({interaction, grounded, simOnPlay}) */ export const scenePlay = derived(scenePhysicsState_, (s) => s.play); +/** A1: the knock block ({enabled, gain, maxSpeed, minSpeed, radius, spin, predict}) */ +export const sceneKnock = derived(scenePhysicsState_, (s) => s.knock); /** solver defaults ({material, damping, ccd, timeScale}) */ export const scenePhysicsDefaults = derived(scenePhysicsState_, (s) => ({ material: s.material, @@ -172,7 +200,7 @@ export const scenePhysicsDefaults = derived(scenePhysicsState_, (s) => ({ timeScale: s.timeScale })); -const NESTED = ['ground', 'bounds', 'material', 'damping', 'play']; +const NESTED = ['ground', 'bounds', 'material', 'damping', 'play', 'knock']; /** * Apply a change locally + replicate (latest-wins). Nested blocks MERGE, so a diff --git a/src/lib/throwVelocity.js b/src/lib/throwVelocity.js index 1cbd3077..347f668f 100644 --- a/src/lib/throwVelocity.js +++ b/src/lib/throwVelocity.js @@ -65,7 +65,9 @@ export function clampThrow(linvel, angvel) { * Estimate the velocity a held body should be released with, from a short ring * of recent poses. Returns clamped values — every caller wants them clamped and * a second opinion about the ceiling is exactly the bug this replaced. - * @param {{t: number, pos: THREE.Vector3, quat: THREE.Quaternion}[]} samples oldest first + * @param {{t: number, pos: THREE.Vector3, quat?: THREE.Quaternion | null}[]} samples oldest first + * (24-A A1: `quat` is optional — a knock probe ring may carry positions only, and the + * body below already skips the angular half when either end lacks one) * @param {{minDt?: number}} [opts] * @returns {{linvel: THREE.Vector3, angvel: THREE.Vector3}} */ diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 15173109..2667e982 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -243,6 +243,11 @@ const tempVector = new THREE.Vector3(); let grab = null; /** @type {any} two-hand scale: { object, startDistance, startScale, before } */ let scaleGrab = null; +/** 24-A A1: the object a VR hand is holding right now, or null — the knock probe + * skips it (a hand knocking the crate it is carrying would fight its own hold). */ +export function vrGrabbedUuid() { + return grab?.object?.uuid ?? scaleGrab?.object?.uuid ?? null; +} let lastMoveSent = 0; // --- clarity pack: controller rays, hover highlight, snap turn --- diff --git a/tests/e2e/knock-physics.test.cjs b/tests/e2e/knock-physics.test.cjs new file mode 100644 index 00000000..469bbe67 --- /dev/null +++ b/tests/e2e/knock-physics.test.cjs @@ -0,0 +1,559 @@ +// 24-A A1 — THE KNOCK: a hand (or a walking player) hits a dynamic body, and the body +// leaves at the speed it was hit. +// +// Section 0 is PURE: knockMath.js imports THREE + throwVelocity and nothing else, so +// the contact test, the response, the spin sign and the one-knock-per-pass cooldown +// are imported straight into node (the throw-velocity precedent). +// +// Sections 1-2 drive the runtime through `feedProbe`, the test hook that pushes a probe +// through a body at an EXACT speed on its own clock: every feed runs the contact test +// synchronously, so a body's velocity is read on the very next line, before rapier has +// stepped once. Section 1 is the initiator alone; section 2 is a non-initiator whose +// hit must cross the wire as `hit` and be applied — clamped — by the stepping peer, +// with the log agreeing on both, the prediction proven and withdrawn, and the +// capability gate dropping it. +// +// THE COUNTERFACTUALS: 1.12 (the block off = zero hits, nothing sent, the body +// untouched — what makes Towers and every saved scene byte-identical), 1.13 (not in +// play = nothing), 2.7 (a gated hit is applied nowhere and its prediction is withdrawn). +// +// Two-peer sections need PEER_CONFIG (the self-hosted signaling box) and GPU_ARGS: the +// prediction is advanced by the frame loop, and a software-rendered page ticks ~2.5 fps. + +const { pathToFileURL } = require('url'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const src = (f) => pathToFileURL(path.join(__dirname, '..', '..', 'src', 'lib', f)).href; + +const sp = (page, body) => + page.evaluate((b) => new Function('sp', b)(window.__stores.scenePhysics), body); +const phys = (page, body) => + page.evaluate((b) => new Function('p', b)(window.__stores.physics), body); +const knock = (page, body) => + page.evaluate((b) => new Function('k', b)(window.__stores.knock), body); +const bodyOf = (page, uuid) => + page.evaluate( + (uuid) => window.__stores.physics.physicsDebug().find((b) => b.uuid === uuid) ?? null, + uuid + ); +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const o = group.getObjectByProperty('uuid', uuid); + return o ? o.position.toArray() : null; + }, uuid); +const speedOf = (b) => (b?.linvel ? Math.hypot(b.linvel.x, b.linvel.y, b.linvel.z) : 0); + +/** park the ball at (0,1,0) with zero velocity — applyThrow reseats AND zeroes */ +const park = (page, uuid) => + phys( + page, + 'return p.applyThrow({ uuid: "' + + uuid + + '", pos: [0, 1, 0], rot: [0, 0, 0], linvel: [0, 0, 0], angvel: [0, 0, 0] })' + ); + +/** + * Sweep a probe along +x (or -x) through y=1, z=0 at `speed` m/s in `dtMs` steps on a + * synthetic clock. A fresh probe id per sweep unless `keep` — the cooldown state is + * per probe, and most sections want a clean pair. + * + * `atHit` is the body's velocity read IN THE SAME EVALUATE, on the line after the first + * hit fired. The first version read it from a second evaluate and measured 0.769 x the + * hand speed on every sweep — exactly (1/(1 + 2/60))^8, the scene's damping over the + * 8-substep backlog the frame loop ran between the two round trips. The number to + * assert is the one the knock wrote, so it is read before rapier steps once. + */ +const sweep = (page, id, opts) => + page.evaluate( + ({ id, uuid, from, to, speed, dtMs, t0, y, z, keep }) => { + const k = window.__stores.knock; + const p = window.__stores.physics; + if (!keep) k.dropProbe(id); + const step = (speed * dtMs) / 1000; + const dir = Math.sign(to - from) || 1; + let hits = 0; + let overlaps = 0; + let armed = true; + let calls = 0; + let t = t0; + let x = from; + let atHit = null; + while (dir > 0 ? x <= to + 1e-9 : x >= to - 1e-9) { + const r = k.feedProbe(id, [x, y, z], t); + if (r.hits > 0 && !atHit && uuid) { + const b = p.physicsDebug().find((entry) => entry.uuid === uuid); + atHit = b?.linvel ? [b.linvel.x, b.linvel.y, b.linvel.z] : null; + } + hits += r.hits; + overlaps += r.overlaps; + armed = armed && r.armed; + calls++; + x += dir * step; + t += dtMs; + } + return { hits, overlaps, armed, calls, lastT: t, atHit }; + }, + { dtMs: 16, t0: 1000, y: 1, z: 0, keep: false, uuid: null, ...opts, id } + ); +const mag = (v) => (Array.isArray(v) ? Math.hypot(v[0], v[1], v[2]) : NaN); +const fmt = (v) => (Array.isArray(v) ? v.map((n) => n.toFixed(3)).join(', ') : 'none'); + +h.run(async () => { + // ---------------------------------------------------------------- section 0 + console.log('\n=== 0. the pure half (no browser) ==='); + { + const THREE = await import('three'); + const m = await import(src('knockMath.js')); + const v3 = (x, y, z) => new THREE.Vector3(x, y, z); + + const far = m.contactOf(v3(-1, 0, 0), 0.12, v3(2, 0, 0), v3(0, 0, 0), 0.3, v3(0, 0, 0)); + h.check(!far.overlap && far.approach === 2, '0.1 a probe 1 m away closing at 2 m/s: no overlap, approach 2'); + h.check(far.n.x === 1 && far.n.y === 0, '0.2 the normal points FROM the probe INTO the body'); + const near = m.contactOf(v3(-0.4, 0, 0), 0.12, v3(2, 0, 0), v3(0, 0, 0), 0.3, v3(0, 0, 0)); + h.check(near.overlap, '0.3 inside r_probe + r_body it overlaps'); + const receding = m.contactOf(v3(-0.4, 0, 0), 0.12, v3(-2, 0, 0), v3(0, 0, 0), 0.3, v3(0, 0, 0)); + h.check(receding.overlap && receding.approach === -2, '0.4 a receding probe overlaps with NEGATIVE approach'); + const outrun = m.contactOf(v3(-0.4, 0, 0), 0.12, v3(2, 0, 0), v3(0, 0, 0), 0.3, v3(3, 0, 0)); + h.check(outrun.approach === -1, '0.5 a probe slower than the ball it chases reads as receding (' + outrun.approach + ')'); + const coincident = m.contactOf(v3(0, 0, 0), 0.12, v3(0, 0, 0), v3(0, 0, 0), 0.3, v3(0, 0, 0)); + h.check(coincident.overlap && coincident.approach === 0, '0.6 coincident centres with no motion: overlap, approach 0, no NaN'); + + const base = { bodyVel: v3(0, 0, 0), bodyAngvel: v3(0, 0, 0), n: v3(1, 0, 0), bodyRadius: 0.3, gain: 1, spin: 0, maxSpeed: 12 }; + const two = m.knockResponse({ ...base, probeVel: v3(2, 0, 0), approach: 2 }); + const six = m.knockResponse({ ...base, probeVel: v3(6, 0, 0), approach: 6 }); + h.check( + Math.abs(two.linvel.x - 2) < 1e-9 && Math.abs(six.linvel.x - 6) < 1e-9, + '0.7 the response is the approach speed along n (2 -> 2, 6 -> 6): MONOTONIC in probe speed' + ); + const gained = m.knockResponse({ ...base, probeVel: v3(6, 0, 0), approach: 6, gain: 1.5 }); + h.check(Math.abs(gained.linvel.x - 9) < 1e-9, '0.8 gain scales it (6 x 1.5 = ' + gained.linvel.x + ')'); + const moving = m.knockResponse({ ...base, bodyVel: v3(-1, 0, 0), probeVel: v3(2, 0, 0), approach: 3 }); + h.check( + Math.abs(moving.linvel.x - 2) < 1e-9, + '0.9 a ball coming AT the hand leaves at hand speed (infinite-mass hand: -1 + 3 = ' + moving.linvel.x + ')' + ); + const capped = m.knockResponse({ ...base, probeVel: v3(15, 0, 0), approach: 15 }); + h.check(Math.abs(capped.linvel.length() - 12) < 1e-9, '0.10 maxSpeed 12 caps a 15 m/s knock at 12'); + const ceiling = m.knockResponse({ ...base, probeVel: v3(30, 0, 0), approach: 30, maxSpeed: 999 }); + h.check(Math.abs(ceiling.linvel.length() - 20) < 1e-9, '0.11 ...and the throw ceiling (20) binds ABOVE any maxSpeed'); + const notBinding = m.knockResponse({ ...base, probeVel: v3(15, 0, 0), approach: 15, maxSpeed: 30 }); + h.check(Math.abs(notBinding.linvel.x - 15) < 1e-9, '0.12 a maxSpeed above the knock leaves it alone (15)'); + + // spin: a probe brushing UP the left side of the ball (n = +x, tangential +y) + // drags the surface point at -x upward, which is a turn about -z + const brushed = m.knockResponse({ ...base, probeVel: v3(0, 1, 0), approach: 0, spin: 0.5 }); + h.check( + brushed.angvel.z < 0 && Math.abs(brushed.angvel.z + 0.5 / 0.3) < 1e-9, + '0.13 a tangential brush curls the ball about -z at spin/r (' + brushed.angvel.z.toFixed(3) + ' rad/s)' + ); + const central = m.knockResponse({ ...base, probeVel: v3(2, 0, 0), approach: 2, spin: 0.5 }); + h.check(central.angvel.length() < 1e-9, '0.14 a dead-centre hit spins nothing'); + + // cooldown: one knock per pass, hysteresis on the way back in + const probe = m.createProbe('t', 0.12); + h.check(m.cooldownStep(probe, 'b', true, 0) === true, '0.15 a fresh pair may fire'); + m.markSpent(probe, 'b'); + h.check(m.cooldownStep(probe, 'b', true, 16) === false, '0.16 ...and not again while still inside'); + m.cooldownStep(probe, 'b', false, 100); // left + h.check(m.cooldownStep(probe, 'b', true, 130) === false, '0.17 back in after 30 ms out: a flicker, still spent'); + m.cooldownStep(probe, 'b', false, 140); // left again + h.check(m.cooldownStep(probe, 'b', true, 140 + m.REARM_MS + 10) === true, '0.18 back in after the hysteresis: re-armed'); + + // the ring: capped by count and window, velocity read over it + const ring = m.createProbe('r', 0.12); + for (let i = 0; i < 10; i++) m.pushSample(ring, v3(i * 0.032, 0, 0), null, 1000 + i * 16); + h.check(ring.samples.length <= m.PROBE_SAMPLES, '0.19 the ring holds at most ' + m.PROBE_SAMPLES + ' samples (' + ring.samples.length + ')'); + h.check(Math.abs(m.probeVelocity(ring).x - 2) < 1e-6, '0.20 ...and reads 2 m/s off them (quat-less samples are fine)'); + const sparse = m.createProbe('s', 0.12); + for (let i = 0; i < 5; i++) m.pushSample(sparse, v3(i * 0.12, 0, 0), null, 1000 + i * 60); + h.check(sparse.samples.length === 2 && Math.abs(m.probeVelocity(sparse).x - 2) < 1e-6, '0.21 a slow page trims to the window but keeps two samples: still 2 m/s'); + + const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.3, 8, 8)); + const sb = m.localBoundsOf(sphere); + h.check(Math.abs(sb.radius - 0.3) < 1e-6, '0.22 a sphere mesh bounds to its radius (' + sb.radius.toFixed(3) + ')'); + const group = new THREE.Group(); + const a = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + const b = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + b.position.set(2, 0, 0); + group.add(a, b); + const gb = m.localBoundsOf(group); + h.check(gb.radius > 1.4 && Math.abs(gb.center.x - 1) < 1e-6, '0.23 a group bounds to the union of its meshes (centre x ' + gb.center.x.toFixed(2) + ', r ' + gb.radius.toFixed(2) + ')'); + sphere.scale.set(2, 1, 1); + h.check(m.radiusScaleOf(sphere) === 2, '0.24 the radius scale is the largest scale component'); + } + + const browser = await h.launch({ args: h.GPU_ARGS }); + { + 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 ball = await A.page.evaluate(() => { + window.__stores.commandsHandler.sceneCommand('/create Sphere 0.3'); + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const sphere = group.children[group.children.length - 1]; + sphere.name = 'Ball'; + sphere.position.set(0, 1, 0); + sphere.userData.physics = { mode: 'dynamic', mass: 1 }; + window.__stores.objectsGroup.update((v) => v); + window.__stores.objectActions.deselectObject(); + return sphere.uuid; + }); + + // ---------------------------------------------------------------- section 1 + console.log('\n=== 1. the initiator knocks its own body ==='); + + // zero-g so the ball stays where it is parked, and NO damping in this section: every + // speed below is read the instant the knock wrote it, and a knocked ball is simply + // parked again (applyThrow reseats and zeroes it) before the next sweep + await sp( + A.page, + 'sp.setScenePhysics({ gravity: 0, damping: { linear: 0 }, knock: { enabled: true } })' + ); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually( + () => bodyOf(A.page, ball), + (b) => !!b && b.mode === 'dynamic', + '1.1 (premise) the ball is a dynamic body on the simulating peer' + ); + await A.page.waitForTimeout(300); + await park(A.page, ball); + const parked = await bodyOf(A.page, ball); + h.check(speedOf(parked) < 0.01, '1.2 (premise) parked at rest in zero-g (|v| = ' + speedOf(parked).toFixed(3) + ')'); + + const two = await sweep(A.page, 'p', { uuid: ball, from: -1.2, to: 0, speed: 2 }); + h.check(two.armed, '1.3 (premise) the probe was armed (play + enabled + a sim)'); + h.check(two.hits === 1, '1.4 a 2 m/s sweep through the ball knocks it ONCE (' + two.hits + ' hits over ' + two.overlaps + ' overlapping feeds)'); + h.check( + !!two.atHit && Math.abs(two.atHit[0] - 2) < 0.2 && Math.abs(two.atHit[1]) < 0.05 && Math.abs(two.atHit[2]) < 0.05, + '1.5 ...and the ball leaves at ~2 m/s along the hand\'s direction (' + fmt(two.atHit) + ')' + ); + + await park(A.page, ball); + const six = await sweep(A.page, 'p', { uuid: ball, from: -1.2, to: 0, speed: 6 }); + h.check(six.hits === 1 && !!six.atHit && Math.abs(six.atHit[0] - 6) < 0.6, '1.6 a 6 m/s sweep leaves it at ~6 m/s (' + fmt(six.atHit) + '): monotonic in probe speed'); + + await park(A.page, ball); + await sp(A.page, 'sp.setScenePhysics({ knock: { gain: 2 } })'); + const gained = await sweep(A.page, 'p', { uuid: ball, from: -1.2, to: 0, speed: 2 }); + h.check(!!gained.atHit && Math.abs(gained.atHit[0] - 4) < 0.4, '1.7 gain 2 doubles it (2 m/s -> ' + fmt(gained.atHit) + ')'); + await sp(A.page, 'sp.setScenePhysics({ knock: { gain: 1 } })'); + + await park(A.page, ball); + await sp(A.page, 'sp.setScenePhysics({ knock: { maxSpeed: 3 } })'); + const capped = await sweep(A.page, 'p', { uuid: ball, from: -1.2, to: 0, speed: 6 }); + h.check(mag(capped.atHit) <= 3.001 && mag(capped.atHit) > 2.9, '1.8 maxSpeed 3 caps a 6 m/s knock at 3 (' + mag(capped.atHit).toFixed(3) + ')'); + await sp(A.page, 'sp.setScenePhysics({ knock: { maxSpeed: 12 } })'); + + await park(A.page, ball); + const receding = await sweep(A.page, 'p', { from: 0.1, to: 1.2, speed: 2 }); + const afterReceding = await bodyOf(A.page, ball); + h.check(receding.overlaps > 0, '1.9 (premise) a probe starting inside and moving away DID overlap'); + h.check(receding.hits === 0 && speedOf(afterReceding) < 0.01, '1.10 ...and a receding probe does nothing'); + + const resting = await A.page.evaluate((uuid) => { + const k = window.__stores.knock; + k.dropProbe('p'); + let hits = 0; + let overlaps = 0; + for (let i = 0; i < 12; i++) { + const r = k.feedProbe('p', [0.2, 1, 0], 1000 + i * 16); + hits += r.hits; + overlaps += r.overlaps; + } + return { hits, overlaps }; + }, ball); + const afterResting = await bodyOf(A.page, ball); + h.check(resting.overlaps > 0 && resting.hits === 0 && speedOf(afterResting) < 0.01, '1.11 a hand RESTING inside the ball does nothing (' + resting.overlaps + ' overlaps, ' + resting.hits + ' hits)'); + const slow = await sweep(A.page, 'p', { from: -0.6, to: -0.2, speed: 0.2 }); + h.check(slow.overlaps > 0 && slow.hits === 0, '1.11b a hand slower than minSpeed (0.2 < 0.3 m/s) does nothing either'); + + // ONE per pass: in, dither inside, out for longer than the hysteresis, back in. + // The second pass is FASTER on purpose: after the first knock the ball is already + // moving away at 2 m/s, and a hand at 2 m/s cannot catch it (approach 0 — the + // first version swept at the same speed and read the correct "no hit"). At 4 m/s + // the approach is 2, and the knock ADDS it: the ball leaves at 4. + await park(A.page, ball); + const passes = await A.page.evaluate((uuid) => { + const k = window.__stores.knock; + const p = window.__stores.physics; + k.dropProbe('q'); + let t = 1000; + let hits = 0; + const feed = (x) => { + hits += k.feedProbe('q', [x, 1, 0], t).hits; + t += 16; + }; + for (let x = -1.2; x <= -0.2; x += 0.032) feed(x); + const first = hits; + for (let i = 0; i < 20; i++) feed(i % 2 ? -0.3 : -0.2); // dithering INSIDE + const dithered = hits; + for (let i = 0; i < 8; i++) feed(-1.0); // out for 128 ms (> REARM_MS) + for (let x = -1.0; x <= -0.2; x += 0.064) feed(x); // 4 m/s + const b = p.physicsDebug().find((entry) => entry.uuid === uuid); + return { first, dithered, again: hits, speed: b ? Math.hypot(b.linvel.x, b.linvel.y, b.linvel.z) : NaN }; + }, ball); + h.check(passes.first === 1, '1.12 the first pass knocks once'); + h.check(passes.dithered === 1, '1.13 dithering inside the ball adds nothing (' + passes.dithered + ')'); + h.check(passes.again === 2, '1.14 leaving for longer than the hysteresis and coming back FASTER knocks again (' + passes.again + ')'); + h.check(Math.abs(passes.speed - 4) < 0.4, '1.14b ...and the second knock adds its approach on top of the ball\'s own speed (2 + 2 = ' + passes.speed.toFixed(3) + ')'); + + await park(A.page, ball); + await phys(A.page, 'p.holdBody("' + ball + '")'); + const held = await sweep(A.page, 'p', { from: -1.2, to: 0, speed: 2 }); + const afterHeld = await bodyOf(A.page, ball); + h.check(held.hits === 0 && afterHeld.hold === 'user', '1.15 a body somebody is carrying is never knocked (hold ' + afterHeld.hold + ', ' + held.hits + ' hits)'); + await phys(A.page, 'p.releaseBody("' + ball + '", { linvel: [0,0,0], angvel: [0,0,0] })'); + + await park(A.page, ball); + await sweep(A.page, 'p', { from: -1.2, to: 0, speed: 2 }); + const logged = await knock(A.page, 'return { last: k.lastHitOf("' + ball + '"), snap: k.hitLogSnapshot() }'); + h.check(logged.last && logged.last.by === A.id, '1.16 the log names the hitter (' + (logged.last?.by ?? 'nobody') + ')'); + h.check(logged.last && Math.abs(logged.last.speed - 2) < 0.2 && logged.last.probe === 'p', '1.17 ...with the approach speed and the probe (' + logged.last?.speed.toFixed(2) + ' m/s, ' + logged.last?.probe + ')'); + h.check(logged.snap.recent.length >= 5 && logged.snap.last[ball], '1.18 the snapshot carries the recent ring and the per-body last hit (' + logged.snap.recent.length + ')'); + + // THE COUNTERFACTUAL: the block off leaves the scene byte-identical to today + await park(A.page, ball); + const sentBefore = await knock(A.page, 'return k.knockDebug().sent'); + await sp(A.page, 'sp.setScenePhysics({ knock: { enabled: false } })'); + const off = await sweep(A.page, 'p', { from: -1.2, to: 0, speed: 6 }); + const afterOff = await bodyOf(A.page, ball); + const sentAfter = await knock(A.page, 'return k.knockDebug().sent'); + h.check(off.armed === false && off.hits === 0, '1.19 knock.enabled:false — nothing is armed, nothing hits'); + h.check(speedOf(afterOff) < 0.01 && sentAfter === sentBefore, '1.20 ...the body is untouched and nothing goes on the wire (sent ' + sentBefore + ' -> ' + sentAfter + ')'); + await sp(A.page, 'sp.setScenePhysics({ knock: { enabled: true } })'); + + await A.page.evaluate(() => window.__stores.isLocked.set(null)); + const editor = await sweep(A.page, 'p', { from: -1.2, to: 0, speed: 6 }); + h.check(editor.armed === false && editor.hits === 0, '1.21 out of play mode the probes stand down'); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + const stopped = await sweep(A.page, 'p', { from: -1.2, to: 0, speed: 6 }); + h.check(stopped.armed === false && stopped.hits === 0, '1.22 with no simulation anywhere the probes stand down'); + + // ---------------------------------------------------------------- section 2 + console.log('\n=== 2. a non-initiator knocks: the hit crosses the wire ==='); + + const B = await h.setupPage(browser, 'B'); + // the Connect pill lives in the editor chrome, which play mode hides — leave play + // to dial, and come back once the mesh has settled + await A.page.evaluate(() => window.__stores.isLocked.set(null)); + await h.connect(A, B); + await A.page.evaluate(() => window.__stores.objectActions.deselectObject()); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + // damping in THIS section, so a knocked ball comes to rest for the convergence + // reads; the applied speed is read through a hit LISTENER on A at the instant the + // message lands, before damping has had a step + await sp(A.page, 'sp.setScenePhysics({ damping: { linear: 1 } })'); + await A.page.evaluate((uuid) => { + window.__applied = null; + window.__stores.knock.registerHitListener((hit, local) => { + if (local || hit.uuid !== uuid) return; + const b = window.__stores.physics.physicsDebug().find((entry) => entry.uuid === uuid); + window.__applied = b?.linvel ? [b.linvel.x, b.linvel.y, b.linvel.z] : null; + }); + }, ball); + await B.page.evaluate(() => { + window.__stores.isLocked.set(true); + window.__sent = []; + let peer = null; + window.__stores.peers.subscribe((p) => (peer = p))(); + const original = peer.send.bind(peer); + peer.send = (message) => { + window.__sent.push(message); + return original(message); + }; + }); + // the sim starts AFTER B joined (the throw-peer order): `simulate` is sent at + // start/stop and not in the handshake, so a LATE JOINER is never told a sim is + // running and its probes never arm — a pre-existing gap (moveSmoothing's header + // records it for the same reason), noted in STATUS-24a as a follow-up + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => bodyOf(A.page, ball), (b) => !!b, '2.0 (premise) A is simulating again'); + await h.eventually( + () => B.page.evaluate(() => new Promise((r) => window.__stores.physics.remoteSimulating.subscribe(r)())), + (v) => !!v, + '2.1 (premise) B knows A is simulating' + ); + await h.eventually(() => sp(B.page, 'return sp.scenePhysicsDebug().knock.enabled'), (v) => v === true, '2.2 (premise) the knock block reached B over scenephysics'); + await park(A.page, ball); + await h.eventually( + () => posOf(B.page, ball), + (p) => !!p && Math.hypot(p[0], p[1] - 1, p[2]) < 0.05, + '2.3 (premise) B sees the ball parked at (0,1,0)' + ); + + const remote = await B.page.evaluate((uuid) => { + const k = window.__stores.knock; + k.dropProbe('b'); + let hits = 0; + let t = 1000; + for (let x = -1.2; x <= 0; x += 0.064) { + hits += k.feedProbe('b', [x, 1, 0], t).hits; + t += 16; + } + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const object = group.getObjectByProperty('uuid', uuid); + return { hits, predicting: k.knockDebug().predictions.includes(uuid), x0: object.position.x, local: k.lastHitOf(uuid) }; + }, ball); + h.check(remote.hits === 1, '2.4 B\'s 4 m/s sweep registers one knock locally'); + const message = await B.page.evaluate(() => window.__sent.find((m) => m.type === 'hit') ?? null); + h.check(!!message && message.uuid === ball, '2.5 ...and a `hit` message left B'); + h.check( + !!message && Math.abs(message.linvel[0] - 4) < 0.4 && Math.abs(message.linvel[1]) < 0.05, + '2.6 carrying the RESULT velocity (~4 m/s along x: ' + (message?.linvel ?? []).map((v) => v.toFixed(2)).join(', ') + ')' + ); + h.check(!!message && !('by' in message), '2.7 the message carries no `by` — the receiver stamps the connection'); + h.check(remote.predicting, '2.8 B started a local PREDICTION for the ball the instant it sent'); + + await h.eventually( + () => A.page.evaluate(() => window.__applied), + (v) => Array.isArray(v), + '2.9a the hit reached A' + ); + const applied = await A.page.evaluate(() => window.__applied); + const appliedBody = await bodyOf(A.page, ball); + h.check( + appliedBody?.hold === null && !!applied && Math.abs(applied[0] - 4) < 0.4 && Math.abs(applied[1]) < 0.05, + '2.9 A applied it to the body the instant it landed (' + fmt(applied) + ' m/s)' + ); + const logs = await Promise.all([ + knock(A.page, 'return k.lastHitOf("' + ball + '")'), + knock(B.page, 'return k.lastHitOf("' + ball + '")') + ]); + h.check(logs[0]?.by === B.id && logs[1]?.by === B.id, '2.10 both logs name B as the hitter (A says ' + logs[0]?.by + ')'); + h.check(logs[0] && logs[1] && logs[0].at === logs[1].at && Math.abs(logs[0].speed - logs[1].speed) < 1e-6, '2.11 ...with the SAME stamp and speed on both peers (A2 keys onhit by that stamp)'); + + const advanced = await B.page.evaluate( + ([uuid, x0]) => + new Promise((resolve) => + setTimeout(() => { + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const object = group.getObjectByProperty('uuid', uuid); + resolve(object.position.x - x0); + }, 60) + ), + [ball, remote.x0] + ); + h.check(advanced > 0.04, '2.12 within 60 ms B\'s rendered ball has moved along the hit (' + advanced.toFixed(3) + ' m) — prediction, or authority already landing on it'); + await h.eventually( + async () => { + const a = await posOf(A.page, ball); + const b = await posOf(B.page, ball); + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + }, + (gap) => gap < 0.3, + '2.13 the two peers converge on where the ball came to rest, over the ordinary move stream', + 12000 + ); + const stillPredicting = await knock(B.page, 'return k.knockDebug().predictions'); + h.check(stillPredicting.length === 0, '2.14 the prediction ended when authority\'s moves arrived'); + + // prediction OFF: still applied, nothing moved locally ahead of authority + await sp(A.page, 'sp.setScenePhysics({ knock: { predict: false } })'); + await h.eventually(() => sp(B.page, 'return sp.scenePhysicsDebug().knock.predict'), (v) => v === false, '2.15 (premise) predict:false reached B'); + await park(A.page, ball); + await A.page.evaluate(() => (window.__applied = null)); + await B.page.waitForTimeout(600); + const noPredict = await B.page.evaluate((uuid) => { + const k = window.__stores.knock; + k.dropProbe('b'); + let hits = 0; + let t = 5000; + for (let x = -1.2; x <= 0; x += 0.064) { + hits += k.feedProbe('b', [x, 1, 0], t).hits; + t += 16; + } + return { hits, predicting: k.knockDebug().predictions.includes(uuid) }; + }, ball); + h.check(noPredict.hits === 1 && !noPredict.predicting, '2.16 with predict:false B sends but predicts nothing'); + await h.eventually( + () => A.page.evaluate(() => window.__applied), + (v) => Array.isArray(v), + '2.17a the hit reached A' + ); + const appliedNoPredict = await A.page.evaluate(() => window.__applied); + h.check(!!appliedNoPredict && Math.abs(appliedNoPredict[0] - 4) < 0.4, '2.17 ...and A still applies it (' + fmt(appliedNoPredict) + ' m/s)'); + await h.eventually( + async () => { + const a = await posOf(A.page, ball); + const b = await posOf(B.page, ball); + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + }, + (gap) => gap < 0.3, + '2.18 prediction OFF still converges (one move interval of latency instead)', + 12000 + ); + await sp(A.page, 'sp.setScenePhysics({ knock: { predict: true } })'); + await h.eventually(() => sp(B.page, 'return sp.scenePhysicsDebug().knock.predict'), (v) => v === true, '2.19 (premise) predict:true is back on B'); + + // the capability gate: `hit` is CONTENT, so a plugin may refuse it — nothing applies, + // nothing is logged on A, and B's prediction is WITHDRAWN rather than stranded + await park(A.page, ball); + await B.page.waitForTimeout(600); + const logBefore = await knock(A.page, 'return k.lastHitOf("' + ball + '")?.at ?? 0'); + await A.page.evaluate(() => window.__stores.cloudHooks.setCapabilityProvider((peerId, type) => type !== 'hit')); + const gated = await B.page.evaluate((uuid) => { + const k = window.__stores.knock; + k.dropProbe('b'); + let hits = 0; + let t = 9000; + for (let x = -1.2; x <= 0; x += 0.064) { + hits += k.feedProbe('b', [x, 1, 0], t).hits; + t += 16; + } + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return { hits, predicting: k.knockDebug().predictions.includes(uuid), x0: group.getObjectByProperty('uuid', uuid).position.x }; + }, ball); + h.check(gated.hits === 1 && gated.predicting, '2.20 (premise) B knocked and is predicting'); + await A.page.waitForTimeout(250); + const refused = await bodyOf(A.page, ball); + const logAfter = await knock(A.page, 'return k.lastHitOf("' + ball + '")?.at ?? 0'); + h.check(speedOf(refused) < 0.01, '2.21 a `hit` the capability gate refuses is applied nowhere (|v| ' + speedOf(refused).toFixed(3) + ')'); + h.check(logAfter === logBefore, '2.22 ...and never reaches A\'s log'); + await h.eventually( + () => B.page.evaluate((uuid) => { + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const k = window.__stores.knock; + return { x: group.getObjectByProperty('uuid', uuid).position.x, predicting: k.knockDebug().predictions.includes(uuid) }; + }, ball), + (v) => !v.predicting && Math.abs(v.x) < 0.05, + '2.23 B\'s unconfirmed prediction is WITHDRAWN: the ball is back where it started (the PREDICT_MAX_MS revert)', + 4000 + ); + await A.page.evaluate(() => window.__stores.cloudHooks.setCapabilityProvider(null)); + + // the initiator's own knock reaches the other peer's log + await park(A.page, ball); + await B.page.waitForTimeout(600); + await sweep(A.page, 'p', { from: -1.2, to: 0, speed: 2 }); + await h.eventually( + () => knock(B.page, 'return k.lastHitOf("' + ball + '")?.by ?? null'), + (by) => by === A.id, + '2.24 A\'s own knock is broadcast too, so B\'s log names A' + ); + + // the block off on the AUTHOR replicates, and B then sends nothing + await sp(A.page, 'sp.setScenePhysics({ knock: { enabled: false } })'); + await h.eventually(() => sp(B.page, 'return sp.scenePhysicsDebug().knock.enabled'), (v) => v === false, '2.25 (premise) enabled:false reached B'); + const sentBeforeOff = await B.page.evaluate(() => window.__sent.filter((m) => m.type === 'hit').length); + const offOnB = await sweep(B.page, 'b', { from: -1.2, to: 0, speed: 6 }); + const sentAfterOff = await B.page.evaluate(() => window.__sent.filter((m) => m.type === 'hit').length); + h.check(offOnB.armed === false && offOnB.hits === 0 && sentAfterOff === sentBeforeOff, '2.26 with the block off B knocks nothing and sends nothing (' + sentBeforeOff + ' -> ' + sentAfterOff + ' hit messages)'); + + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + await h.finish(browser); +}); diff --git a/tests/e2e/scene-physics-state.test.cjs b/tests/e2e/scene-physics-state.test.cjs index d9a44518..fd7e3287 100644 --- a/tests/e2e/scene-physics-state.test.cjs +++ b/tests/e2e/scene-physics-state.test.cjs @@ -46,6 +46,13 @@ h.run(async () => { state.play.interaction === 'grab' && state.play.grounded === false && state.play.simOnPlay === false, '1.6 the play block ships {grab, not grounded, no sim on play}' ); + // 24-A A1: the knock block ships OFF — the whole compatibility story for every + // saved scene, and the value the knock-physics counterfactual measures + h.check( + JSON.stringify(state.knock) === + JSON.stringify({ enabled: false, gain: 1, maxSpeed: 12, minSpeed: 0.3, radius: 0.12, spin: 0.5, predict: true }), + '1.8 the knock block ships {off, gain 1, max 12, min 0.3, radius 0.12, spin 0.5, predict}' + ); const defaultsMatch = await sp( page, 'const { changedAt: a, ...live } = sp.scenePhysicsDebug();' + @@ -73,6 +80,18 @@ h.run(async () => { clamped.play.interaction === 'grab', '2.7 an unknown interaction falls back to grab, not through to the UI' ); + const knockClamped = await sp( + page, + 'return sp.normalizeScenePhysics({ knock: { gain: 99, maxSpeed: 999, minSpeed: -1, radius: 5, spin: -2, enabled: "yes", predict: 0 } }).knock' + ); + h.check( + knockClamped.gain === 5 && knockClamped.maxSpeed === 20 && knockClamped.minSpeed === 0 && knockClamped.radius === 1 && knockClamped.spin === 0, + '2.9 the knock block clamps (gain 5, maxSpeed 20 = the throw ceiling, minSpeed 0, radius 1, spin 0)' + ); + h.check( + knockClamped.enabled === false && knockClamped.predict === true, + '2.10 ...and its booleans refuse a non-boolean instead of coercing it' + ); // the clamp must be in the NORMALIZER, so a hostile wire payload cannot dodge it const viaWire = await sp( page, @@ -96,6 +115,17 @@ h.run(async () => { '3.2 its SIBLINGS survive (friction ' + merged.g.friction + ', enabled ' + merged.g.enabled + ')' ); h.check(merged.bounds.limit === -100, '3.3 an untouched block is untouched'); + const knockMerged = await sp( + page, + 'sp.setScenePhysics({ knock: { enabled: true } });' + + 'const k = sp.scenePhysicsDebug().knock;' + + 'sp.setScenePhysics({ knock: { enabled: false } });' + + 'return k' + ); + h.check( + knockMerged.enabled === true && knockMerged.gain === 1 && knockMerged.maxSpeed === 12, + '3.3b switching the knock on keeps its siblings (gain ' + knockMerged.gain + ', max ' + knockMerged.maxSpeed + ')' + ); const stamps = await sp( page, @@ -164,6 +194,26 @@ h.run(async () => { round.stamp > round.before, '5.5 a restore stamps FRESH — an old file\'s stale changedAt cannot lose to live state' ); + // 24-A A1: a file written before the knock existed carries no `knock` key, and + // restoring it must leave the knock OFF — an absent block means "at the default" + const oldFile = await sp( + page, + 'sp.setScenePhysics({ knock: { enabled: true, gain: 3 } });' + + 'sp.scenePhysicsRestore({ gravity: -5, ground: { height: 1 } });' + + 'const k = sp.scenePhysicsDebug().knock;' + + 'sp.setScenePhysics({ knock: { enabled: true, gain: 2 } });' + + 'const snap = sp.scenePhysicsSnapshot();' + + 'sp.scenePhysicsRestore(sp.DEFAULT_SCENE_PHYSICS);' + + 'return { k, snapKnock: snap && snap.knock }' + ); + h.check( + oldFile.k.enabled === false && oldFile.k.gain === 1, + '5.6 restoring a pre-knock file resets the block to OFF (enabled ' + oldFile.k.enabled + ', gain ' + oldFile.k.gain + ')' + ); + h.check( + oldFile.snapKnock && oldFile.snapKnock.enabled === true && oldFile.snapKnock.gain === 2, + '5.7 ...and a scene that switched it on saves the block with the singleton' + ); // ---------------------------------------------------------------- section 6 console.log('\n=== 6. two peers: B joins mid-session and inherits A\'s config ==='); From 6129314f4e4e6bbf78796217fdf96db07b9f1714 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 05:33:09 +0300 Subject: [PATCH 02/17] [feat] F3: proportional translate replicates its falloff neighbours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a proportional vertex drag ends with ONE whole-geometry meshgeo commit (commitMeshGeoSnapshot: applied locally, broadcast, recorded) instead of the selection-only verts stream — the falloff neighbourhood was never on the wire before, so a peer saw the selected vertex move and the bulge around it never arrive (19-A P4 gap; plan option A, locked) - the LOCAL apply is load-bearing: applyMeshGeo rebuilds the receiver non-indexed while a /create Plane is indexed, so both sides swap together or the sender's next verts indices address a layout the peer no longer holds (the undo path's rule) - the swap rebuilds handles in triangle order and the refresher clamps the selection by COUNT only, so the selection is captured as positions before the commit and re-found by position after (commitFalloffSnapshot) - a commit refused by MAX_SNAPSHOT falls back to the old verts + entry path - suite mesh-falloff-sync (two peers, 24 checks): B's neighbours match A's corner for corner, both hold the same layout, a later plain drag lands on the same corner for B, one undo flattens the bulge on both - counterfactual: with the end-of-drag commit replaced by `false`, mesh-falloff-sync reads B's halfway neighbour 0.0000 vs A's 0.5000 (red) - held: mesh-proportional 63, mesh-pivot-gizmo 131, mesh-edge-gizmo 26 (base) - svelte-check 361/47, list identical to base; build green (server stopped) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm --- src/lib/meshEdit.js | 49 +++++++- tests/e2e/mesh-falloff-sync.test.cjs | 172 +++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/mesh-falloff-sync.test.cjs diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js index 753dbc5d..9c67fec5 100644 --- a/src/lib/meshEdit.js +++ b/src/lib/meshEdit.js @@ -1523,6 +1523,40 @@ function broadcastSelected(positionArray) { }); } +/** + * F3 (v1.13): a PROPORTIONAL drag ends with ONE whole-geometry `meshgeo` commit — + * applied locally, broadcast, and recorded as the undo entry — instead of the + * selection-only `verts` stream. The falloff neighbourhood was never on the wire + * before (only the gesture's own handles were sent, per handle), so a peer saw the + * selected vertices move and the bulge around them never arrive. + * + * The LOCAL apply is load-bearing, not a convenience: `applyMeshGeo` rebuilds the + * receiver's geometry NON-indexed, while a `/create Plane` is indexed — so if only + * the peer swapped, the sender's next `verts` message would carry indices into a + * layout the peer no longer has. Both sides swap together (the undo path's rule). + * + * THE TRAP that follows: the swap rebuilds `handles` in triangle order through + * `refreshVertexEditSession`, which clamps the selection by COUNT only, so the + * indices would silently name different vertices. The selection is captured as + * POSITIONS before the commit and re-found by position after it. + * @param {number[]} before @param {number[]} after + * @returns {boolean} false when the commit was refused (size cap) and nothing changed + */ +function commitFalloffSnapshot(before, after) { + if (!edited || selectedHandle < 0) return false; + const anchorPos = handles[selectedHandle].position.clone(); + const memberPos = [...vertexSelection].map((i) => handles[i]?.position.clone()).filter(Boolean); + if (!commitMeshGeoSnapshot(edited.uuid, before, after)) return false; + // handles were rebuilt by the vertex session refresher — same positions, new order + const find = (/** @type {any} */ p) => handles.findIndex((h) => h.position.distanceToSquared(p) < 1e-10); + const anchor = find(anchorPos); + vertexSelection = new Set(memberPos.map(find).filter((i) => i >= 0)); + selectedHandle = anchor; + if (anchor >= 0) vertexSelection.add(anchor); + syncVertexSelection(); + return true; +} + /** Called from Scene.svelte on dragging-changed for the proxy @param {boolean} dragging */ export function onProxyDragChanged(dragging) { if (!edited || !proxy) return; @@ -1574,8 +1608,19 @@ export function onProxyDragChanged(dragging) { // catch any tail movement since the last change event applyProxyGesture(); const after = handles[selectedHandle].position.toArray(); - broadcastGesture(); // final unthrottled state, every moved handle - if (vertexSelection.size > 1 || falloffActive() || mode !== 'translate') { + // F3: a live falloff commits the WHOLE geometry once (see commitFalloffSnapshot); + // read the predicate here, before the falloff state is cleared below + let committedWhole = false; + if (falloffActive() && dragStartExpanded) { + const afterExpanded = trisToPositions(readTriangles(edited.geometry)); + committedWhole = + JSON.stringify(dragStartExpanded) === JSON.stringify(afterExpanded) || + commitFalloffSnapshot(dragStartExpanded, afterExpanded); + } + if (!committedWhole) broadcastGesture(); // final unthrottled state, every moved handle + if (committedWhole) { + // the commit applied, sent and recorded everything + } else if (vertexSelection.size > 1 || falloffActive() || mode !== 'translate') { const afterExpanded = trisToPositions(readTriangles(edited.geometry)); if (dragStartExpanded && JSON.stringify(dragStartExpanded) !== JSON.stringify(afterExpanded)) recordEntry({ diff --git a/tests/e2e/mesh-falloff-sync.test.cjs b/tests/e2e/mesh-falloff-sync.test.cjs new file mode 100644 index 00000000..2e623cf9 --- /dev/null +++ b/tests/e2e/mesh-falloff-sync.test.cjs @@ -0,0 +1,172 @@ +// F3 (v1.13): a PROPORTIONAL vertex drag replicates its falloff NEIGHBOURS. +// +// Before this, only the gesture's own handles went over the `verts` channel, so a peer +// watched the selected vertex rise and the bulge around it never arrive — until some +// unrelated full-geometry sync (a topology op, a reload, a late join) happened to carry +// it. Option A of the plan: stream nothing extra during the drag, commit ONE `meshgeo` +// on drag end (the same snapshot the undo entry already held), applied on BOTH sides so +// the geometry representation stays the same on each. +// +// The checks read NEIGHBOUR positions specifically, corner for corner — an aggregate +// health check (max/min/spread) passes with the neighbours unmoved, which is exactly how +// this gap survived 19-A P4. Counterfactual (proven at commit time): with the end-of-drag +// commit removed, B's halfway vertex reads 0 while A's reads 0.5 → red. +const h = require('./helpers.cjs'); + +/** z of the first position entry at grid (x, y) on the object with this uuid */ +const Z_AT = ({ uuid, x, y }) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const object = g?.getObjectByProperty('uuid', uuid); + const position = object?.geometry?.attributes?.position; + if (!position) return null; + for (let i = 0; i < position.count; i++) + if (Math.abs(position.getX(i) - x) < 1e-4 && Math.abs(position.getY(i) - y) < 1e-4) return position.getZ(i); + return null; +}; +const zAt = (page, uuid, x, y) => page.evaluate(Z_AT, { uuid, x, y }); +const smooth = (t) => (t <= 0 ? 1 : t >= 1 ? 0 : 1 - t * t * (3 - 2 * t)); + +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 makes the grid (a PlaneGeometry lies in XY, so the drag goes along Z; 4/8 = 0.5 step) + const uuid = await A.page.evaluate(() => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create Plane 4 4 8 8'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + return g.children[g.children.length - 1].uuid; + }); + await h.eventually( + () => zAt(B.page, uuid, 0, 0), + (z) => z !== null, + 'B holds the plane (premise)', + 20000 + ); + + // A: edit mode, pick the origin vertex, proportional on, drag it +1 in Z, release + const drag = await A.page.evaluate((uuid) => { + const s = window.__stores; + const me = s.meshEdit; + me.enterEditMode(uuid); + let controls; + s.TControls.subscribe((c) => (controls = c))(); + let anchor = -1; + for (let i = 0; i < 81; i++) { + me.selectHandle(i); + const p = controls.object?.position; + if (!p) break; + if (Math.hypot(p.x, p.y) < 1e-6) { + anchor = i; + break; + } + } + if (anchor < 0) return { missing: true }; + me.selectHandle(anchor); + me.proportionalEdit.set(true); + me.proportionalRadius.set(1); + me.onProxyDragChanged(true); + controls.object.position.z += 0.4; + me.onProxyMoved(); + controls.object.position.z += 0.6; + me.onProxyMoved(); + me.onProxyDragChanged(false); + // the gizmo must still sit on the vertex that was dragged (the selection was + // re-found by POSITION after the geometry swap re-ordered the handles) + const seat = controls.object?.position?.clone(); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + const object = g.getObjectByProperty('uuid', uuid); + const local = seat ? object.worldToLocal(seat.clone()) : null; + return { + seat: local ? [local.x, local.y, local.z] : null, + indexed: !!object.geometry.index, + count: object.geometry.attributes.position.count + }; + }, uuid); + h.check(!drag.missing, 'A found the origin vertex (premise)'); + + // --- A's own picture, corner for corner ----------------------------------- + const pts = [ + ['anchor', 0, 0, 1], + ['+x half', 0.5, 0, smooth(0.5)], + ['-x half', -0.5, 0, smooth(0.5)], + ['+y half', 0, 0.5, smooth(0.5)], + ['diagonal', 0.5, 0.5, smooth(Math.SQRT1_2)], + ['rim', 1, 0, 0], + ['beyond', 1.5, 0, 0] + ]; + for (const [label, x, y, expect] of pts) { + const z = await zAt(A.page, uuid, x, y); + h.check(z !== null && Math.abs(z - expect) < 1e-3, `A: ${label} sits at z=${expect.toFixed(3)} (${z?.toFixed(4)})`); + } + h.check( + drag.seat && Math.abs(drag.seat[0]) < 1e-4 && Math.abs(drag.seat[1]) < 1e-4 && Math.abs(drag.seat[2] - 1) < 1e-3, + `A's gizmo still sits on the dragged vertex after the swap (${JSON.stringify(drag.seat?.map((n) => +n.toFixed(3)))})` + ); + h.check(!drag.indexed, 'A swapped to the same NON-indexed representation the peer will hold'); + + // --- B receives the NEIGHBOURS, not just the selection --------------------- + await h.eventually( + () => zAt(B.page, uuid, 0.5, 0), + (z) => z !== null && Math.abs(z - smooth(0.5)) < 1e-3, + `B's halfway neighbour rose by the smoothstep weight (${smooth(0.5)})`, + 15000 + ); + for (const [label, x, y] of pts) { + const a = await zAt(A.page, uuid, x, y); + const b = await zAt(B.page, uuid, x, y); + h.check(a !== null && b !== null && Math.abs(a - b) < 1e-5, `B matches A corner for corner: ${label} (${a?.toFixed(4)} vs ${b?.toFixed(4)})`); + } + const bCount = await B.page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const object = g?.getObjectByProperty('uuid', uuid); + return { count: object?.geometry?.attributes?.position?.count, indexed: !!object?.geometry?.index }; + }, uuid); + h.check(bCount.count === drag.count && !bCount.indexed, `both peers hold the same layout (${drag.count} / ${bCount.count} entries)`); + + // --- a FOLLOW-UP plain drag still addresses the right vertices on the peer ---- + // (the representation agreement above is what makes this true: the sender's verts + // indices must mean the same corners on the receiver) + await A.page.evaluate(() => { + const s = window.__stores; + const me = s.meshEdit; + me.proportionalEdit.set(false); + let controls; + s.TControls.subscribe((c) => (controls = c))(); + me.onProxyDragChanged(true); + controls.object.position.z += 0.5; + me.onProxyMoved(); + me.onProxyDragChanged(false); + }); + await h.eventually( + () => zAt(B.page, uuid, 0, 0), + (z) => z !== null && Math.abs(z - 1.5) < 1e-3, + 'a later plain vertex drag lands on the same corner for B (indices agree)', + 15000 + ); + const stillHalf = await zAt(B.page, uuid, 0.5, 0); + h.check(Math.abs(stillHalf - smooth(0.5)) < 1e-3, `...and touched no neighbour on B (${stillHalf?.toFixed(4)})`); + + // --- ONE undo flattens the bulge on A and reaches B -------------------------- + await A.page.evaluate(() => { + window.__stores.history.undo(); // the plain drag + window.__stores.history.undo(); // the whole bulge + }); + const undone = await zAt(A.page, uuid, 0.5, 0); + h.check(Math.abs(undone) < 1e-6, `one undo flattens the whole bulge on A (${undone?.toFixed(6)})`); + await h.eventually( + () => zAt(B.page, uuid, 0.5, 0), + (z) => z !== null && Math.abs(z) < 1e-6, + 'the undo replicates to B', + 15000 + ); + + await A.page.evaluate(() => window.__stores.meshEdit.exitEditMode()); + await h.finish(browser); +}); From d701b6d510845f3ebbfdb868131ea9da8e7ebe28 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 05:33:09 +0300 Subject: [PATCH 03/17] [fix] F2: vertex slide says why it stands down under a placed pivot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decision (plan option A, the interaction itself stays WONTFIX): the slide measures from the PROXY and a placed pivot seats the proxy away from the vertex, so a slide under a pivot would project from an unrelated point; slide constrains ONE vertex to its own edge, a pivot transforms a SET about a chosen centre — different questions, no evidence anyone wants both at once - the tool silently doing nothing was the only real problem: proxyLocal now toasts once per session when the slide is armed, a drag has started and a custom pivot is placed (slidePivotTold, reset in exitEditMode) - no wire, no history, no toolbox change; held mesh suites at base - svelte-check 361/47, list identical to base; build green (server stopped) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm --- src/lib/meshEdit.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js index 9c67fec5..9ebebb9e 100644 --- a/src/lib/meshEdit.js +++ b/src/lib/meshEdit.js @@ -575,6 +575,7 @@ export function exitEditMode() { hoveredHandle = -1; slideEdge = null; slideStart = null; + slidePivotTold = false; vertexSlide.set(false); // an armed tool never survives the session proportionalEdit.set(false); falloffStart = null; @@ -1124,6 +1125,8 @@ function refreshGeometryAfterWrite() { export const vertexSlide = writable(false); /** the edge chosen for the live slide: local-space endpoints @type {any} */ let slideEdge = null; +/** F2: the stand-down toast fires once per session @type {boolean} */ +let slidePivotTold = false; /** local-space position at drag start (the origin for the direction vote) @type {any} */ let slideStart = null; /** the live slide's parameter along its edge (0 = start, 1 = far end) — kept @@ -1206,8 +1209,17 @@ function proxyLocal() { // the slide projects the PROXY's position onto one of the vertex's own edges, // which only means anything while the proxy IS the vertex — a custom pivot // seats it somewhere else entirely, so the constraint stands down there - if (!get(vertexSlide) || !slideStart || vertexSelection.size > 1 || hasMeshPivot(edited.uuid)) + if (!get(vertexSlide) || !slideStart || vertexSelection.size > 1 || hasMeshPivot(edited.uuid)) { + // F2 (v1.13, decided WONTFIX for the interaction, made VISIBLE): the slide + // measures from the PROXY, and a placed pivot seats the proxy away from the + // vertex, so the projection would slide it by a nonsense amount. The tool + // silently doing nothing was the only real problem — say so, once a session. + if (get(vertexSlide) && slideStart && vertexSelection.size <= 1 && !slidePivotTold && hasMeshPivot(edited.uuid)) { + slidePivotTold = true; + showToast('Vertex slide is off while a custom pivot is placed — the slide measures from the gizmo, and the pivot moved it off the vertex. Reset the pivot to slide.'); + } return local; + } if (!slideEdge) { // choose on the first REAL movement: the incident edge whose direction best // matches how the user started dragging (a tiny jitter must not decide it) From 52a3b0e79ce5dc9466373801152fe329df62e820 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 08:02:46 +0300 Subject: [PATCH 04/17] [feat] 24-A A2: onhit node, api.onHit/hitLog, Knock rows, haptics, simulate in the handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `onhit` trigger node (Triggers, beside onimpact): minSpeed, who anyone|me|others; fired on EVERY peer from the `hit` application (fireObjectHit) as a trigger stamp derived from the message's own `at` — one message per knock, no nodetrigger, the stamp literally equal on every peer; value outputs speed/byMe as a handle map with a `__default` pulse on the unnamed dot (unwrapHandle grew `__default`; every earlier handle-map producer omits it, so they are byte-unchanged) - own card OnHitNode.svelte (pulse dot + speed/byMe rows, the MoveInput shape), registered in Nodes.svelte's CORE_NODE_TYPES (the two-registry gotcha) - api.onHit(cb) -> unsubscribe + api.hitLog(); the knock import is PRIMED and kept as a promise so a listener registered at module boot is not dropped (the DEVX #8 family) - a LOCAL VR hit buzzes the hitting hand (min(1, 0.2 + speed/10), 30 ms) through a haptic seam Scene passes into startKnock (hapticPulse); the head probe buzzes nothing - sendHandshake pushes `simulate` when this peer is simulating (A1's late-joiner finding: the probes and play-mode grab never armed for a joiner mid-run), held with the singletons under the same !holdContent condition; additive, the start message's own shape - Inspector > Physics > Knock: enabled checkbox + Gain / Max speed / Probe radius / Spin rows, data-anchor Knock -> openSceneSection('Physics:Knock') - suite knock-node (50 checks: two peers + a late joiner, PEER_CONFIG, GPU_ARGS): who and minSpeed on both peers, the equal stamp, speed/byMe, the per-player touches banked once per hit per peer (the 21-F3 double-bank counter-case), the SDK payload agreeing with the node's stamp, the handshake simulate, the Inspector rows + deep link, the haptic seam - counterfactuals, each red then restored: fireObjectHit without its applyNodeTrigger -> 23 pass / 11 fail (2.2, 2.4, 2.5, 2.7, 2.11, 3.2-3.4: nothing stamped anywhere); the who gate removed -> 44 / 7 (2.3, 2.6, 3.3, 3.4 and the double-bank 2.11/3.6: `me` fired on the other peer); the handshake simulate push removed -> 47 / 4 (5.1-5.3: the late joiner's remoteSimulating stays null); unwrapHandle without __default -> 49 / 2 (2.10: the unnamed edge reads undefined, the Math node its 5 fallback) - held: knock-physics 77/0, scene-physics-state 37/0, play-interact 46/0, throw-peer 28/0, flow-unknown-node 24/0 (the new card resolves), game-towers 20/0; flow-spawner 39/0 39 pass then `SCRIPT FAILED: page.waitForSelector('.svelte-flow') Timeout 15000ms` — A/B'd AND PRE-EXISTING: pristine origin/release/next src on this same box, server and lock fails IDENTICALLY (39 pass, same timeout at the same point). It is the suite's own last check in §7, which opens the node editor by clicking `p[title="Node editor (N)"]`; that string exists only as a menu-item PROPERTY in Controls.svelte, so the opener looks stale on this build — a separate ticket, not this lane's (flow-unknown-node mounts the same editor 24/0 with the new card); palette-groups 10/1 — the one red is PRE-EXISTING on release/next (the Music group has no NodeWrapper accent; NodeWrapper untouched here) - svelte-check 361/47 (base 361/47, identical entry list); build green Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013je8UxeJpoMSRcyejTiNFj --- src/components/Scene.svelte | 5 +- src/components/editors/Nodes.svelte | 4 + src/components/editors/nodes/OnHitNode.svelte | 72 +++ src/components/menu/Inspector.svelte | 56 +++ src/lib/flowRuntime.js | 75 +++- src/lib/flowSockets.js | 3 + src/lib/knock.js | 11 +- src/lib/moduleSDK.js | 45 ++ src/lib/nodeCatalog.js | 16 + src/lib/peerHandler.svelte.js | 10 +- tests/e2e/knock-node.test.cjs | 414 ++++++++++++++++++ 11 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 src/components/editors/nodes/OnHitNode.svelte create mode 100644 tests/e2e/knock-node.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 10d75f2c..fa75cc36 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -50,7 +50,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticPulse } from '$lib/vrControls'; import { vrKeyboardTarget } from '$lib/vrKeyboard'; import { measureMode, measureClick } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; @@ -1246,7 +1246,8 @@ // 24-A A1: the knock's feeds. The VR hand poses come from vrControls through // this seam rather than an import (knock.js stays off vrControls' 3500 lines), // and the two "what am I holding" reads keep a probe off its own carried object. - startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()] }); + // A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses + startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticPulse }); xrControllers.forEach((controller) => { controller.addEventListener('select', onXRSelect); diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 875be0b4..adfaffd2 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -58,6 +58,7 @@ import GamepadNode from './nodes/GamepadNode.svelte'; import PlayAnimNode from './nodes/PlayAnimNode.svelte'; import AnimStateNode from './nodes/AnimStateNode.svelte'; + import OnHitNode from './nodes/OnHitNode.svelte'; import UnknownNode from './nodes/UnknownNode.svelte'; import { flowNodes as flowNodesStore, flowEdges as flowEdgesStore, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; @@ -157,6 +158,9 @@ setuniform: EffectNode, onclick: OnClickNode, onimpact: AnimationNode, + // 24-A A2: its own card — the pulse dot PLUS speed/byMe value rows (the MoveInput + // shape: several source handles need labelled rows, not one right-edge dot) + onhit: OnHitNode, onenter: OnClickNode, // CL-C: same pulse card, sensor copy onexit: OnClickNode, collider: ColliderNode, // CL-C diff --git a/src/components/editors/nodes/OnHitNode.svelte b/src/components/editors/nodes/OnHitNode.svelte new file mode 100644 index 00000000..fb02a877 --- /dev/null +++ b/src/components/editors/nodes/OnHitNode.svelte @@ -0,0 +1,72 @@ + + + + +
+
+ + {pulsing ? 'hit!' : 'idle'} +
+
+ min m/s + setNodeData(id, { minSpeed: v })} + /> +
+ +
+ speed + {(+(handles.speed ?? 0)).toFixed(2)} + +
+
+ by me + + {handles.byMe ? 'yes' : 'no'} + + +
+

connect to the object; pulses when a hand or player knocks it

+
+
diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index c4f2393c..ec3ff9ca 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -67,6 +67,7 @@ scenePhysicsBounds, scenePhysicsDefaults, scenePlay, + sceneKnock, DEFAULT_GRAVITY } from '$lib/scenePhysics'; import { scenePost, sceneProvidesAo } from '$lib/scenePost'; @@ -2425,6 +2426,61 @@ Dynamic objects fall and collide while a simulation runs (▶ or P).

+ +

Knock

+ setScenePhysics({ knock: { enabled: e.currentTarget.checked } })} + > + Hands and players knock dynamic objects + + {#if $sceneKnock.enabled} + setScenePhysics({ knock: { gain: v } })} + /> + setScenePhysics({ knock: { maxSpeed: v } })} + /> + setScenePhysics({ knock: { radius: v } })} + /> + setScenePhysics({ knock: { spin: v } })} + /> + {/if} +

+ An open VR hand, or walking into an object on desktop, sends it off at the speed it + was hit. Grip still grabs. Shared, and it needs a running simulation. +

+

Play mode

Pointer diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 6048db04..3f26ecc9 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1779,6 +1779,7 @@ export const valueTypes = [ 'gamepadbutton', // 21-E5: pad trigger — the keypress model verbatim 'gamepadaxis', // 21-E5: a stick, read LOCALLY (never streamed) 'onimpact', // PFX-C: physics impact trigger + 'onhit', // 24-A A2: the knock's trigger — a handle map: __default pulse + speed/byMe 'onenter', 'onexit', // CL-C: sensor overlap triggers 'velocity', // CL-C: live speed readout (m/s) 'measure', // B6: an object's top / bottom / height / y / speed @@ -1813,7 +1814,11 @@ let graphOutputs = {}; * @param {any} value @param {any} edge */ function unwrapHandle(value, edge) { if (value && typeof value === 'object' && value.__handles) - return edge?.sourceHandle ? value.__handles[edge.sourceHandle] : undefined; + // 24-A A2: `__default` is what the UNNAMED output handle reads — On Hit keeps its + // pulse on the ordinary right-edge dot (so it wires like On Click into an Object + // Selector or a Counter) and carries speed/byMe as named handles beside it. Every + // earlier handle-map producer omits it, so an unnamed edge there still reads undefined. + return edge?.sourceHandle ? value.__handles[edge.sourceHandle] : value.__default; return value; } @@ -2194,6 +2199,18 @@ function evalNodeBody(node, allNodes, allEdges, time, seen, ctx) { const dt = trig ? time - trig.lastT : Infinity; return dt >= 0 && dt < num(d.pulse ?? 0.3) ? 1 : 0; } + case 'onhit': { + // 24-A A2: the knock's trigger. fireObjectHit stamps this node on EVERY peer + // from the hit message's own `at`, so the pulse agrees everywhere with no second + // message; speed/byMe are the LAST accepted hit's, held until the next one. + const trig = ctx && ctx.triggers ? ctx.triggers[node.id] : null; + const dt = trig ? time - trig.lastT : Infinity; + const info = hitInfo.get(node.id); + return { + __default: dt >= 0 && dt < num(d.pulse ?? 0.3) ? 1 : 0, + __handles: { speed: info ? info.speed : 0, byMe: info && info.byMe ? 1 : 0 } + }; + } case 'onenter': case 'onexit': { // CL-C: sensor overlap edges arrive as replicated trigger stamps @@ -2662,6 +2679,59 @@ export function fireObjectImpact(uuid, strength) { }); } +/** + * 24-A A2: the last hit each On Hit node ACCEPTED — its value outputs. Runtime state + * keyed by node id (a late joiner reads 0 until the next knock; the trigger log it is + * handed carries the stamps, not the speeds). + * @type {Map} + */ +const hitInfo = new Map(); + +/** + * A hit's wall-clock `at` (ms — the sender's Date.now, monotonic per sender) as a trigger + * stamp in the tick clock's seconds: the fold syncedNow applies to Date.now, so every peer + * derives ONE stamp from one message. Off the synced clock there is no peer to agree + * with and the tick clock is performance-based, so the local clock is used instead. + * @param {number} atMs + */ +function stampFromWallClock(atMs) { + return synced && Number.isFinite(atMs) ? (atMs % 86400000) / 1000 : syncedNow(); +} + +/** + * 24-A A2: a body was KNOCKED (knock.js — this peer's own probe, or a peer's `hit` + * message being applied) — pulse every On Hit node targeting it whose `minSpeed` and + * `who` gates pass. Unlike fireObjectImpact this runs on EVERY peer from the SAME + * message, so the stamp is derived from the message's `at` and NOT replicated: a + * nodetrigger on top would stamp every peer twice. `who` is read against `by` per peer, + * which is how `me` reaches a setvariable scope:'player' without a second writer. + * @param {{uuid: string, by?: string, at: number, speed: number}} hit + * @param {boolean} local true on the peer whose probe hit + * @returns {number} nodes pulsed + */ +export function fireObjectHit(hit, local) { + if (!hit || typeof hit.uuid !== 'string') return 0; + const me = /** @type {any} */ (get(peers))?.peer?.id ?? ''; + const byMe = !!local || (!!hit.by && hit.by === me); + const ctx = runtimeCtx(); + const stamp = stampFromWallClock(hit.at); + const speed = Number.isFinite(hit.speed) ? hit.speed : 0; + let fired = 0; + nodes.forEach((node) => { + if (node.type !== 'onhit') return; + if (!(reachesObjectSelector(node.id, hit.uuid) || implicitOwnerOf(node) === hit.uuid)) return; + const data = resolveInputs(node, nodes, edges, syncedNow(), ctx); + if (speed < num(data.minSpeed ?? 0)) return; + const who = data.who ?? 'anyone'; + if (who === 'me' && !byMe) return; + if (who === 'others' && byMe) return; + hitInfo.set(node.id, { speed, byMe, at: hit.at }); + applyNodeTrigger(node.id, stamp, false); + fired++; + }); + return fired; +} + /** * B6: the physics INITIATOR reports how long a dynamic body has been still * (0 = it is moving). Same shape as fireObjectImpact: initiator-detected, @@ -3342,6 +3412,9 @@ export function startFlowRuntime() { import('./possess').then((m) => (possessRef = m)); // 21-F4: the travel node's loader + the allplayers verdict channel import('./levels').then((m) => (levelsRef = m)); + // 24-A A2: the knock's hit feed drives On Hit. PRIMED for the same reason as physics: + // knock.js imports physics, which imports this module. + import('./knock').then((m) => m.registerHitListener((hit, local) => fireObjectHit(hit, local))); import('./gamePresence').then((m) => (presenceRef = m)); flowGraphs.subscribe(() => { nodes = allNodes(); diff --git a/src/lib/flowSockets.js b/src/lib/flowSockets.js index d306bb78..96a5eaab 100644 --- a/src/lib/flowSockets.js +++ b/src/lib/flowSockets.js @@ -24,6 +24,9 @@ const OUTPUT = { gamepadbutton: 'event', gamepadaxis: 'number', onimpact: 'event', // PFX-C + // 24-A A2: the pulse is the unnamed handle; `speed`/`byMe` are named handles that + // reach number/boolean inputs through the event coercion row (outputType is per NODE) + onhit: 'event', onenter: 'event', onexit: 'event', // CL-C: sensor overlap edges animfinished: 'event', // 17-E: a clip reached its end animmarker: 'event', // 17-E F5: the playhead crossed a named point in a clip diff --git a/src/lib/knock.js b/src/lib/knock.js index 3fec1ac4..30e876e0 100644 --- a/src/lib/knock.js +++ b/src/lib/knock.js @@ -96,6 +96,9 @@ const probes = new Map(); let hands = null; /** @type {(() => (string | null)[]) | null} */ let heldUuids = null; +/** A2: Scene's haptic seam (vrControls.hapticPulse), `(intensity, ms, hand) => void` + * @type {((intensity: number, ms: number, hand: 'left'|'right') => void) | null} */ +let haptic = null; let started = false; /** @type {Map} */ const boundsCache = new Map(); @@ -330,6 +333,10 @@ function fireKnock(probe, object, speed, point, response) { probe: hit.probe }); } + // A2: the hand that hit feels it — LOCAL only (the message carries no haptic), and + // the head probe is desktop, where there is nothing to buzz. 0.2 + speed/10, capped. + if (haptic && (probe.id === 'left' || probe.id === 'right')) + haptic(Math.min(1, 0.2 + speed / 10), 30, probe.id); noteHit(hit, true); return true; } @@ -540,13 +547,14 @@ export function dropProbe(id) { /** * Wire the feeds. Called from Scene's onMount beside startPlayInteract — BELOW every * `let` its closures read (the TDZ rule). - * @param {{hands?: (hand: 'left'|'right') => any, heldUuids?: () => (string | null)[]}} [options] + * @param {{hands?: (hand: 'left'|'right') => any, heldUuids?: () => (string | null)[], haptic?: (intensity: number, ms: number, hand: 'left'|'right') => void}} [options] */ export function startKnock(options = {}) { if (started || typeof window === 'undefined') return () => {}; started = true; hands = options.hands ?? null; heldUuids = options.heldUuids ?? null; + haptic = options.haptic ?? null; return stopKnock; } @@ -555,6 +563,7 @@ export function stopKnock() { started = false; hands = null; heldUuids = null; + haptic = null; probes.clear(); predictions.clear(); bodyTracks.clear(); diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 48b2cf74..0185afb8 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -174,7 +174,15 @@ let flowGraphsRef = null; /** R3a: primed for api.flow.addNodes' spec defaults — nodeCatalog statically imports * THIS module, so a static edge back is a direct cycle. @type {any} */ let nodeCatalogRef = null; +/** 24-A A2: primed for api.onHit / api.hitLog — knock.js imports physics, which imports + * flowRuntime, which imports THIS module (the same cycle as the refs above). The promise + * is kept as well as the ref, because a listener registered at module boot must not be + * dropped for arriving before the import settles (the DEVX #8 family). @type {any} */ +let knockRef = null; +/** @type {Promise} */ +let knockReady = Promise.resolve(null); if (typeof window !== 'undefined') { + knockReady = import('./knock').then((m) => (knockRef = m)); import('./inputRuntime').then((m) => (inputRuntimeRef = m)); import('./physics').then((m) => (physicsRef = m)); import('./possess').then((m) => (possessRef = m)); @@ -724,6 +732,43 @@ function makeApi(moduleId, moduleName = moduleId) { haptic(intensity = 0.5, durationMs = 50, hand = undefined) { vrControlsRef?.hapticPulse?.(intensity, durationMs, hand); }, + /** + * 24-A A2: every KNOCK this peer sees — its own hand's, and every peer's as the + * `hit` message is applied — as `{uuid, by, at, speed, point, linvel, angvel, + * probe, local}`. `local` is true on the peer whose hand it was; `by` is that + * peer's id (empty when solo). The same feed On Hit stamps from, so a module and a + * graph agree on which hits happened. Returns the unsubscribe; torn down with the + * module. Football's last-touch attribution rides this, evaluated BY EACH PEER + * (the peerVars one-writer rule). + * @param {(hit: any) => void} fn @returns {() => void} + */ + onHit(fn) { + /** @param {any} hit @param {boolean} local */ + const wrapped = (hit, local) => fn({ ...hit, local }); + /** @type {(() => void) | null} */ + let off = null; + let gone = false; + knockReady.then((m) => { + if (m && !gone) off = m.registerHitListener(wrapped); + }); + const stop = () => { + gone = true; + off?.(); + off = null; + }; + onDispose(stop); + return stop; + }, + /** + * 24-A A2: the knock log as a COPY — `last` = the most recent hit per live body + * (keyed by uuid), `recent` = the last 32 hits in order. Runtime state: a late + * joiner's log starts empty (a module that needs history keeps its own through + * registerStateSync). + * @returns {{last: Record, recent: any[]}} + */ + hitLog() { + return knockRef?.hitLogSnapshot?.() ?? { last: {}, recent: [] }; + }, /** In a VR session right now? (DEVX #6) @returns {boolean} */ isVR() { return !!get(isVRMode); diff --git a/src/lib/nodeCatalog.js b/src/lib/nodeCatalog.js index 1f5af3e4..a06ac847 100644 --- a/src/lib/nodeCatalog.js +++ b/src/lib/nodeCatalog.js @@ -608,6 +608,22 @@ export const nodeCatalog = [ defaults: { pulse: 0.3, minStrength: 1 }, params: [{ key: 'minStrength', kind: 'range', min: 0, max: 10, step: 0.1 }] }, + // 24-A A2: a hand (VR controller) or a walking player KNOCKED this body (A1's + // probe). Fired on EVERY peer as the `hit` message is applied, stamped from the + // message's own `at` — one message per knock, identical stamps everywhere, no + // nodetrigger. `who` is read per peer against the hitter's id, which is how a + // per-player count reaches setvariable scope:'player' without a second writer. + // Its own card (OnHitNode): the pulse dot plus `speed` and `byMe` value outputs, + // so a graph can scale a burst by how hard the hit was. + { + type: 'onhit', + label: 'On Hit', + defaults: { pulse: 0.3, minSpeed: 0, who: 'anyone' }, + params: [ + { key: 'minSpeed', kind: 'range', min: 0, max: 10, step: 0.1 }, + { key: 'who', kind: 'select', options: ['anyone', 'me', 'others'] } + ] + }, // CL-C C2: sensor overlap edges (initiator-detected, replicated stamps) { type: 'onenter', label: 'On Enter', defaults: { pulse: 0.3 } }, { type: 'onexit', label: 'On Exit', defaults: { pulse: 0.3 } }, diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 3dad91e9..dd91e354 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -39,7 +39,7 @@ import { applyModuleMessage, moduleVersions, checkModuleVersions, checkPeerAppVe import { APP_VERSION, COMMIT_SHA } from '$lib/version.js'; import { applyLockRequest, applyUnlock, applyLockDenied } from '$lib/lockControl'; import { applyDrawLive, applyDrawEnd } from '$lib/drawMode'; -import { applySimulate, physicsExternalMove, applyThrow, applyHit } from '$lib/physics'; +import { applySimulate, physicsExternalMove, applyThrow, applyHit, simulating, simPaused } from '$lib/physics'; import { noteRemoteMove } from '$lib/moveSmoothing'; import { noteRemoteHit, endKnockPrediction } from '$lib/knock'; import { applyJointCreate, applyJointDelete, applyJointsSnapshot, sendJoints } from '$lib/joints'; @@ -1104,6 +1104,14 @@ export class PeerConnection { if (getobjects && !holdContent) this.requestFullState(conn) // singleton PUSH, like environmentState/scenePhysicsState above if (!holdContent) conn.send(gameStatePayload()) + // 24-A A2: WHETHER A SIM IS RUNNING HERE, for a late joiner. `simulate` went out at + // start/stop only, so a peer joining mid-run kept `remoteSimulating` null and neither + // the knock probes nor play-mode grab armed until the sim restarted (A1's finding; + // football's late joiner mid-match is the case). The start message's own shape, so + // an older joiner applies it exactly as it applies the live one, and held with the + // singletons — a running sim is content about THIS room. + if (!holdContent && get(simulating)) + conn.send({ type: 'simulate', running: true, paused: get(simPaused), peerId: this.peer.id }) // module state is the one PER-PEER payload in the get* family (each peer // answers with its OWN states — e.g. campreview presence), so it can't be // deduped down to the host like the shared-scene requests above (B5) diff --git a/tests/e2e/knock-node.test.cjs b/tests/e2e/knock-node.test.cjs new file mode 100644 index 00000000..14acf594 --- /dev/null +++ b/tests/e2e/knock-node.test.cjs @@ -0,0 +1,414 @@ +// 24-A A2 — ON HIT, THE SDK FEED, THE HANDSHAKE AND THE INSPECTOR ROWS. +// +// The knock (A1) is a `hit` message applied on every peer; A2 turns it into a TRIGGER +// NODE stamped from that message's own `at` — one message per knock, identical stamps +// everywhere, no nodetrigger — with `who: anyone|me|others` read PER PEER against the +// hitter's id and `speed`/`byMe` as value outputs. The same feed reaches a module through +// `api.onHit`, and a running sim now rides the handshake so a late joiner arms its probes. +// +// Sections: 1 the registries agree · 2 the initiator knocks (who/minSpeed on both peers, +// the stamp is literally equal on A and B, speed/byMe, the per-player Set Variable banks +// ONCE per hit per peer) · 3 the non-initiator knocks (the mirror image) · 4 the SDK feed +// carries what the node saw · 5 the handshake tells a late joiner the sim is running · +// 6 the Inspector's Knock rows + the `Physics:Knock` deep link · 7 the haptic seam. +// +// THE COUNTERFACTUALS (each proven red by breaking the code, restored before commit): +// fireObjectHit without its applyNodeTrigger → 2.2/2.4 red (nothing stamped); +// the `who` gate removed → 2.5/2.6/3.3 red (`me` fires on the other peer); +// the handshake `simulate` push removed → 5.2 red (remoteSimulating stays null); +// unwrapHandle without `__default` → 2.10 red (the unnamed edge reads undefined → 15). +// +// Two peers need PEER_CONFIG (the self-hosted signaling box) and GPU_ARGS (the flow tick +// and the physics step both ride the frame loop). + +const h = require('./helpers.cjs'); + +const sp = (page, body) => + page.evaluate((b) => new Function('sp', b)(window.__stores.scenePhysics), body); +const bodyOf = (page, uuid) => + page.evaluate( + (uuid) => window.__stores.physics.physicsDebug().find((b) => b.uuid === uuid) ?? null, + uuid + ); +const speedOf = (b) => (b?.linvel ? Math.hypot(b.linvel.x, b.linvel.y, b.linvel.z) : 0); +/** park the ball at (0,1,0) with zero velocity (applyThrow reseats AND zeroes — initiator only) */ +const park = (page, uuid) => + page.evaluate( + (uuid) => + window.__stores.physics.applyThrow({ + uuid, + pos: [0, 1, 0], + rot: [0, 0, 0], + linvel: [0, 0, 0], + angvel: [0, 0, 0] + }), + uuid + ); + +/** sweep a synthetic probe along +x through y=1, z=0 at `speed` m/s — the knock-physics shape */ +const sweep = (page, id, opts) => + page.evaluate( + ({ id, from, to, speed, dtMs, t0 }) => { + const k = window.__stores.knock; + k.dropProbe(id); + const step = (speed * dtMs) / 1000; + let hits = 0; + let armed = true; + let t = t0; + for (let x = from; x <= to + 1e-9; x += step) { + const r = k.feedProbe(id, [x, 1, 0], t); + hits += r.hits; + armed = armed && r.armed; + t += dtMs; + } + return { hits, armed }; + }, + { dtMs: 16, t0: 1000, from: -1.2, to: 0, ...opts, id } + ); + +const node = (id, type, data, x = 0, y = 0) => ({ + id, + type, + position: { x, y }, + data: { type, ...data }, + class: 'w-[150px]' +}); +// the CANONICAL edge id (Nodes.svelte / hudActions.makeEdge) — peer dedupe depends on it +const edge = (source, target, targetHandle) => ({ + id: 'e-' + source + '-' + target + (targetHandle ? '.' + targetHandle : ''), + source, + target, + ...(targetHandle ? { targetHandle } : {}) +}); +/** write BOTH stores (the runtime reads flowGraphs, the editor flowNodes) and push to peers */ +const setGraph = (page, nodes, edges) => + page.evaluate( + ([nodes, edges]) => { + window.__stores.setActiveGraph(window.__stores.SCENE_GRAPH); + window.__stores.flowGraphs.update((graphs) => ({ ...graphs, scene: { nodes, edges } })); + window.__stores.flowNodes.set(nodes); + window.__stores.flowEdges.set(edges); + let peer = null; + window.__stores.peers.subscribe((p) => (peer = p))(); + nodes.forEach((node) => peer?.send({ type: 'nodecreate', node })); + edges.forEach((edge) => peer?.send({ type: 'edgecreate', edge })); + }, + [nodes, edges] + ); +const holdsGraph = (page, ids) => + page.evaluate((ids) => { + const have = window.__stores.allNodes().map((n) => n.id); + return ids.every((id) => have.includes(id)); + }, ids); + +/** the trigger-log entry of a node: {count, lastT} or null */ +const trig = (page, id) => + page.evaluate((id) => { + let map = null; + window.__stores.flowTriggers.subscribe((v) => (map = v))(); + return map?.[id] ? { ...map[id] } : null; + }, id); +const value = (page, id) => + page.evaluate((id) => { + let values = null; + window.__stores.flowValues.subscribe((v) => (values = v))(); + const v = values?.[id]; + return v === undefined ? null : JSON.parse(JSON.stringify(v)); + }, id); +const touches = (page) => + page.evaluate(() => window.__stores.peerVars.peerVarsDebug().mine.touches ?? 0); +const settle = (page, ms = 900) => page.waitForTimeout(ms); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + { + 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'); + + // ---------------------------------------------------------------- section 1 + console.log('\n=== 1. the registries agree ==='); + const reg = await A.page.evaluate(() => { + const { nodeCatalog, flowSockets } = window.__stores; + const groups = nodeCatalog.nodeCatalog ?? nodeCatalog.catalog ?? null; + const spec = nodeCatalog.findNodeSpec('onhit'); + const group = (groups ?? []).find((g) => g.items.some((i) => i.type === 'onhit'))?.group ?? null; + return { + spec: spec ? { defaults: spec.defaults, params: spec.params.map((p) => p.key) } : null, + group, + out: flowSockets.outputType('onhit'), + inputs: flowSockets.inputHandles('onhit'), + toNumber: flowSockets.canConnect(flowSockets.outputType('onhit'), 'number'), + toBoolean: flowSockets.canConnect(flowSockets.outputType('onhit'), 'boolean'), + toEffect: flowSockets.canConnect(flowSockets.outputType('onhit'), 'effect') + }; + }); + h.check(!!reg.spec && reg.spec.defaults.who === 'anyone' && reg.spec.params.join() === 'minSpeed,who', '1.1 the catalog has On Hit with minSpeed + who (' + JSON.stringify(reg.spec) + ')'); + h.check(reg.group === null || reg.group === 'Triggers', '1.2 ...in the Triggers group (' + reg.group + ')'); + h.check(reg.out === 'event' && reg.inputs.length === 0, '1.3 an EVENT source with no declared inputs (the palette rule: Triggers hold sources)'); + h.check(reg.toNumber && reg.toBoolean && reg.toEffect, '1.4 its handles reach number, boolean and the Object Selector (event coercion)'); + + // ---------------------------------------------------------------- the scene + await h.connect(A, B); + const ball = await A.page.evaluate(() => { + window.__stores.commandsHandler.sceneCommand('/create Sphere 0.3'); + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + const sphere = group.children[group.children.length - 1]; + sphere.name = 'Ball'; + sphere.position.set(0, 1, 0); + sphere.userData.physics = { mode: 'dynamic', mass: 1 }; + window.__stores.objectsGroup.update((v) => v); + window.__stores.objectActions.deselectObject(); + return sphere.uuid; + }); + await h.eventually( + () => B.page.evaluate((uuid) => { + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return !!group?.getObjectByProperty('uuid', uuid); + }, ball), + (ok) => ok, + '(premise) B holds the ball' + ); + // zero-g, no damping, the block ON; peer vars from a clean slate + await sp(A.page, 'sp.setScenePhysics({ gravity: 0, damping: { linear: 0 }, knock: { enabled: true } })'); + for (const p of [A, B]) await p.page.evaluate(() => window.__stores.peerVars.clearPeerVars?.()); + + // THE GRAPH: four On Hit flavours on the ball, each counted; `me` also banks a + // per-player variable; a Math node reads the unnamed handle (the __default read) + const nodes = [ + node('sel', 'objectselector', { selected: ball }, 400, 0), + node('hitAny', 'onhit', { who: 'anyone' }, 0, 0), + node('hitMe', 'onhit', { who: 'me' }, 0, 120), + node('hitOthers', 'onhit', { who: 'others' }, 0, 240), + node('hitFast', 'onhit', { who: 'anyone', minSpeed: 5 }, 0, 360), + node('cntAny', 'counter', {}, 200, 0), + node('cntMe', 'counter', {}, 200, 120), + node('cntOthers', 'counter', {}, 200, 240), + node('cntFast', 'counter', {}, 200, 360), + node('sv', 'setvariable', { name: 'touches', value: 1, op: 'add', scope: 'player' }, 200, 480), + node('sum', 'math', { op: 'add', a: 5, b: 10 }, 200, 600) + ]; + const edges = [ + edge('hitAny', 'sel'), + edge('hitMe', 'sel'), + edge('hitOthers', 'sel'), + edge('hitFast', 'sel'), + edge('hitAny', 'cntAny', 'pulse'), + edge('hitMe', 'cntMe', 'pulse'), + edge('hitOthers', 'cntOthers', 'pulse'), + edge('hitFast', 'cntFast', 'pulse'), + edge('hitMe', 'sv', 'trigger'), + edge('hitAny', 'sum', 'a') + ]; + await setGraph(A.page, nodes, edges); + const ids = nodes.map((n) => n.id); + await h.eventually(() => holdsGraph(B.page, ids), (ok) => ok, '(premise) B holds the graph'); + // the stale-stamp guard records first-seen at TICK time — settle before the first knock + await settle(A.page, 800); + + for (const p of [A, B]) await p.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually(() => bodyOf(A.page, ball), (b) => !!b && b.mode === 'dynamic', '(premise) the ball is a dynamic body on A, the initiator'); + await h.eventually( + () => B.page.evaluate(() => { + let v = null; + window.__stores.physics.remoteSimulating.subscribe((x) => (v = x))(); + return v; + }), + (v) => v === A.id, + '(premise) B knows A is simulating' + ); + await settle(A.page, 400); + await park(A.page, ball); + + // ---------------------------------------------------------------- section 2 + console.log('\n=== 2. the initiator knocks: who/minSpeed on both peers, one stamp ==='); + const s1 = await sweep(A.page, 'p', { speed: 2 }); + h.check(s1.armed && s1.hits === 1, '2.1 (premise) A\'s 2 m/s sweep knocks the ball once'); + await settle(A.page, 700); + const a2 = { any: await trig(A.page, 'hitAny'), me: await trig(A.page, 'hitMe'), others: await trig(A.page, 'hitOthers'), fast: await trig(A.page, 'hitFast'), cnt: await trig(A.page, 'cntAny') }; + h.check(!!a2.any && !!a2.me, '2.2 on A: `anyone` and `me` are stamped'); + h.check(!a2.others && !a2.fast, '2.3 on A: `others` is not (A hit it), and minSpeed 5 gates a 2 m/s hit'); + h.check(a2.cnt?.count === 1, '2.4 ...and the Counter behind `anyone` reads 1 (' + a2.cnt?.count + ')'); + const b2 = { any: await trig(B.page, 'hitAny'), me: await trig(B.page, 'hitMe'), others: await trig(B.page, 'hitOthers'), fast: await trig(B.page, 'hitFast'), cnt: await trig(B.page, 'cntAny') }; + h.check(!!b2.any && !!b2.others, '2.5 on B: `anyone` and `others` are stamped (A hit it, B is the other)'); + h.check(!b2.me && !b2.fast, '2.6 on B: `me` is NOT — who is read per peer against the hitter'); + h.check(!!a2.any && !!b2.any && a2.any.lastT === b2.any.lastT, '2.7 THE STAMP IS LITERALLY EQUAL on A and B (' + a2.any?.lastT + ' / ' + b2.any?.lastT + '): derived from the one message, no nodetrigger'); + const va = await value(A.page, 'hitAny'); + const vb = await value(B.page, 'hitAny'); + h.check(!!va?.__handles && Math.abs(va.__handles.speed - 2) < 0.25 && !!vb?.__handles && Math.abs(vb.__handles.speed - 2) < 0.25, '2.8 `speed` reads the approach speed on both (' + va?.__handles?.speed?.toFixed(2) + ' / ' + vb?.__handles?.speed?.toFixed(2) + ')'); + h.check(va?.__handles?.byMe === 1 && vb?.__handles?.byMe === 0, '2.9 `byMe` is 1 on A and 0 on B'); + const sum = await value(A.page, 'sum'); + h.check(sum === 10, '2.10 the Math node wired from the UNNAMED handle reads 0 + 10 = 10, not its 5 fallback (' + sum + '): __default resolves'); + h.check((await touches(A.page)) === 1 && (await touches(B.page)) === 0, '2.11 the per-player `touches` banked ONCE on A and not on B (the setvariable one-writer shape)'); + + // ---------------------------------------------------------------- section 3 + console.log('\n=== 3. the non-initiator knocks: the mirror image ==='); + await park(A.page, ball); + await settle(A.page, 300); + const s3 = await sweep(B.page, 'q', { speed: 6 }); + h.check(s3.armed && s3.hits === 1, '3.1 (premise) B\'s 6 m/s sweep knocks once (the hit goes to A as a message)'); + await h.eventually(() => trig(A.page, 'cntAny'), (t) => t?.count === 2, '3.2 A\'s `anyone` Counter reaches 2 once the hit lands'); + await settle(A.page, 500); + const a3 = { me: await trig(A.page, 'cntMe'), others: await trig(A.page, 'cntOthers'), fast: await trig(A.page, 'cntFast') }; + const b3 = { me: await trig(B.page, 'cntMe'), others: await trig(B.page, 'cntOthers'), fast: await trig(B.page, 'cntFast'), any: await trig(B.page, 'cntAny') }; + h.check(a3.me?.count === 1 && a3.others?.count === 1, '3.3 on A: `me` stays at 1 (B hit it) and `others` is now 1'); + h.check(b3.me?.count === 1 && b3.others?.count === 1 && b3.any?.count === 2, '3.4 on B: `me` 1, `others` 1, `anyone` 2 — every peer counted each hit exactly once'); + h.check(a3.fast?.count === 1 && b3.fast?.count === 1, '3.5 minSpeed 5 passes a 6 m/s hit on both'); + h.check((await touches(A.page)) === 1 && (await touches(B.page)) === 1, '3.6 `touches`: one each, banked by the hitter only — no double bank (the 21-F3 counter-case)'); + const vb3 = await value(B.page, 'hitFast'); + h.check(!!vb3?.__handles && Math.abs(vb3.__handles.speed - 6) < 0.6 && vb3.__handles.byMe === 1, '3.7 B\'s `speed` reads ~6 and `byMe` 1 for its own hit (' + vb3?.__handles?.speed?.toFixed(2) + ')'); + const applied = await bodyOf(A.page, ball); + h.check(speedOf(applied) > 1, '3.8 (premise) A applied the knock to the body (|v| ' + speedOf(applied).toFixed(2) + ')'); + + // ---------------------------------------------------------------- section 4 + console.log('\n=== 4. the SDK feed: api.onHit carries what the node saw ==='); + const installFeed = (peer) => + peer.page.evaluate(async () => { + window.__feed = { hits: [], off: null, api: null }; + await window.__stores.moduleSDK.initModules([ + { + id: 'hitfeed', + name: 'Hit feed test', + version: '1.0.0', + description: 'proves api.onHit / api.hitLog', + register(api) { + window.__feed.api = api; + window.__feed.off = api.onHit((hit) => window.__feed.hits.push(hit)); + } + } + ]); + return typeof window.__feed.api.onHit === 'function' && typeof window.__feed.api.hitLog === 'function'; + }); + h.check((await installFeed(A)) && (await installFeed(B)), '4.1 api.onHit and api.hitLog exist'); + await park(A.page, ball); + await settle(A.page, 300); + const s4 = await sweep(A.page, 'p', { speed: 3 }); + h.check(s4.hits === 1, '4.2 (premise) A knocks once more'); + await h.eventually(() => B.page.evaluate(() => window.__feed.hits.length), (n) => n === 1, '4.3 B\'s callback fired once for A\'s hit'); + const fa = await A.page.evaluate(() => window.__feed.hits[0]); + const fb = await B.page.evaluate(() => window.__feed.hits[0]); + h.check(fa && fa.uuid === ball && fa.local === true && fa.by === A.id && Math.abs(fa.speed - 3) < 0.3, '4.4 A\'s payload: uuid, local:true, by = A, speed ~3 (' + JSON.stringify({ local: fa?.local, by: fa?.by === A.id, speed: fa?.speed?.toFixed(2) }) + ')'); + h.check(fb && fb.uuid === ball && fb.local === false && fb.by === A.id && fb.at === fa.at, '4.5 B\'s payload: local:false, by = A, the SAME `at` (' + fb?.at + ')'); + const stampA = await trig(A.page, 'hitAny'); + h.check(!!stampA && Math.abs(stampA.lastT - ((fa.at % 86400000) / 1000)) < 1e-6, '4.6 the node\'s stamp is that `at` folded the way the tick clock folds Date.now: the module and the graph saw ONE hit'); + const logA = await A.page.evaluate((uuid) => window.__feed.api.hitLog(), ball); + h.check(logA.last[ball]?.by === A.id && logA.recent.length >= 3, '4.7 api.hitLog(): last-per-body names A, the ring holds the session\'s hits (' + logA.recent.length + ')'); + await A.page.evaluate(() => window.__feed.off()); + await park(A.page, ball); + await settle(A.page, 300); + await sweep(A.page, 'p', { speed: 3 }); + await settle(A.page, 500); + h.check((await A.page.evaluate(() => window.__feed.hits.length)) === 1, '4.8 after the unsubscribe A\'s callback stays at 1'); + h.check((await B.page.evaluate(() => window.__feed.hits.length)) === 2, '4.9 ...while B\'s (still subscribed) reads 2'); + + // ---------------------------------------------------------------- section 5 + console.log('\n=== 5. a late joiner learns the sim is running from the handshake ==='); + const C = await h.setupPage(browser, 'C'); + // the Connect pill lives in the editor chrome, which play mode hides + await A.page.evaluate(() => window.__stores.isLocked.set(null)); + await h.connect(C, A); + const cSim = await C.page.evaluate(() => { + let v = null; + window.__stores.physics.remoteSimulating.subscribe((x) => (v = x))(); + return v; + }); + h.check(cSim === A.id, '5.1 (measured) C\'s remoteSimulating names A (' + cSim + ')'); + h.check(cSim === A.id, '5.2 THE FINDING CLOSED: `simulate` rode the handshake — before A2 a joiner mid-run sat with null until the sim restarted'); + await C.page.evaluate(() => window.__stores.isLocked.set(true)); + await h.eventually( + () => C.page.evaluate((uuid) => { + let group = null; + window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return !!group?.getObjectByProperty('uuid', uuid); + }, ball), + (ok) => ok, + '(premise) C holds the ball' + ); + await sp(A.page, 'return null'); + const s5 = await sweep(C.page, 'c', { speed: 2 }); + h.check(s5.armed, '5.3 ...so C\'s probes ARM straight away (a sim runs somewhere)'); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + + // ---------------------------------------------------------------- section 6 + console.log('\n=== 6. the Inspector: Knock rows and the Physics:Knock deep link ==='); + await A.page.evaluate(() => window.__stores.isLocked.set(null)); + await A.page.evaluate(() => window.__stores.openSceneSection('Physics:Knock')); + await settle(A.page, 1200); + const anchor = await A.page.evaluate(() => { + const el = document.querySelector('[data-anchor="Knock"]'); + if (!el) return { found: false }; + const sticky = document.querySelector('#drawer-label')?.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + return { found: true, top: Math.round(r.top), stickyBottom: Math.round(sticky?.bottom ?? 0), text: el.textContent?.trim() }; + }); + h.check(anchor.found && anchor.text === 'Knock', '6.1 the Knock sub-heading exists inside Physics (' + JSON.stringify(anchor) + ')'); + h.check(anchor.found && anchor.top >= anchor.stickyBottom - 4 && anchor.top < 500, '6.2 the deep link lands it just under the sticky header'); + const rows = await A.page.evaluate(() => ({ + enabled: document.querySelector('#physics-knock-enabled')?.checked ?? null, + gain: !!document.querySelector('#physics-knock-gain'), + max: !!document.querySelector('#physics-knock-maxspeed'), + radius: !!document.querySelector('#physics-knock-radius'), + spin: !!document.querySelector('#physics-knock-spin') + })); + h.check(rows.enabled === true && rows.gain && rows.max && rows.radius && rows.spin, '6.3 with the block on, the checkbox reads on and the four rows are drawn (' + JSON.stringify(rows) + ')'); + await A.page.click('#physics-knock-enabled'); + await settle(A.page, 400); + const offA = await sp(A.page, 'let v; sp.sceneKnock.subscribe((x) => (v = x))(); return v.enabled'); + const offRows = await A.page.evaluate(() => !!document.querySelector('#physics-knock-gain')); + h.check(offA === false && offRows === false, '6.4 the checkbox writes knock.enabled false and the rows fold away'); + await h.eventually( + () => sp(B.page, 'let v; sp.sceneKnock.subscribe((x) => (v = x))(); return v.enabled'), + (v) => v === false, + '6.5 ...and B\'s block follows (the one scenephysics singleton, no new message)' + ); + await A.page.click('#physics-knock-enabled'); + await settle(A.page, 300); + await A.page.evaluate(() => { + const el = document.querySelector('#physics-knock-gain'); + if (!el) return; + el.value = '2'; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }); + await settle(A.page, 300); + const gain = await sp(A.page, 'let v; sp.sceneKnock.subscribe((x) => (v = x))(); return v'); + h.check(gain.enabled === true && Math.abs(gain.gain - 2) < 1e-9, '6.6 the Gain row writes knock.gain (' + gain.gain + ')'); + await sp(A.page, 'sp.setScenePhysics({ knock: { gain: 1 } })'); + + // ---------------------------------------------------------------- section 7 + console.log('\n=== 7. the haptic seam: a LOCAL VR hand feels its own hit ==='); + await A.page.evaluate(() => window.__stores.isLocked.set(true)); + await A.page.evaluate(() => { + const k = window.__stores.knock; + window.__hap = []; + k.stopKnock(); + k.startKnock({ haptic: (i, ms, hand) => window.__hap.push([i, ms, hand]) }); + }); + await park(A.page, ball); + await settle(A.page, 300); + const left = await sweep(A.page, 'left', { speed: 4 }); + const hap1 = await A.page.evaluate(() => window.__hap.slice()); + h.check(left.hits === 1 && hap1.length === 1 && hap1[0][2] === 'left' && Math.abs(hap1[0][0] - 0.6) < 1e-9 && hap1[0][1] === 30, '7.1 a left-hand knock at 4 m/s buzzes the LEFT hand at 0.2 + 4/10 = 0.6 for 30 ms (' + JSON.stringify(hap1) + ')'); + await park(A.page, ball); + await settle(A.page, 300); + const head = await sweep(A.page, 'head', { speed: 4 }); + h.check(head.hits === 1 && (await A.page.evaluate(() => window.__hap.length)) === 1, '7.2 the head probe (desktop) buzzes nothing'); + await park(A.page, ball); + await settle(A.page, 300); + await sweep(B.page, 'right', { speed: 4 }); + await h.eventually(() => trig(A.page, 'cntAny'), (t) => (t?.count ?? 0) >= 6, '(premise) B\'s hit landed on A'); + h.check((await A.page.evaluate(() => window.__hap.length)) === 1, '7.3 a PEER\'s hit never buzzes this hand — the message carries no haptic'); + await A.page.evaluate(() => { + window.__stores.knock.stopKnock(); + window.__stores.knock.startKnock({}); + }); + + await h.finish(browser); +}); From 0030bf36adeaa88a196084d1814acd7488f5002c Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 08:02:53 +0300 Subject: [PATCH 05/17] [feat] 24-A A3: one graphBuilder, a remap that walks every string field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR #192 follow-up, done before the second game's graph is written. - graphBuilder() at module scope: N/E hoisted from towersGraph()/beatGraph(); E takes both handles (the beat superset — a 3-arg call yields the Towers id verbatim), and both graphs return g.done() - remapData walks every OWN string field and string array, replacing a def-local object name with its uuid, except HUMAN_TEXT_KEYS (label/format/text/placeholder/ name); uuid/selected/camera/hash fall out as ordinary cases; the Set crosses into the page as a list argument - scripts/compare-authored.cjs: canonicalises what a build mints afresh (session id/createdAt/appVersion/thumbnail, changedAt/startedAt/at stamps, every uuid by first appearance) and diffs two authored trees; the ritual is in the script header - PROVEN byte-identical on Towers: `--only towers --out` before (the pre-A3 script) and after -> SAME games/towers/scene.tpscene (76676 canonical chars, the inline thumbnail 6607/6607 and thumb.webp 2968/2968 identical), SAME index.json; the collectible module loaded in both builds (no SKIP) - held: game-towers 20/0 - svelte-check 361/47 unchanged (no src change) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013je8UxeJpoMSRcyejTiNFj --- scripts/author-templates.cjs | 116 +++++++++++++++++++------------ scripts/compare-authored.cjs | 129 +++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 scripts/compare-authored.cjs diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index e120d62e..8859460e 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -38,6 +38,21 @@ // becomes its content hash, so a Sound node can play the same bytes) · `view` (the // editor camera the file opens on) · `thumb.camera` (render the card through a named // camera object). A def with `music` exports WITH assets, so the bytes ride the .tpscene. +// +// 24-A A3 (the PR #192 follow-up): the node/edge helpers are ONE module-scope +// `graphBuilder()` and `remapData` walks every own string field. Both are meant to leave +// every earlier def byte-identical, and that is CHECKED, not believed — build the same def +// with the script before and after the change and compare the two trees: +// +// APP_URL=https://theprototype.app:5177/ node scripts/author-templates.cjs --only towers --out /tmp/towers-before +// (edit) APP_URL=https://theprototype.app:5177/ node scripts/author-templates.cjs --only towers --out /tmp/towers-after +// node scripts/compare-authored.cjs /tmp/towers-before /tmp/towers-after +// +// compare-authored.cjs unzips each .tpscene, strips what a build mints afresh every run +// (`changedAt`/`createdAt`/`at` stamps, the session uuid, and every object uuid — replaced +// by its order of first appearance, so the graph's remapped references still have to +// agree) and diffs the rest; the thumbnails are compared by size only (an offscreen render +// is not bit-stable across GPU drivers). const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); @@ -79,16 +94,15 @@ const ONLY = // {mode:'static'|'dynamic', mass, restitution, friction}. const gray = { floor: 0x8b939c, block: 0xaab2bd, wall: 0x99a3ae, accent: 0xd97706 }; -// ---- B8: Towers, the first GAME def ------------------------------------------- -// A DATA-ONLY game: core nodes + a HUD document + the collectible module. Rebuilt -// from the first playthrough's findings — the clever sensor-conveyor spawner cascaded -// once you grabbed a crate (spawn -> falls into the zone -> jitters out -> spawns -// again), the emissive shader docs read "strange", and the night look was black on the -// user's display. So: crates are PRE-PLACED dynamic objects (grabbable, stable, no -// churn — the plan's own "Towers pre-places crates"); the look is a lit preset plus -// material emissive, no shader graphs; every node carries a label; and a pause menu -// (P) gives a Restart-while-playing button. -function towersGraph() { +// ---- 24-A A3: the ONE graph builder every def authors its nodes through ----------- +// Hoisted from towersGraph()/beatGraph(), which each carried a local copy (PR #192's +// review asked for this the moment a second game arrived). Byte-identical output: the +// `class: 'w-[150px]'`, the label rule (a programmatic node with no label renders a blank +// card) and the editor's CANONICAL edge id — `e-[.]- +// [.]` (Nodes.svelte / hudActions.makeEdge) — which peer dedupe depends on. +// The E signature is the beat graph's superset: a Sequence step is a SOURCE handle, and a +// three-argument call (every Towers edge) produces exactly the id it always did. +function graphBuilder() { /** @type {any[]} */ const nodes = []; /** @type {any[]} */ const edges = []; /** every node gets a LABEL — a programmatic node with none renders a blank card. @@ -97,16 +111,36 @@ function towersGraph() { nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' }); return id; }; - // the editor's canonical edge id (hudActions.makeEdge) — peer dedupe depends on it - /** @param {string} source @param {string} target @param {string} [handle] */ - const E = (source, target, handle) => { + /** @param {string} source @param {string} target @param {string} [targetHandle] @param {string} [sourceHandle] */ + const E = (source, target, targetHandle, sourceHandle) => { edges.push({ - id: 'e-' + source + '-' + target + (handle ? '.' + handle : ''), + id: 'e-' + source + (sourceHandle ? '.' + sourceHandle : '') + '-' + target + (targetHandle ? '.' + targetHandle : ''), source, target, - ...(handle ? { targetHandle: handle } : {}) + ...(sourceHandle ? { sourceHandle } : {}), + ...(targetHandle ? { targetHandle } : {}) }); }; + return { N, E, nodes, edges, done: () => ({ nodes, edges }) }; +} + +// 24-A A3: the node-data keys that are HUMAN TEXT and must never be remapped, even when +// their value happens to equal a def-local object's name — a HUD text whose format is +// literally "Build pad" must stay text, and a variable NAMED like an object is a name. +const HUMAN_TEXT_KEYS = new Set(['label', 'format', 'text', 'placeholder', 'name']); + +// ---- B8: Towers, the first GAME def ------------------------------------------- +// A DATA-ONLY game: core nodes + a HUD document + the collectible module. Rebuilt +// from the first playthrough's findings — the clever sensor-conveyor spawner cascaded +// once you grabbed a crate (spawn -> falls into the zone -> jitters out -> spawns +// again), the emissive shader docs read "strange", and the night look was black on the +// user's display. So: crates are PRE-PLACED dynamic objects (grabbable, stable, no +// churn — the plan's own "Towers pre-places crates"); the look is a lit preset plus +// material emissive, no shader graphs; every node carries a label; and a pause menu +// (P) gives a Restart-while-playing button. +function towersGraph() { + const g = graphBuilder(); + const { N, E } = g; // ---- round control --------------------------------------------------------- // Start from the menu: entering 'playing' from menu BUMPS the round and re-stamps @@ -216,7 +250,7 @@ function towersGraph() { N('gotime', 'setgamestate', 'Time over', 1000, 2240, { state: 'over', outcome: "Time's up!", reset: false }); E('alltime', 'gotime', 'trigger'); - return { nodes, edges }; + return g.done(); } const TOWERS_HUD_PANEL = { @@ -563,25 +597,8 @@ function beatMarkers() { } function beatGraph() { - /** @type {any[]} */ const nodes = []; - /** @type {any[]} */ const edges = []; - /** @param {string} id @param {string} type @param {string} label @param {number} x @param {number} y @param {any} data */ - const N = (id, type, label, x, y, data) => { - nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' }); - return id; - }; - // the editor's canonical edge id with BOTH handles (Nodes.svelte) — a Sequence step is - // a SOURCE handle, which the Towers helper never needed - /** @param {string} source @param {string} target @param {string} [targetHandle] @param {string} [sourceHandle] */ - const E = (source, target, targetHandle, sourceHandle) => { - edges.push({ - id: 'e-' + source + (sourceHandle ? '.' + sourceHandle : '') + '-' + target + (targetHandle ? '.' + targetHandle : ''), - source, - target, - ...(sourceHandle ? { sourceHandle } : {}), - ...(targetHandle ? { targetHandle } : {}) - }); - }; + const g = graphBuilder(); + const { N, E } = g; // selectors — every trigger and action names its object through one N('selcond', 'objectselector', 'Conductor', 760, 190, { selected: 'Conductor' }); N('selstage', 'objectselector', 'Stage', 760, 340, { selected: 'Stage' }); @@ -663,7 +680,7 @@ function beatGraph() { N('cutdetail', 'setcamera', 'Bar 3: Detail', 520, 1400, { camera: '' }); E('cuts', 'cutdetail', 'trigger', 'step3'); E('seldetail', 'cutdetail', 'camera'); - return { nodes, edges }; + return g.done(); } const BEAT_HUD_PANEL = { @@ -1016,7 +1033,9 @@ const DEFS = [ // CDN, and the file belongs to no repo); the page hands the bytes to the Explorer, // which is what makes them a scene asset the .tpscene bundles. const music = def.music ? { ...def.music, b64: (await fetchMusic(def.music)).toString('base64') } : null; - const out = await page.evaluate(async ({ d, music }) => { + const out = await page.evaluate(async ({ d, music, humanTextKeys }) => { + // 24-A A3: the module-scope Set does not cross into the page — it arrives as a list + const humanText = new Set(humanTextKeys); const s = window.__stores; const T = s.THREE; s.commandsHandler.sceneCommand('/clear all'); @@ -1200,14 +1219,23 @@ const DEFS = [ const grid = (i) => ({ x: 40 + (i % 4) * 220, y: 40 + Math.floor(i / 4) * 140 }); // a node's object reference may be a def-local NAME: `uuid` on effect/anim // nodes, `selected` on an Object Selector (B8 — the selector is how every - // trigger and action names its target, so a game graph is mostly selectors) + // trigger and action names its target), `camera` on the camera nodes (28-G), + // `hash: '$music'` on a Sound node — and, 24-A A3, ANY own string field a + // later node type may add: every string that names a def-local object becomes + // its uuid, string ARRAYS too (a future multi-target node), EXCEPT the human- + // text keys in HUMAN_TEXT_KEYS. `uuid`/`selected`/`camera`/`hash` fall out + // as ordinary cases, so the four earlier rules produce exactly what they did. const remapData = (data) => { const out = { ...(data ?? {}) }; - if (out.uuid && named[out.uuid]) out.uuid = named[out.uuid]; - if (out.selected && named[out.selected]) out.selected = named[out.selected]; - // 28-G: `camera` on setcamera/gamestart/setlook, and the track's hash - if (out.camera && named[out.camera]) out.camera = named[out.camera]; - if (out.hash === '$music' && named['$music']) out.hash = named['$music']; + for (const key of Object.keys(out)) { + if (humanText.has(key)) continue; + const v = out[key]; + if (typeof v === 'string') { + if (named[v]) out[key] = named[v]; + } else if (Array.isArray(v) && v.length && v.every((x) => typeof x === 'string')) { + out[key] = v.map((x) => (named[x] ? named[x] : x)); + } + } return out; }; const resolved = {}; @@ -1350,7 +1378,7 @@ const DEFS = [ if (s.animationPreview) s.animationPreview.animationsRestore({}, false); if (s.sceneMusic) s.sceneMusic.musicRestore(null, false); return { bytes: Array.from(bytes), thumb }; - }, { d: def, music }); + }, { d: def, music, humanTextKeys: [...HUMAN_TEXT_KEYS] }); const bytes = Buffer.from(out.bytes); const thumb = out.thumb ? Buffer.from(out.thumb.split(',')[1], 'base64') : null; built[def.slug] = { entry: def, bytes, thumb }; diff --git a/scripts/compare-authored.cjs b/scripts/compare-authored.cjs new file mode 100644 index 00000000..9554943f --- /dev/null +++ b/scripts/compare-authored.cjs @@ -0,0 +1,129 @@ +// 24-A A3: prove two authored trees are the SAME CONTENT. +// +// node scripts/compare-authored.cjs [--only ] +// +// For every `
//scene.tpscene` under , the same file must exist +// under and their session.json must agree once the things a build mints +// afresh every run are canonicalised: +// · the session `id`, `createdAt`, `appVersion` and the inline `thumbnail` (an offscreen +// render is not bit-stable across GPU drivers — its byte LENGTH is reported instead), +// · every `changedAt` / `startedAt` / `at` stamp (latest-wins bookkeeping, not content), +// · every uuid, replaced by `uuid#` in order of FIRST APPEARANCE — so a graph's +// remapped `selected`/`uuid`/`camera` references still have to point at the same +// objects in the same order, which is exactly what the remap change must preserve. +// `thumb.webp` is compared by size within 25%, and the index.json row (if both trees +// carry one) with `bytes` dropped. Exit 0 = identical, 1 = a difference (printed). +const fs = require('fs'); +const path = require('path'); +const { unzipSync } = require('fflate'); + +const [beforeDir, afterDir] = process.argv.slice(2, 4).map((p) => p && path.resolve(p)); +if (!beforeDir || !afterDir) { + console.error('usage: node scripts/compare-authored.cjs '); + process.exit(2); +} +const onlyFlag = process.argv.indexOf('--only'); +const ONLY = onlyFlag !== -1 ? String(process.argv[onlyFlag + 1] ?? '').split(',').filter(Boolean) : null; + +const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g; +const STAMP_KEYS = new Set(['changedAt', 'startedAt', 'at', 'createdAt']); + +/** @param {any} value @param {Map} uuids @param {string} key */ +function canon(value, uuids, key = '') { + if (typeof value === 'string') { + return value.replace(UUID, (u) => { + if (!uuids.has(u)) uuids.set(u, 'uuid#' + uuids.size); + return uuids.get(u) ?? u; + }); + } + if (typeof value === 'number' && STAMP_KEYS.has(key)) return 0; + if (Array.isArray(value)) return value.map((v) => canon(v, uuids, key)); + if (value && typeof value === 'object') { + /** @type {Record} */ const out = {}; + for (const k of Object.keys(value)) out[k] = canon(value[k], uuids, k); + return out; + } + return value; +} + +/** @param {string} file */ +function readScene(file) { + const zip = unzipSync(new Uint8Array(fs.readFileSync(file))); + const entries = Object.keys(zip).sort(); + const session = JSON.parse(Buffer.from(zip['session.json']).toString('utf8')); + const thumbLen = typeof session.thumbnail === 'string' ? session.thumbnail.length : 0; + const { id, createdAt, appVersion, thumbnail, ...rest } = session; + const others = {}; + for (const name of entries) if (name !== 'session.json') others[name] = Buffer.from(zip[name]).toString('utf8'); + return { entries, thumbLen, canonical: canon(rest, new Map()), others }; +} + +/** @param {string} dir @returns {string[]} */ +function scenes(dir) { + /** @type {string[]} */ const out = []; + const walk = (d) => { + for (const name of fs.readdirSync(d)) { + const p = path.join(d, name); + if (fs.statSync(p).isDirectory()) walk(p); + else if (name === 'scene.tpscene') out.push(path.relative(dir, p)); + } + }; + walk(dir); + return out.sort(); +} + +/** first differing line of two JSON dumps @param {string} a @param {string} b */ +function firstDiff(a, b) { + const la = a.split('\n'); + const lb = b.split('\n'); + for (let i = 0; i < Math.max(la.length, lb.length); i++) + if (la[i] !== lb[i]) return { line: i + 1, before: la[i] ?? '', after: lb[i] ?? '' }; + return null; +} + +let failures = 0; +const list = scenes(beforeDir).filter((rel) => !ONLY || ONLY.some((slug) => rel.includes(path.sep + slug + path.sep))); +if (!list.length) { + console.error('no scene.tpscene under ' + beforeDir); + process.exit(2); +} +for (const rel of list) { + const a = path.join(beforeDir, rel); + const b = path.join(afterDir, rel); + if (!fs.existsSync(b)) { + console.log('MISSING ' + rel + ' in ' + afterDir); + failures++; + continue; + } + const A = readScene(a); + const B = readScene(b); + const ja = JSON.stringify(A.canonical, null, 1); + const jb = JSON.stringify(B.canonical, null, 1); + const diff = ja === jb ? null : firstDiff(ja, jb); + const entriesSame = JSON.stringify(A.entries) === JSON.stringify(B.entries); + const othersSame = JSON.stringify(A.others) === JSON.stringify(B.others); + const thumbOk = A.thumbLen === 0 ? B.thumbLen === 0 : Math.abs(A.thumbLen - B.thumbLen) / A.thumbLen < 0.25; + const dir = path.dirname(rel); + const thumbA = path.join(beforeDir, dir, 'thumb.webp'); + const thumbB = path.join(afterDir, dir, 'thumb.webp'); + const sizeA = fs.existsSync(thumbA) ? fs.statSync(thumbA).size : 0; + const sizeB = fs.existsSync(thumbB) ? fs.statSync(thumbB).size : 0; + const webpOk = sizeA === 0 ? sizeB === 0 : Math.abs(sizeA - sizeB) / sizeA < 0.25; + const ok = !diff && entriesSame && othersSame && thumbOk && webpOk; + console.log((ok ? 'SAME ' : 'DIFF ') + rel + ' (' + ja.length + ' canonical chars, thumb ' + A.thumbLen + '/' + B.thumbLen + ', webp ' + sizeA + '/' + sizeB + ')'); + if (diff) console.log(' first difference at canonical line ' + diff.line + ':\n before: ' + diff.before + '\n after: ' + diff.after); + if (!entriesSame) console.log(' zip entries differ: ' + A.entries.join(',') + ' vs ' + B.entries.join(',')); + if (!othersSame) console.log(' a non-session entry differs'); + if (!thumbOk || !webpOk) console.log(' thumbnail size drifted more than 25%'); + if (!ok) failures++; +} +// the index rows, when both trees carry an index.json +const ia = path.join(beforeDir, 'index.json'); +const ib = path.join(afterDir, 'index.json'); +if (fs.existsSync(ia) && fs.existsSync(ib)) { + const strip = (idx) => JSON.stringify(idx, (k, v) => (k === 'bytes' ? undefined : v)); + const same = strip(JSON.parse(fs.readFileSync(ia, 'utf8'))) === strip(JSON.parse(fs.readFileSync(ib, 'utf8'))); + console.log((same ? 'SAME ' : 'DIFF ') + 'index.json (bytes dropped)'); + if (!same) failures++; +} +process.exit(failures ? 1 : 0); From 661d7c50ae63b9f78da2aaab07035e1d23a269ab Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 08:10:39 +0300 Subject: [PATCH 06/17] [feat] F1: proportional falloff for rotate and scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proportional editing was a TRANSLATE tool: applyPivotTransform turned/scaled the selected set only and left the falloff neighbourhood exactly where it was. It now blends the WHOLE weighted neighbourhood — the plan's option 3, the weighted transform blend: per vertex the gesture's rotation is slerped and its scale lerped toward identity by that vertex's smoothstep weight, then applied. For a pure rotation that is the conventional weighted ANGLE (a w=0.5 vertex turns half way, so a straight row becomes a spiral, which is what Blender does) and it is the only reading that stays defined when rotate and scale combine. - the selection keeps w = 1 by construction (beginFalloff), so a rotate with nothing in range is byte-identical to before. - fixes a pre-existing hole the blend exposed: recaptureVertexFalloff (the mid-drag wheel resize) always re-applied as a TRANSLATE, so resizing the radius during a rotate/scale displaced the neighbours; it re-applies through applyProxyGesture (which dispatches by mode) when a gizmo gesture is live, keeping the translate shape for the VR/no-gesture path. - replication and undo come free: a falloff gesture already commits one whole-geometry meshgeo (F3). - suite mesh-falloff-rotate-scale (22 checks): swept ANGLES about the pivot, never distances (every invariant a rotation preserves is preserved by a WRONG rotation too) — 75.94 / 45.00 / 14.06 degrees at the three weighted rings, the rim vertex unturned, a vertex beyond the radius byte-identical, one undo exact, the scale factors lerp(1, 2, w), and proportional OFF still turning the selection alone. - counterfactual: with the weight forced to 1 the suite reads 7 FAILURES — every weighted vertex sweeps 90.00 degrees, the spiral twist is 0.000 and every scale factor is 2. - two fixture traps this suite paid for, both recorded in its header: an attribute INDEX recorded before a meshgeo commit addresses a DIFFERENT vertex after it (F3's commit rebuilds the mesh index-expanded), so every tracked vertex is recorded in both layouts; and a 4-wide plane has no vertex beyond a radius of 2, so the grid is 6 wide. - held: mesh-proportional 63, mesh-pivot-gizmo 131, mesh-falloff-sync 24 (base) - svelte-check 361/47, list identical to base; build green (server stopped) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm --- src/lib/meshEdit.js | 40 +++- tests/e2e/mesh-falloff-rotate-scale.test.cjs | 200 +++++++++++++++++++ 2 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/mesh-falloff-rotate-scale.test.cjs diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js index 9ebebb9e..f31ea7ec 100644 --- a/src/lib/meshEdit.js +++ b/src/lib/meshEdit.js @@ -1365,8 +1365,14 @@ function recaptureVertexFalloff() { refreshHandleMatrix(i); } } - // members first, anchor last — the tail commitSelectedLocal refreshes - // normals/bounds/overlay and sets needsUpdate (the onProxyMoved shape) + // F1: a live gizmo gesture re-applies by MODE (a rotate/scale wheel resize used to + // fall through to the translate falloff and displace the neighbours); the VR / + // no-gesture path keeps the translate shape: members first, anchor last — the + // tail commitSelectedLocal refreshes normals/bounds/overlay and sets needsUpdate + if (proxyGesture) { + applyProxyGesture(); + return; + } applyFalloff(deltaVector.copy(handles[selectedHandle].position).sub(falloffOrigin())); commitSelectedLocal(handles[selectedHandle].position.clone()); } @@ -1440,10 +1446,8 @@ function applyTranslate(delta) { * on every call, so a long drag cannot drift, and conjugated out of the proxy * frame into object-local before it touches a vertex. * - * DELIBERATE: proportional falloff is a TRANSLATE tool and is left alone here. - * Only the selected set turns/scales; the neighbourhood keeps its positions. A - * weighted partial rotation is a different (and much less obvious) operation, - * and silently inventing one would be worse than not offering it. + * Since v1.13 (F1) the falloff neighbourhood turns/scales too, weighted — see the + * blend note inside. Before that the falloff was a translate-only tool here. */ function applyPivotTransform() { const g = /** @type {any} */ (proxyGesture); @@ -1460,15 +1464,33 @@ function applyPivotTransform() { // resets that to 1 — the divide keeps it honest if it ever is not const scale = proxy.scale.clone().divide(g.scale); const point = new THREE.Vector3(); - for (const index of gestureIndices()) { + // F1 (v1.13): PROPORTIONAL rotate/scale = the WEIGHTED TRANSFORM BLEND (the plan's + // option 3): per vertex the rotation is slerped and the scale lerped toward + // identity by its falloff weight, then applied. For a pure rotation that is the + // conventional weighted ANGLE (a w = 0.5 vertex turns half way, so a straight edge + // through the falloff becomes a spiral — what Blender does), and it is the only + // reading that stays defined when rotate and scale combine. The selection has + // w = 1 by construction (beginFalloff), so it turns by the full amount either way. + const weighted = falloffActive(); + const identity = new THREE.Quaternion(); + const one = new THREE.Vector3(1, 1, 1); + const q = new THREE.Quaternion(); + const sc = new THREE.Vector3(); + const indices = weighted + ? handles.map((_, i) => i).filter((i) => /** @type {number[]} */ (falloffWeights)[i] > 0) + : gestureIndices(); + for (const index of indices) { if (!g.starts[index]) continue; + const w = weighted ? /** @type {number[]} */ (falloffWeights)[index] : 1; + q.copy(identity).slerp(dQuat, w); + sc.copy(one).lerp(scale, w); point .copy(g.starts[index]) .sub(pivot) .applyQuaternion(Rinv) - .multiply(scale) + .multiply(sc) .applyQuaternion(R) - .applyQuaternion(dQuat) + .applyQuaternion(q) .add(pivot); writeHandle(index, point); } diff --git a/tests/e2e/mesh-falloff-rotate-scale.test.cjs b/tests/e2e/mesh-falloff-rotate-scale.test.cjs new file mode 100644 index 00000000..3df9107b --- /dev/null +++ b/tests/e2e/mesh-falloff-rotate-scale.test.cjs @@ -0,0 +1,200 @@ +// F1 (v1.13): PROPORTIONAL falloff for ROTATE and SCALE — the weighted transform blend. +// +// Before this, `applyPivotTransform` turned/scaled the SELECTED set only and left the +// falloff neighbourhood exactly where it was (documented, deliberate). Now each vertex +// in the radius gets the gesture's rotation SLERPED and its scale LERPED toward identity +// by its smoothstep weight. For a pure rotation that is the conventional weighted ANGLE: +// a vertex halfway out turns half way, so a straight row of vertices through the falloff +// becomes a spiral — which is the reading every check below measures as a swept ANGLE +// about the pivot, never as a distance (every invariant a rotation preserves is also +// preserved by a WRONG rotation). Counterfactual (proven at commit time): with the weight +// forced to 1 every vertex in range turns the full 90°, the row stays a straight line and +// the collinearity check goes red. +// +// FIXTURE TRAP THIS SUITE PAID FOR, twice over. (1) An attribute INDEX recorded before a +// meshgeo commit addresses a DIFFERENT vertex after it: F3 made a proportional gesture end +// in a whole-geometry commit, and `applyMeshGeo` rebuilds the mesh index-EXPANDED (a +// 13x13 plane's 169 entries become 864 in triangle order). Every tracked vertex is +// therefore recorded TWICE — its indexed entry and its expanded one — and read back +// through whichever matches the live count. (2) A `/create Plane 4 4` spans -2..2, so +// "beyond the radius" named a vertex that does not exist and the script crashed on +// `undefined.every`; the grid is 6 wide here, with the same 0.5 step. +const h = require('./helpers.cjs'); + +const smooth = (t) => (t <= 0 ? 1 : t >= 1 ? 0 : 1 - t * t * (3 - 2 * t)); +const RADIUS = 2; // grid step is 0.5, so +x holds vertices at t = .25 / .5 / .75 / 1 +/** the +x row this suite measures: weighted, at the rim, and outside it */ +const XS = [0, 0.5, 1, 1.5, 2, 2.5]; + +/** fresh plane, edit mode, origin vertex selected, proportional armed at RADIUS */ +const arm = (page) => + page.evaluate( + ({ RADIUS, XS }) => { + const s = window.__stores; + const me = s.meshEdit; + me.exitEditMode(); + s.commandsHandler.sceneCommand('/create Plane 6 6 12 12'); + let g; + s.objectsGroup.subscribe((v) => (g = v))(); + window.__mesh = g.children[g.children.length - 1]; + me.enterEditMode(window.__mesh.uuid); + let controls; + s.TControls.subscribe((c) => (controls = c))(); + let anchor = -1; + for (let i = 0; i < 400; i++) { + me.selectHandle(i); + const p = controls.object?.position; + if (!p) break; + if (Math.hypot(p.x, p.y) < 1e-6) { + anchor = i; + break; + } + } + if (anchor < 0) return null; + me.selectHandle(anchor); + me.proportionalEdit.set(true); + me.proportionalRadius.set(RADIUS); + // Remember each +x grid vertex TWICE: its attribute index in the geometry as + // it stands now, and its index in the EXPANDED (index-walked) layout a + // meshgeo commit will swap in. `trisToPositions(readTriangles(...))` walks + // `geometry.index` in order, so expanded slot j holds original vertex + // index.array[j] — hence the plain indexOf. + const geometry = window.__mesh.geometry; + const position = geometry.attributes.position; + const indexArray = geometry.index ? geometry.index.array : null; + const track = {}; + const trackExp = {}; + for (const x of XS) + for (let i = 0; i < position.count; i++) + if (Math.abs(position.getX(i) - x) < 1e-4 && Math.abs(position.getY(i)) < 1e-4) { + track[x] = i; + trackExp[x] = indexArray ? Array.prototype.indexOf.call(indexArray, i) : i; + break; + } + window.__track = track; + window.__trackExp = trackExp; + window.__origCount = position.count; + window.__beforeExpanded = s.faceEdit.trisToPositions(s.faceEdit.readTriangles(geometry)); + return { track, trackExp, count: position.count }; + }, + { RADIUS, XS } + ); + +/** one exact gizmo gesture through the real drag lifecycle (mesh-pivot-gizmo's recipe) */ +const gesture = (page, spec) => + page.evaluate((spec) => { + const s = window.__stores; + const THREE = s.THREE; + const me = s.meshEdit; + let controls; + s.TControls.subscribe((c) => (controls = c))(); + s.objectActions.setTransformMode(spec.mode); + me.onProxyDragChanged(true); + if (!controls.object) return false; + if (spec.mode === 'rotate') + controls.object.quaternion.setFromAxisAngle(new THREE.Vector3(...spec.axis), (spec.degrees * Math.PI) / 180); + else controls.object.scale.set(...spec.scale); + me.onProxyMoved(); + me.onProxyDragChanged(false); + s.objectActions.setTransformMode('translate'); + return true; + }, spec); + +/** the tracked +x vertices' current positions, keyed by their ORIGINAL x — read through + * the indexed map while the geometry is still the one `arm` saw, and through the + * expanded map once a commit has swapped it (see the fixture note at the top) */ +const readTracked = (page) => + page.evaluate(() => { + const position = window.__mesh.geometry.attributes.position; + const map = position.count === window.__origCount ? window.__track : window.__trackExp; + const out = { __expanded: map === window.__trackExp }; + for (const [x, i] of Object.entries(map)) out[x] = [position.getX(i), position.getY(i), position.getZ(i)]; + return out; + }); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // ==== ROTATE 90° about Z, pivot = the anchor at the origin ================== + const armed = await arm(A.page); + h.check( + armed && Object.keys(armed.track).length === XS.length && Object.values(armed.trackExp).every((i) => i >= 0), + `tracked the six +x grid vertices in both layouts (premise: ${JSON.stringify(armed?.track)} / ${JSON.stringify(armed?.trackExp)})` + ); + const ran = await gesture(A.page, { mode: 'rotate', axis: [0, 0, 1], degrees: 90 }); + h.check(ran, 'the rotate gesture found a seated gizmo to drive (premise)'); + const rot = await readTracked(A.page); + h.check(rot.__expanded, 'the proportional gesture committed a whole-geometry snapshot (premise: F3)'); + const deg = (p) => (Math.atan2(p[1], p[0]) * 180) / Math.PI; + h.check(Math.hypot(...rot['0']) < 1e-6, `the anchor (on the axis) stays put (${JSON.stringify(rot['0'].map((n) => +n.toFixed(4)))})`); + for (const x of [0.5, 1, 1.5]) { + const expect = smooth(x / RADIUS) * 90; + const got = deg(rot[String(x)]); + h.check( + Math.abs(got - expect) < 0.05, + `the vertex at x=${x} swept ${expect.toFixed(2)}° about the pivot — the smoothstep weight times 90° (${got.toFixed(2)}°)` + ); + h.check( + Math.abs(Math.hypot(rot[String(x)][0], rot[String(x)][1]) - x) < 1e-6, + `...and kept its distance from the pivot (${x})` + ); + } + h.check( + Math.abs(deg(rot['2'])) < 1e-6 && Math.abs(rot['2'][0] - 2) < 1e-6, + `the vertex AT the radius did not turn at all (${deg(rot['2']).toFixed(4)}°)` + ); + const beyondBefore = await A.page.evaluate(() => { + const i = window.__trackExp['2.5']; + return [window.__beforeExpanded[i * 3], window.__beforeExpanded[i * 3 + 1], window.__beforeExpanded[i * 3 + 2]]; + }); + h.check( + rot['2.5'].every((n, k) => n === beyondBefore[k]), + `a vertex beyond the radius is byte-identical to before (${JSON.stringify(rot['2.5'])})` + ); + // THE COUNTERFACTUAL'S READING: the three weighted vertices are NOT collinear with + // the pivot (76° / 45° / 14° is a spiral); with w forced to 1 they all sit on the + // rotated +y axis and this reads zero + const cross = (a, b) => a[0] * b[1] - a[1] * b[0]; + const twist = Math.abs(cross(rot['0.5'], rot['1'])) + Math.abs(cross(rot['1'], rot['1.5'])); + h.check(twist > 0.2, `the row through the falloff curves into a spiral (twist ${twist.toFixed(3)}) — a straight edge no longer stays straight`); + + // ONE undo restores the whole neighbourhood exactly (the meshgeo snapshot covers it) + const undo = await A.page.evaluate(() => { + window.__stores.history.undo(); + const now = window.__mesh.geometry.attributes.position.array; + let gap = 0; + for (let i = 0; i < Math.min(now.length, window.__beforeExpanded.length); i++) + gap = Math.max(gap, Math.abs(now[i] - window.__beforeExpanded[i])); + return { gap, same: now.length === window.__beforeExpanded.length }; + }); + h.check(undo.same && undo.gap < 1e-6, `ONE undo restores the pre-rotate geometry exactly (max gap ${undo.gap.toExponential(1)})`); + + // ==== SCALE x2 about the anchor: the factor lerps toward 1 by the weight ====== + const armed2 = await arm(A.page); + h.check(!!armed2, 'armed a fresh plane for the scale gesture (premise)'); + const ran2 = await gesture(A.page, { mode: 'scale', scale: [2, 2, 2] }); + h.check(ran2, 'the scale gesture found a seated gizmo (premise)'); + const sc = await readTracked(A.page); + for (const x of [0.5, 1, 1.5]) { + const factor = 1 + (2 - 1) * smooth(x / RADIUS); + h.check( + Math.abs(sc[String(x)][0] - x * factor) < 1e-5 && Math.abs(sc[String(x)][1]) < 1e-9, + `the vertex at x=${x} scaled by lerp(1, 2, w) = ${factor.toFixed(4)} along +x (${sc[String(x)][0].toFixed(4)})` + ); + } + h.check(Math.abs(sc['2'][0] - 2) < 1e-6, `the rim vertex did not scale (${sc['2'][0].toFixed(6)})`); + h.check(Math.hypot(...sc['0']) < 1e-6, 'the anchor at the pivot stays put under scale'); + + // ==== OFF: with proportional disarmed a rotate turns the selection only ====== + await A.page.evaluate(() => window.__stores.meshEdit.proportionalEdit.set(false)); + await gesture(A.page, { mode: 'rotate', axis: [0, 0, 1], degrees: 90 }); + const offRead = await readTracked(A.page); + h.check( + Math.abs(offRead['1'][0] - sc['1'][0]) < 1e-9 && Math.abs(offRead['1'][1] - sc['1'][1]) < 1e-9, + `with proportional OFF a neighbour does not turn — the pre-F1 behaviour survives (${JSON.stringify(offRead['1'].map((n) => +n.toFixed(4)))})` + ); + + await A.page.evaluate(() => window.__stores.meshEdit.exitEditMode()); + await h.finish(browser); +}); From 2ebd781e61dee35510ee8be0d15d4a7e07bc268f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 12 Sep 2026 15:40:41 +0300 Subject: [PATCH 07/17] [feat] P2 watch-look: watching a peer adopts their look state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watching a peer already adopts their CAMERA; it now adopts the state that decides what that camera LOOKS like, which is the only way a camera-scoped or locally overridden look is observable from outside. Presence, never data. - `lookPresence.js` (a LEAF, the `campreview` shape): a per-peer `lookstate` row {camera, mode, overrides:{post,shaders}, look} published on CHANGE (signature gated, never on a timer), replied to `getmodulestate` beside sendCameraPreviewState, and dropped at both finalizeDisconnect sites. ADDITIVE: an older build never sends one, its row stays ABSENT, and the watcher falls back to its own state — which is exactly what it did before this existed. - Outline.svelte resolves the chain from the WATCHED peer's row while `specatorMode` names them: the camera they look through and its own look, their view mode, their local post switch and their Set Look overrides. `resolvedDoc(key, overrides?)` takes the override map as an argument so the watcher never writes anything of theirs into its own stores — leaving the watch reverts by construction. `__postDebug().adoptedFrom` names whose state it is. - The watch banner SAYS when adoption cannot take effect (they shared no row, or they have the scene look switched off) — the P1 lesson that a viewpoint-scoped feature must speak on its own surface, since silence there is indistinguishable from a dead wire. - Suite `watch-look`, 45 checks on two peers: the handshake row both ways, live changes (camera, Set Look, the local switch, wireframe), watch adopts, stop reverts, an absent row falls back, and A disconnecting mid-watch strands nothing. Counterfactuals, each proven by breaking the code and watching it go red: - drop the `adopted` branch in Outline's chain effect -> 10 red (2.3/2.7/2.9/2.10/ 2.13/2.14/2.16/3.1/4.2/4.6): B renders its own chain while watching. - remove `dropPeerLook` from the two disconnect sites -> 5.1/5.2/5.3 red: B stays stranded on a departed peer's look. - remove the module-level send-on-change subscribes -> 1.5 red (the camera change never reaches B's row) and §2 follows it down. Also fixed on the way, both found by those runs: - a duplicated, mis-indented `dropPeerLook` in leaveSession's loop (idempotent, so harmless, but it read as a mistake). - the suite selected `.peer-watch`, which is ALSO worn by the join-a-peer's-camera button rendered beside Watch whenever that peer is previewing — the exact fixture this suite builds. B joined A's camera instead of watching it, so every reading was "fill-blue": the right answer for the wrong reason. It selects by exclusion now and asserts the button count. The same trap sits on `.spectator-exit`, which the camera-preview banner shares with the watch banner. Gates: watch-look 45/45 · duplicate-parity 28/0 · scene-post-effects 41/1 and shader-editor 64/2 (both pre-existing, reproduced at the base commit) · svelte-check 361 errors / 47 warnings with the message set identical to base · `npm run build` green with the dev server down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR --- src/App.svelte | 7 +- src/components/Outline.svelte | 26 ++- src/components/menu/Toasts.svelte | 16 +- src/lib/lookPresence.js | 159 +++++++++++++++++ src/lib/peerHandler.svelte.js | 9 + src/lib/scenePost.js | 8 +- tests/e2e/watch-look.test.cjs | 276 ++++++++++++++++++++++++++++++ 7 files changed, 489 insertions(+), 12 deletions(-) create mode 100644 src/lib/lookPresence.js create mode 100644 tests/e2e/watch-look.test.cjs diff --git a/src/App.svelte b/src/App.svelte index 118d45a5..66f609de 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -367,9 +367,10 @@ import { startMusicToolbox } from './lib/musicToolbox' import('./lib/objectListNav'), import('./lib/inviteLinks'), import('./lib/helperLayer'), - import('./lib/explorerClipboard') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib } + import('./lib/explorerClipboard'), + import('./lib/lookPresence') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib } }) } }) diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 8f305979..c131f85f 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -8,6 +8,10 @@ // the scene's. Exactly how HudLayer resolves an attached HUD — a look on a camera IS // a post document keyed by that camera's uuid, so there is no new concept here. import { cameraPreview } from '$lib/cameraPreview'; + // P2: while WATCHING a peer, the chain resolves from THEIR look state (camera, view + // mode, local post switch, Set Look overrides) instead of ours — presence, never data. + import { specatorMode } from '../stores/appStore.js'; + import { peerLooks, lookOf } from '$lib/lookPresence'; import { scenePost, postStacks, @@ -237,6 +241,8 @@ // belt-and-braces for unknown engines: post also skips the first composer frames // (the boot-compile window is where the breakage bites hardest) let postWarm = $state(false); + /** P2: the peer whose look state the chain was last resolved from ('' = our own) */ + let adoptedFrom = ''; let warmupFrames = 0; let postGateToasted = false; @@ -245,17 +251,25 @@ // mode, the local kill switch, the capability gate and the warm-up). // `postWarm` flipping after 10 frames is one extra rebuild, once. $effect(() => { - const throughCamera = $cameraPreview?.uuid ?? null; + // P2: the WATCHED peer's row, when there is one. `specatorMode` holds a peer id + // while watching; an absent row (an older build) falls through to our own state, + // and so does leaving the watch — nothing of theirs is ever written into ours. + void $peerLooks; + const watching = typeof $specatorMode === 'string' ? $specatorMode : ''; + const adopted = watching ? lookOf(watching) : null; + adoptedFrom = adopted ? watching : ''; + const throughCamera = adopted ? adopted.camera : ($cameraPreview?.uuid ?? null); // resolvedDoc reads the stores with get(), which registers NO svelte dependency — // so BOTH have to be touched here or this effect stops re-running when a document // changes (measured: setting a camera to No files replaced rendered nothing new). void $postStacks; void $lookOverride; + const overrides = adopted ? adopted.look : undefined; const entries = effectivePostStack({ - stack: resolvedDoc(POST_SCENE_KEY), - cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null), - mode: $viewMode, - localEnabled: $postEnabledLocal, + stack: resolvedDoc(POST_SCENE_KEY, overrides), + cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera, overrides) : null), + mode: adopted ? adopted.mode : $viewMode, + localEnabled: adopted ? adopted.overrides.post !== false : $postEnabledLocal, postOk, postWarm }); @@ -462,6 +476,8 @@ ((composer as any).passes ?? []).at(-1) === outlinePassSelected, postWarm, postOk, + // P2: whose look state the chain came from ('' = this viewer's own) + adoptedFrom, // L4: what the renderer was TOLD about tone mapping and what it actually // holds - double grading is invisible in the stack itself stackTonemaps, diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index c35d1511..1e10a4b2 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -18,6 +18,9 @@ // so arming a panel that is not mounted arms nothing import { armExplorerSceneSave, explorerClose } from '../../stores/appStore' import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, connectDrawerTab, toastsInDrawerOnly, showInfoToast, dismissToastById } from '../../stores/appStore' + // P2: the watch banner says when the watched peer's look CANNOT be adopted (the + // P1 rule: a scoped feature must say on its own surface when it takes no effect) + import { peerLooks, watchLookNote } from '$lib/lookPresence' import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave' import { cancelOutboundRequest } from '$lib/peerApproval' import { rolesInfo } from '$lib/cloudHooks' @@ -38,6 +41,12 @@ * The avatar lookup is GUARDED now: by the time this runs the peer may have * travelled or left, and the inline version dereferenced it unconditionally. */ + // `watchLookNote` reads the map with get(); `$peerLooks` is the dependency (the + // get()-registers-nothing rule) — passed as the unused argument the codebase uses + // for exactly this so svelte-check does not flag a comma expression + const lookNote = $derived(typeof $specatorMode === 'string' ? noteFor($specatorMode, $peerLooks) : ''); + function noteFor(peerId: string, _dep: unknown) { return watchLookNote(peerId); } + function exitSpectate() { if (!$specatorMode) return; const dolly = $globalScene?.getObjectByName('dolly'); @@ -360,7 +369,7 @@ $effect(() => {

- Watching {$specatorMode} + Watching {$specatorMode}{#if lookNote} · {lookNote}{/if}

+ +
+ {#if isPost && postGraphs.length} + + + {/if} +{/snippet} + {#snippet actions()} {#if doc}
{/if} @@ -592,16 +699,39 @@

- {ownerName} has no shader yet + {#if isPost} + No post effect to edit yet + {:else} + {ownerName} has no shader yet + {/if}

-

- {scope === SCENE_GRAPH_KEY - ? 'A scene shader drives every object that has no shader of its own' - : 'Deselect to edit the scene-wide shader instead'} -

+ {#if isPost} + +
+ {#each postPresets() as preset (preset.key)} + + {/each} +
+

+ A post effect runs over the finished frame — add it to a look in Configure + Scene ▸ Post-processing +

+ {:else} +

+ {scope === SCENE_GRAPH_KEY + ? 'A scene shader drives every object that has no shader of its own' + : 'Deselect to edit the scene-wide shader instead'} +

+ {/if}
{/if} @@ -750,6 +880,11 @@ {doc.nodes.length} nodes · {doc.edges.length} wires · {doc.backend}

Replicates to peers · saved with the scene

+ {:else if isPost} +

+ Create a post effect, then add it to a look in Configure Scene ▸ + Post-processing. +

{:else}

Select nothing for the scene shader, or one object for its own. @@ -781,6 +916,7 @@ >

+ {@render domainSwitch()} {scopeLabel}
- {#each defs.filter((d) => d.group === group) as def (def.key)} + {#each defs.filter((/** @type {any} */ d) => d.group === group) as def (def.key)}
+ {:else if param.type === 'graph'} +
+ {param.label} + ({ value: g.key, name: g.name }))} + value={entry.params[param.key] ?? ''} + placeholder="Pick a graph…" + onchange={(v) => setPostEffectParams(entry.id, { [param.key]: String(v ?? '') }, docKey)} + /> + +
+ {#each graphErrorsOf(entry, $shaderErrors) as message, i (i)} +

{message}

+ {/each} {:else if param.type === 'asset'}
{param.label} diff --git a/src/lib/postGraphPresets.js b/src/lib/postGraphPresets.js new file mode 100644 index 00000000..1183c143 --- /dev/null +++ b/src/lib/postGraphPresets.js @@ -0,0 +1,145 @@ +// P4 — the shipped POST-GRAPH presets, as pure DATA. +// +// They exist to PROVE THE SEAM rather than to be hardcoded effects: each one is an +// ordinary post graph a user could have built node by node in the editor, so anything a +// preset can do is something the domain can do, and "delete a node and see what changes" +// is how you learn the vocabulary. That is the plan's own reason for shipping posterise, +// ordered dithering, depth+normal edge detect and a custom AO variant specifically — +// between them they touch the scene colour, the pixel grid, the depth buffer and the +// normal buffer, which is every input the domain has. +// +// Imports NOTHING (the shaderCatalog / hudKinds precedent), so the shapes are testable +// with no browser and no GL context. Positions are AUTHORED rather than left to +// normalizeShaderGraph's fallback grid, because these are the first graphs most people +// will open and a readable left-to-right layout is part of the explanation. + +/** + * @typedef {{key: string, label: string, hint: string, doc: () => {nodes: any[], edges: any[], domain: 'post'}}} PostPreset + */ + +/** @param {string} id @param {string} type @param {number} x @param {number} y @param {any} [data] */ +const node = (id, type, x, y, data = {}) => ({ id, type, position: { x, y }, data }); + +/** @param {string} source @param {string} sourceHandle @param {string} target @param {string} targetHandle */ +const edge = (source, sourceHandle, target, targetHandle) => ({ + // the editor's canonical id shape, handles included — the flow lane's lesson that an + // id in any other shape does not survive a reconcile + id: 'e-' + source + '.' + sourceHandle + '-' + target + '.' + targetHandle, + source, + sourceHandle, + target, + targetHandle +}); + +/** @type {PostPreset[]} */ +export const POST_PRESETS = [ + { + key: 'posterise', + label: 'Posterise', + hint: 'Snaps the frame into a few brightness steps — a flat, printed look.', + doc: () => ({ + domain: 'post', + nodes: [ + node('scene', 'sceneColor', 60, 120), + node('steps', 'posterize', 280, 120, { steps: 5 }), + node('out', 'postOutput', 520, 120) + ], + edges: [edge('scene', 'rgb', 'steps', 'a'), edge('steps', 'out', 'out', 'color')] + }) + }, + { + key: 'dither', + label: 'Ordered dither', + hint: 'Posterise with a 4x4 Bayer pattern mixed in first, so the bands break into dots.', + doc: () => ({ + domain: 'post', + nodes: [ + node('scene', 'sceneColor', 60, 60), + node('bayer', 'bayer', 60, 240, { scale: 1 }), + // A CONSTANT IS A NODE. The arithmetic nodes take their operands from SOCKETS + // and have no params at all, so authoring `{ b: 0.5 }` on one would be silently + // ignored and the unwired socket's 0.0 used instead — a preset that looks + // authored and does nothing. Every number here is a Float node on purpose. + node('half', 'float', 60, 370, { value: 0.5 }), + node('depth', 'float', 240, 440, { value: 0.18 }), + // centre the threshold on zero, then scale it to about one posterise step — + // that is what turns a hard band edge into a dot pattern rather than a shift + node('centre', 'subtract', 300, 240), + node('amount', 'multiply', 470, 240), + node('mixed', 'add', 470, 60), + node('steps', 'posterize', 650, 60, { steps: 4 }), + node('out', 'postOutput', 830, 60) + ], + edges: [ + edge('bayer', 'out', 'centre', 'a'), + edge('half', 'out', 'centre', 'b'), + edge('centre', 'out', 'amount', 'a'), + edge('depth', 'out', 'amount', 'b'), + edge('scene', 'rgb', 'mixed', 'a'), + edge('amount', 'out', 'mixed', 'b'), + edge('mixed', 'out', 'steps', 'a'), + edge('steps', 'out', 'out', 'color') + ] + }) + }, + { + key: 'edges', + label: 'Edge detect (ink)', + hint: 'Draws a line wherever depth or surface direction breaks — silhouettes and creases.', + doc: () => ({ + domain: 'post', + nodes: [ + node('scene', 'sceneColor', 60, 60), + node('ink', 'color', 60, 200, { value: '#101014' }), + node('lines', 'edgeDetect', 60, 330, { depthWeight: 6, normalWeight: 1.4 }), + node('mix', 'mix', 380, 160), + node('out', 'postOutput', 620, 160) + ], + edges: [ + edge('scene', 'rgb', 'mix', 'a'), + edge('ink', 'out', 'mix', 'b'), + edge('lines', 'out', 'mix', 't'), + edge('mix', 'out', 'out', 'color') + ] + }) + }, + { + key: 'customao', + label: 'Ambient occlusion (graph)', + hint: 'A depth-only contact shading you can retune, as an alternative to the built-in AO pass.', + doc: () => ({ + domain: 'post', + nodes: [ + node('scene', 'sceneColor', 60, 60), + node('ao', 'ambientOcclusion', 60, 220, { radius: 8, bias: 0.002 }), + node('strength', 'float', 60, 360, { value: 0.85 }), + node('scaled', 'multiply', 300, 260), + node('light', 'oneMinus', 470, 260), + node('shade', 'multiply', 640, 120), + node('out', 'postOutput', 820, 120) + ], + edges: [ + edge('ao', 'out', 'scaled', 'a'), + edge('strength', 'out', 'scaled', 'b'), + edge('scaled', 'out', 'light', 'a'), + edge('scene', 'rgb', 'shade', 'a'), + edge('light', 'out', 'shade', 'b'), + edge('shade', 'out', 'out', 'color') + ] + }) + } +]; + +/** The empty starting point the "New graph" entry creates: the frame, straight through. */ +export function emptyPostGraph() { + return { + domain: /** @type {'post'} */ ('post'), + nodes: [node('scene', 'sceneColor', 90, 130), node('out', 'postOutput', 400, 130)], + edges: [edge('scene', 'rgb', 'out', 'color')] + }; +} + +/** @param {string} key @returns {PostPreset|null} */ +export function postPreset(key) { + return POST_PRESETS.find((preset) => preset.key === key) ?? null; +} diff --git a/src/lib/postGraphs.js b/src/lib/postGraphs.js new file mode 100644 index 00000000..7017d61c --- /dev/null +++ b/src/lib/postGraphs.js @@ -0,0 +1,379 @@ +// P4 — THE POST DOMAIN: a shader graph that is a post-processing effect. +// +// Layer 1 of the look (the plan's three-layer table) has had a stack, a registry and +// twelve built-in kinds since L1-L5; what it has not had is a way to AUTHOR a new kind +// without writing a module. This module is that: a post graph document compiles to a +// fragment function over SCREEN buffers and enters the ordinary scene stack as one more +// entry, so it replicates, saves, undoes, reorders and MERGES with its neighbours with +// nothing new on the wire and no new history kind. +// +// THE DOMAIN SPLIT IS NOT COSMETIC (the parent plan states it as a rule): a post pass +// only has screen buffers, so it can never know an object's material inputs, its UVs or +// its light response; a surface graph only has its own fragment, so it can never see a +// neighbouring pixel. Anything needing both is TWO graphs, deliberately. That is why the +// two domains share the catalog and the editor but have their own terminal node, their +// own compile pass (`compilePostGraphToIR`) and their own backend registry +// (`postBackends`, whose output contract is an `Effect`, not a `Material`). +// +// WHERE THE DOCUMENT LIVES: `shaderGraphs`, keyed `'post:'` — the prefix SH1 reserved +// for exactly this. So replication (`shadergraph`), the `'shadergraph'` history kind, the +// four save paths and the editor's document handling are all inherited rather than +// rebuilt; nothing in shaderSync, sessions or autosave needed a line for this batch. +// +// THE ONE THING THAT IS NEW is the bridge: a post effect KIND named `graph` whose +// `params.graph` names the document. `scenePost` stays a pure leaf (it never learns what +// a shader graph is) and this module never touches the composer — Outline reads +// `tpNeedsNormals` off the compiled effect and adds a NormalPass when one asks for it. + +import { get, writable } from 'svelte/store'; +import { + shaderGraphs, + shaderErrors, + shaderGraphOf, + setShaderGraphFor, + shaderClockNow, + openShaderEditor, + registerPostDomain +} from './shaderGraph.js'; +import { compilePostGraphToIR } from './shaderCompile.js'; +import { postBackend, ensurePostBackends, DEFAULT_POST_BACKEND } from './postBackends.js'; +import { + registerPostEffect, + addPostEffect, + setPostEffectParams, + postStacks, + POST_SCENE_KEY +} from './scenePost.js'; +import { POST_PRESETS, postPreset, emptyPostGraph } from './postGraphPresets.js'; + +/** The reserved key prefix (SH1 declared it; this is its first consumer). */ +export const POST_GRAPH_PREFIX = 'post:'; + +/** @param {string} key @returns {boolean} */ +export function isPostGraphKey(key) { + return typeof key === 'string' && key.startsWith(POST_GRAPH_PREFIX); +} + +/** Every post graph document, newest last. @returns {{key: string, name: string}[]} */ +export function postGraphKeys() { + return Object.keys(get(shaderGraphs)) + .filter(isPostGraphKey) + .map((key) => ({ key, name: postGraphName(key) })); +} + +/** The display name: the document's own, else the id after the prefix. @param {string} key */ +export function postGraphName(key) { + const doc = /** @type {any} */ (shaderGraphOf(key)); + return doc?.name || key.slice(POST_GRAPH_PREFIX.length); +} + +// ---- the editor's view of the domain ------------------------------------------------ +// Which half of the editor you are looking at is a LOCAL pref, like every other editor +// setting — but it lives HERE rather than inside the component because the entry points +// that need to write it (a stack row's Edit button, the add menu's "new preset") are not +// the component. `activePostGraph` is the scope in the post half: the surface half takes +// its scope from the SELECTION and has nothing to choose, while a post graph belongs to +// no object at all, so the post half needs one. + +const LS = typeof localStorage !== 'undefined' ? localStorage : null; + +/** 'surface' | 'post' — which domain the shader editor is showing. + * @type {import('svelte/store').Writable} */ +export const shaderDomain = writable(LS?.getItem('shaderDomain') === 'post' ? 'post' : 'surface'); +shaderDomain.subscribe((value) => { + try { + LS?.setItem('shaderDomain', value); + } catch { + /* private mode: the pref is a convenience, never a requirement */ + } +}); + +/** Which post graph the editor is scoped to (null = the first one that exists). + * @type {import('svelte/store').Writable} */ +export const activePostGraph = writable(null); + +/** Open a post graph in the shader editor — the `openShaderEditor` deep-link shape, with + * the two things that make the link LAND: the domain and the scope. @param {string} key */ +export async function openPostGraph(key) { + activePostGraph.set(key); + shaderDomain.set('post'); + await openShaderEditor(); +} + +let idCounter = 0; +function newKey() { + return POST_GRAPH_PREFIX + Date.now().toString(36) + (idCounter++).toString(36); +} + +/** + * Create a post graph, optionally from a shipped preset, and hand back its key. + * @param {{preset?: string, name?: string}} [opts] + */ +export function createPostGraph(opts = {}) { + const preset = opts.preset ? postPreset(opts.preset) : null; + const doc = preset ? preset.doc() : emptyPostGraph(); + const key = newKey(); + setShaderGraphFor(key, { ...doc, name: opts.name || preset?.label || 'Post effect' }); + return key; +} + +/** Delete the document. Any stack entry naming it keeps its row and renders nothing — + * the same shape as an unknown kind, and recoverable by pointing the row at another + * graph. @param {string} key */ +export function deletePostGraph(key) { + if (!isPostGraphKey(key)) return false; + setShaderGraphFor(key, null); + return true; +} + +/** + * Create a graph AND put it in a look, which is what every entry point actually wants. + * @param {{preset?: string, name?: string, docKey?: string}} [opts] + */ +export function addPostGraphToLook(opts = {}) { + const key = createPostGraph(opts); + const id = addPostEffect('graph', undefined, opts.docKey || POST_SCENE_KEY); + setPostGraphEntry(id, key, opts.docKey || POST_SCENE_KEY); + return { key, id }; +} + +/** Point an existing stack entry at a graph — through scenePost's own mutator, so the + * edit records one undo entry and replicates like any other param write. + * @param {string} id @param {string} key */ +export function setPostGraphEntry(id, key, docKey = POST_SCENE_KEY) { + setPostEffectParams(id, { graph: key }, docKey); +} + +// ---- errors --------------------------------------------------------------------- +// Written into `shaderErrors` under the graph's own key, so the editor surfaces a post +// graph's compile errors in exactly the place it surfaces a surface graph's. + +/** @param {string} key @param {string[]} errors */ +function setErrors(key, errors) { + shaderErrors.update((map) => { + const had = map[key] ?? []; + if (had.length === errors.length && had.every((e, i) => e === errors[i])) return map; + return { ...map, [key]: errors }; + }); +} + +// ---- the compiled effects --------------------------------------------------------- + +/** graphKey -> the live Effect the composer currently holds. @type {Map} */ +const live = new Map(); + +/** graphKey -> an Effect a slow (async) backend produced after `make` had to return. + * @type {Map} */ +const resolved = new Map(); + +/** graphKey -> the fragment text the live effect was built from. @type {Map} */ +const builtFrom = new Map(); + +/** + * The structural signature of a graph: its compiled FRAGMENT. + * + * This is what `scenePost.postStackSignature` folds in, and the choice matters. Folding + * the document's `changedAt` would rebuild the whole composer chain on every scrub of + * every param; folding the fragment rebuilds only when the SHADER SOURCE changes, and a + * uniform-backed param (every number in a preset) changes values without changing a + * character of it — those are written straight into the live effect below instead. + * @param {Record} params + */ +function signature(params) { + const key = params?.graph; + if (!isPostGraphKey(key)) return ''; + const doc = shaderGraphOf(key); + if (!doc) return 'missing'; + const result = compilePostGraphToIR(doc); + return result.ok ? /** @type {any} */ (result.ir).fragment : 'error'; +} + +/** + * Build the Effect for one stack entry. SYNCHRONOUS, because `compilePostStack` is — + * the built-in `inject` backend compiles synchronously, and a module backend that does + * not gets one frame of nothing plus a poke (below), which is the same contract + * `shaderTextures` gives a texture that has not arrived. + * @param {Record} params @param {any} ctx + */ +function make(params, ctx) { + ensurePostBackends(); + const key = params?.graph; + if (!isPostGraphKey(key)) return null; + const doc = /** @type {any} */ (shaderGraphOf(key)); + if (!doc) return null; + const result = compilePostGraphToIR(doc); + if (!result.ok) { + setErrors(key, result.errors ?? ['This post graph does not compile.']); + return null; + } + setErrors(key, []); + const ir = /** @type {any} */ (result.ir); + /** @type {Record} */ + const uniforms = {}; + for (const uniform of ir.uniforms) uniforms[uniform.name] = { value: uniform.value }; + // the normal buffer is OURS to declare but the composer's to fill: Outline owns the + // single NormalPass and assigns its texture to every effect that asked for one + if (ir.readsNormals) uniforms.normalBuffer = { value: null }; + const spec = { + name: 'PostGraph_' + key.replace(/[^A-Za-z0-9_]/g, '_'), + fragment: ir.fragment, + uniforms, + readsDepth: ir.readsDepth, + // SET, not NORMAL: a post graph writes the finished pixel (its own Scene colour + // node is how it keeps any of the frame), so blending it over the input again + // would halve every effect and make "replace the picture" unauthorable + blend: 'SET' + }; + const backendKey = doc.backend && postBackend(doc.backend) ? doc.backend : DEFAULT_POST_BACKEND; + const waiting = resolved.get(key); + if (waiting && builtFrom.get(key) === ir.fragment) { + // an async backend finished after the previous rebuild asked for it + resolved.delete(key); + return adopt(key, waiting, ir); + } + let out = null; + try { + out = /** @type {any} */ (postBackend(backendKey))?.compile(spec, ctx) ?? null; + } catch (error) { + setErrors(key, [String(/** @type {any} */ (error)?.message ?? error)]); + return null; + } + if (out && typeof out.then === 'function') { + builtFrom.set(key, ir.fragment); + out.then((/** @type {any} */ effect) => { + resolved.set(key, effect); + // a stamp-free poke, so the chain rebuilds and picks it up without reading as + // an edit (the registerPostEffect precedent one module over) + postStacks.update((map) => ({ ...map })); + }).catch(() => {}); + return null; + } + return adopt(key, out, ir); +} + +/** @param {string} key @param {any} effect @param {any} ir */ +function adopt(key, effect, ir) { + if (!effect) return null; + effect.tpGraphKey = key; + effect.tpNeedsNormals = !!ir.readsNormals; + effect.tpUsesClock = !!ir.usesClock; + live.set(key, effect); + builtFrom.set(key, ir.fragment); + return effect; +} + +/** Per-frame: the SHARED clock, so an animated post effect is at the same point on every + * peer with no message at all (the Time node's whole contract). @param {any} effect */ +function tick(effect) { + if (!effect?.tpUsesClock) return; + const slot = effect.uniforms?.get?.('uShaderTime'); + if (slot) slot.value = shaderClockNow(); +} + +/** @param {any} effect */ +function dispose(effect) { + if (effect?.tpGraphKey && live.get(effect.tpGraphKey) === effect) { + live.delete(effect.tpGraphKey); + builtFrom.delete(effect.tpGraphKey); + } + effect?.dispose?.(); +} + +/** Every live effect that wants the normal buffer, for Outline's single NormalPass. */ +export function effectsNeedingNormals() { + return [...live.values()].filter((effect) => effect.tpNeedsNormals); +} + +// ---- live param writes ------------------------------------------------------------- + +/** + * A graph edit that did NOT change the shader source is a value change: write it into the + * live effect and leave the composer alone. Without this, every scrub of every number in + * a post graph would tear down and rebuild the whole chain — and WITH it, a structural + * edit still rebuilds, because the signature above is the fragment text. + * @param {string} key + */ +function refreshUniforms(key) { + const effect = live.get(key); + if (!effect) return false; + const doc = shaderGraphOf(key); + if (!doc) return false; + const result = compilePostGraphToIR(doc); + if (!result.ok) return false; + const ir = /** @type {any} */ (result.ir); + if (ir.fragment !== builtFrom.get(key)) return false; // structural: let the rebuild run + for (const uniform of ir.uniforms) { + const slot = effect.uniforms?.get?.(uniform.name); + if (slot && uniform.value !== undefined && !uniform.clock) slot.value = uniform.value; + } + return true; +} + +// ---- wiring ------------------------------------------------------------------------- + +let started = false; + +/** Idempotent; called at module load and safe to call again from a test. */ +export function startPostGraphs() { + if (started) return; + started = true; + ensurePostBackends(); + registerPostEffect('graph', { + label: 'Shader graph', + group: 'graph', + params: [ + { + key: 'graph', + label: 'Graph', + // a TYPE of its own: the choices are the post graphs that exist right now, and + // the row needs a way into the editor beside them, which no generic param + // renderer can offer + type: 'graph', + default: '', + hint: 'Which post graph this entry runs.' + } + ], + make, + signature, + tick, + dispose + }); + // THE COMPILE BRANCH. shaderGraph's own compile path is about MATERIALS, and a post + // document has no Surface node — left alone it would set "The graph has no Surface + // output node" on every post graph and try to install a material on a target that + // does not exist. The seam is a registration rather than an import so shaderGraph + // keeps no edge into this module. + registerPostDomain((key) => { + const doc = shaderGraphOf(key); + if (!doc) { + setErrors(key, []); + live.delete(key); + builtFrom.delete(key); + postStacks.update((map) => ({ ...map })); + return { ok: true }; + } + const result = compilePostGraphToIR(doc); + setErrors(key, result.ok ? [] : (result.errors ?? [])); + // a value-only edit writes the uniforms and stops; a structural one pokes the + // stack so Outline's signature compare notices and rebuilds the chain + if (!refreshUniforms(key)) postStacks.update((map) => ({ ...map })); + return { ok: result.ok, errors: result.errors }; + }); +} + +startPostGraphs(); + +/** The shipped presets, for a menu. */ +export function postPresets() { + return POST_PRESETS.map((preset) => ({ key: preset.key, label: preset.label, hint: preset.hint })); +} + +/** test/debug view */ +export function postGraphsDebug() { + return { + graphs: postGraphKeys(), + live: [...live.keys()], + needsNormals: effectsNeedingNormals().length, + errors: get(shaderErrors) + }; +} diff --git a/src/lib/scenePost.js b/src/lib/scenePost.js index bff57b0b..8e1da06d 100644 --- a/src/lib/scenePost.js +++ b/src/lib/scenePost.js @@ -23,7 +23,7 @@ import { registerHistoryKind, recordEntry } from './history'; /** * @typedef {{id: string, kind: string, enabled: boolean, params: Record}} PostEntry * @typedef {{enabled: boolean, effects: PostEntry[], changedAt: number, mode?: 'append'|'replace'}} PostStack - * @typedef {{key: string, label: string, type?: 'number'|'select'|'bool'|'asset', min?: number, max?: number, step?: number, decimals?: number, default: any, hint?: string, options?: {value: any, label: string}[]}} PostParam + * @typedef {{key: string, label: string, type?: 'number'|'select'|'bool'|'asset'|'graph', min?: number, max?: number, step?: number, decimals?: number, default: any, hint?: string, options?: {value: any, label: string}[]}} PostParam */ // ---- the kind REGISTRY ----------------------------------------------------- @@ -49,7 +49,15 @@ const postKinds = {}; * retarget?: (object: any, camera: any) => void, * resize?: (object: any, width: number, height: number, dpr: number) => void, * applyLocal?: (object: any, prefs: any, params: any) => void, + * signature?: (params: Record) => string, + * tick?: (object: any, delta: number) => void, * dispose?: (object: any) => void}} def + * + * P4 added two OPTIONAL members, both absent on every built-in so nothing about them + * changes: `signature` lets a kind whose output depends on state OUTSIDE its params (a + * post GRAPH, whose shader lives in its own document) tell the chain when it would + * compile differently, and `tick` is the per-frame write for a kind with a live uniform + * — the shared clock, which must not go through a rebuild. */ export function registerPostEffect(kind, def) { postKinds[kind] = { group: 'other', isPass: false, params: [], ...def, kind }; @@ -389,7 +397,12 @@ export function postStackSignature(entries) { return JSON.stringify( (entries ?? []).map((entry) => { const def = postKinds[entry.kind]; - return [entry.kind, def ? (def.isPass ? 'pass' : 'effect') : 'unknown', entry.params ?? {}]; + const base = [entry.kind, def ? (def.isPass ? 'pass' : 'effect') : 'unknown', entry.params ?? {}]; + // P4: a kind may depend on state its params only POINT at — a post graph's entry + // names a document, and editing that document changes the shader without changing + // one character of the entry. The extra element is appended ONLY when a kind + // declares `signature`, so every built-in's signature is byte-identical. + return def?.signature ? [...base, def.signature(entry.params ?? {})] : base; }) ); } diff --git a/src/lib/shaderCatalog.js b/src/lib/shaderCatalog.js index 3a474677..f22ac930 100644 --- a/src/lib/shaderCatalog.js +++ b/src/lib/shaderCatalog.js @@ -16,9 +16,10 @@ // `uniform: true`, so a param edit is a value write and never a recompile // inputs sockets [{name, type, default}] — `default` is the GLSL used when unwired // outputs sockets [{name, type, suffix}] — `suffix` swizzles the node's temp -// stages which shader STAGES the node works in (absent = both). uv/normal mean -// different things per stage and `emit` receives the stage; a node needing the -// view vector or dFdx is 'fragment' only +// stages which shader STAGES the node works in (absent = every stage: 'fragment', +// 'vertex' and, since P4, 'post'). uv/normal mean different things per stage +// and `emit` receives the stage; a node needing the view vector or dFdx is +// 'fragment' only, and a node reading a SCREEN buffer is 'post' only // nativeType the GLSL type `emit` actually returns, when that is not the FIRST output's // type. Every multi-output node needs it: the compiler declares one temp per // node and the swizzled outputs read it, so the temp's type must not depend on @@ -41,18 +42,32 @@ * @property {any[]} [inputs] * @property {any[]} [outputs] * @property {GlslType} [nativeType] - * @property {('fragment'|'vertex')[]} [stages] which shader stages the node works in. - * Absent = both. A node needing the view vector or screen-space derivatives is - * fragment-only, and the compiler refuses it in the vertex pass with an explanation. + * @property {('fragment'|'vertex'|'post')[]} [stages] which shader stages the node works + * in. Absent = all three. A node needing the view vector or screen-space derivatives is + * fragment-only, one reading the scene's colour or depth buffer is post-only, and the + * compiler refuses either elsewhere with an explanation naming both stages. * @property {string[]} [requires] * @property {string} [prelude] * @property {string} [doc] the manual line, merged in from DOCS below * @property {(arg: any) => string} [emit] */ -/** The single output node every graph must have. */ +/** The single output node every SURFACE graph must have. */ export const SURFACE_NODE = 'surface'; +/** + * The single output node every POST graph must have (P4, the Post domain). A post graph + * is a fragment function over SCREEN buffers — it can never know an object's material, + * and a surface graph can never see a neighbouring pixel — so the two domains share the + * catalog but each has its own terminal, and `outputNodeFor(domain)` names it. + */ +export const POST_OUTPUT_NODE = 'postOutput'; + +/** @param {string} domain @returns {string} */ +export function outputNodeFor(domain) { + return domain === 'post' ? POST_OUTPUT_NODE : SURFACE_NODE; +} + /** float in / float out helper for the one-argument maths nodes. */ const fn1 = (/** @type {string} */ name, /** @type {string} */ glsl, /** @type {GlslType} */ type = 'float') => ({ key: name, @@ -137,14 +152,18 @@ const DEFS = [ requires: ['uv'], outputs: [{ name: 'out', type: 'vec2' }], // the varying in the fragment shader, the ATTRIBUTE in the vertex one (three's - // vertex prefix always declares `uv`, but `vUv` only exists behind USE_UV) - emit: (a) => (a.stage === 'vertex' ? 'uv' : 'vUv') + // vertex prefix always declares `uv`, but `vUv` only exists behind USE_UV) — and + // in a POST graph the SCREEN position, which mainImage receives as `uv` + emit: (a) => (a.stage === 'vertex' || a.stage === 'post' ? 'uv' : 'vUv') }, { key: 'normal', label: 'Normal', group: 'Input', requires: ['normal'], + // a screen pixel has no surface of its own: the post domain reads normals from the + // normal BUFFER (Scene normal) instead, so this one is refused there by name + stages: ['fragment', 'vertex'], outputs: [{ name: 'out', type: 'vec3' }], // FRAGMENT: the VARYING, not three's shaded `normal` — our body is emitted before // , so the shaded one is not in scope yet. @@ -580,6 +599,182 @@ const DEFS = [ } }, + // ---- post (P4: the Post domain — screen buffers, post-only) -------------------- + // Everything here reads what the EffectPass fragment already has in scope: + // `inputColor`/`inputBuffer` (the frame so far), `readDepth`/`getViewZ` + cameraNear/ + // cameraFar (behind EffectAttribute.DEPTH, requested through `requires: ['depth']`), + // `resolution`/`texelSize`, and a NormalPass texture the chain adds ON DEMAND when a + // graph `requires` 'normals'. + { + key: 'sceneColor', + label: 'Scene colour', + group: 'Post', + stages: ['post'], + nativeType: 'vec4', + outputs: [ + { name: 'rgb', type: 'vec3', suffix: '.rgb' }, + { name: 'a', type: 'float', suffix: '.a' }, + { name: 'rgba', type: 'vec4' } + ], + emit: () => 'inputColor' + }, + { + key: 'sceneSample', + label: 'Scene sample', + group: 'Post', + stages: ['post'], + inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], + nativeType: 'vec4', + outputs: [ + { name: 'rgb', type: 'vec3', suffix: '.rgb' }, + { name: 'a', type: 'float', suffix: '.a' }, + { name: 'rgba', type: 'vec4' } + ], + emit: (a) => 'texture2D(inputBuffer, ' + a.in.uv + ')' + }, + { + key: 'sceneDepth', + label: 'Scene depth', + group: 'Post', + stages: ['post'], + requires: ['depth'], + inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], + // raw (non-linear, what the buffer holds) AND a 0..1 linear reading between the + // camera's near and far planes — the second is what every visible use wants + nativeType: 'vec2', + outputs: [ + { name: 'linear', type: 'float', suffix: '.y' }, + { name: 'raw', type: 'float', suffix: '.x' } + ], + // `tpDepthAt` is the compiler's POST_DEPTH_PRELUDE, emitted once whenever any node + // requires 'depth' — declared there rather than here so Edge detect and the AO node + // can call it without this node being in the graph + emit: (a) => 'tpDepthAt(' + a.in.uv + ')' + }, + { + key: 'sceneNormal', + label: 'Scene normal', + group: 'Post', + stages: ['post'], + requires: ['normals'], + inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], + outputs: [{ name: 'out', type: 'vec3' }], + // the NormalPass encodes view-space normals as 0..1 + emit: (a) => '(texture2D(normalBuffer, ' + a.in.uv + ').rgb * 2.0 - 1.0)' + }, + { + key: 'resolution', + label: 'Resolution', + group: 'Post', + stages: ['post'], + nativeType: 'vec4', + outputs: [ + { name: 'size', type: 'vec2', suffix: '.xy' }, + { name: 'texel', type: 'vec2', suffix: '.zw' } + ], + emit: () => 'vec4(resolution, texelSize)' + }, + { + key: 'bayer', + label: 'Bayer pattern', + group: 'Post', + stages: ['post'], + inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], + params: [{ name: 'scale', type: 'float', default: 1, uniform: true }], + outputs: [{ name: 'out', type: 'float' }], + // the 4x4 ordered-dither threshold matrix, 0..1, indexed by the PIXEL so it never + // swims with the picture; `scale` grows the cells + prelude: + 'float tpBayer4(vec2 px) {\n' + + ' ivec2 p = ivec2(mod(floor(px), 4.0));\n' + + ' int i = p.x + p.y * 4;\n' + + ' float v = 0.0;\n' + + ' if (i == 0) v = 0.0; else if (i == 1) v = 8.0; else if (i == 2) v = 2.0; else if (i == 3) v = 10.0;\n' + + ' else if (i == 4) v = 12.0; else if (i == 5) v = 4.0; else if (i == 6) v = 14.0; else if (i == 7) v = 6.0;\n' + + ' else if (i == 8) v = 3.0; else if (i == 9) v = 11.0; else if (i == 10) v = 1.0; else if (i == 11) v = 9.0;\n' + + ' else if (i == 12) v = 15.0; else if (i == 13) v = 7.0; else if (i == 14) v = 13.0; else v = 5.0;\n' + + ' return (v + 0.5) / 16.0;\n' + + '}\n', + emit: (a) => 'tpBayer4(' + a.in.uv + ' * resolution / max(' + a.params.scale + ', 1.0))' + }, + { + key: 'edgeDetect', + label: 'Edge detect', + group: 'Post', + stages: ['post'], + requires: ['depth', 'normals'], + inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], + params: [ + { name: 'depthWeight', type: 'float', default: 4, uniform: true }, + { name: 'normalWeight', type: 'float', default: 1, uniform: true } + ], + outputs: [{ name: 'out', type: 'float' }], + // a Sobel over LINEAR depth plus the normal buffer: depth finds silhouettes, + // normals find creases a depth edge misses (the box edge facing you) + prelude: + 'float tpEdge(vec2 uv, float dw, float nw) {\n' + + ' vec2 t = texelSize;\n' + + ' float d00 = tpDepthAt(uv + t * vec2(-1.0, -1.0)).y, d10 = tpDepthAt(uv + t * vec2(0.0, -1.0)).y, d20 = tpDepthAt(uv + t * vec2(1.0, -1.0)).y;\n' + + ' float d01 = tpDepthAt(uv + t * vec2(-1.0, 0.0)).y, d21 = tpDepthAt(uv + t * vec2(1.0, 0.0)).y;\n' + + ' float d02 = tpDepthAt(uv + t * vec2(-1.0, 1.0)).y, d12 = tpDepthAt(uv + t * vec2(0.0, 1.0)).y, d22 = tpDepthAt(uv + t * vec2(1.0, 1.0)).y;\n' + + ' float gx = (d20 + 2.0 * d21 + d22) - (d00 + 2.0 * d01 + d02);\n' + + ' float gy = (d02 + 2.0 * d12 + d22) - (d00 + 2.0 * d10 + d20);\n' + + ' float de = sqrt(gx * gx + gy * gy) * dw;\n' + + ' vec3 n = texture2D(normalBuffer, uv).rgb;\n' + + ' float ne = 0.0;\n' + + ' ne += length(texture2D(normalBuffer, uv + t * vec2(1.0, 0.0)).rgb - n);\n' + + ' ne += length(texture2D(normalBuffer, uv + t * vec2(0.0, 1.0)).rgb - n);\n' + + ' ne += length(texture2D(normalBuffer, uv - t * vec2(1.0, 0.0)).rgb - n);\n' + + ' ne += length(texture2D(normalBuffer, uv - t * vec2(0.0, 1.0)).rgb - n);\n' + + ' return clamp(de + ne * nw, 0.0, 1.0);\n' + + '}\n', + emit: (a) => 'tpEdge(' + a.in.uv + ', ' + a.params.depthWeight + ', ' + a.params.normalWeight + ')' + }, + { + key: 'ambientOcclusion', + label: 'Ambient occlusion (depth)', + group: 'Post', + stages: ['post'], + requires: ['depth'], + inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], + params: [ + { name: 'radius', type: 'float', default: 6, uniform: true }, + { name: 'bias', type: 'float', default: 0.002, uniform: true } + ], + outputs: [{ name: 'out', type: 'float' }], + // the custom-AO slot: a cheap 8-tap depth-only occlusion, 0 (open) .. 1 + // (occluded). Not N8AO — the point is that a post GRAPH can sit beside or + // replace it, and this is the honest small version a graph can carry. + prelude: + 'float tpAo(vec2 uv, float radius, float bias) {\n' + + ' float d = tpDepthAt(uv).y;\n' + + ' vec2 r = texelSize * radius;\n' + + ' float occ = 0.0;\n' + + ' vec2 dirs[8];\n' + + ' dirs[0] = vec2(1.0, 0.0); dirs[1] = vec2(-1.0, 0.0); dirs[2] = vec2(0.0, 1.0); dirs[3] = vec2(0.0, -1.0);\n' + + ' dirs[4] = vec2(0.7, 0.7); dirs[5] = vec2(-0.7, 0.7); dirs[6] = vec2(0.7, -0.7); dirs[7] = vec2(-0.7, -0.7);\n' + + ' for (int i = 0; i < 8; i++) {\n' + + ' float s = tpDepthAt(uv + dirs[i] * r).y;\n' + + ' float diff = d - s - bias;\n' + + ' occ += clamp(diff / max(bias * 8.0, 0.0001), 0.0, 1.0) * step(0.0, diff);\n' + + ' }\n' + + ' return clamp(occ / 8.0, 0.0, 1.0);\n' + + '}\n', + emit: (a) => 'tpAo(' + a.in.uv + ', ' + a.params.radius + ', ' + a.params.bias + ')' + }, + { + key: POST_OUTPUT_NODE, + label: 'Post output', + group: 'Output', + stages: ['post'], + inputs: [ + { name: 'color', type: 'vec3', default: null }, + // unwired: the frame's own alpha, so a graph that only recolours keeps it + { name: 'alpha', type: 'float', default: 'inputColor.a' } + ], + outputs: [] + }, + // ---- the output ------------------------------------------------------------- { key: SURFACE_NODE, @@ -617,7 +812,7 @@ const DOCS = { color: 'A colour you pick. Converted sRGB -> linear, so it matches what the picker shows.', vector2: 'Two numbers — usually a UV offset, a tiling amount or a 2D direction.', vector3: 'Three numbers — a direction, a position offset, or a colour you want as numbers.', - uv: "The surface's texture coordinates: 0..1 across the mesh's UV layout. The starting point for anything that varies across a surface.", + uv: "The surface's texture coordinates: 0..1 across the mesh's UV layout — or, in a post graph, the screen position. The starting point for anything that varies across a surface.", normal: 'Which way the surface faces. In the surface stage this is the shaded normal; wired into Position it is the object-space normal, which is what you displace along.', viewDirection: 'The direction from the surface towards the camera. Surface stage only — there is no camera vector while vertices are being placed.', time: 'Seconds from the SHARED clock, so anything animated is at the same point for every peer with no messages. Multiply by speed to go faster.', @@ -667,7 +862,19 @@ const DOCS = { normalMap: 'Reads a normal map image and applies it as surface detail, building the tangent frame from screen-space derivatives so it works on meshes with no tangents.', glsl: 'The escape hatch: write a GLSL expression using a, b and c as the wired inputs, and declare what type it returns.', + // post (P4) + sceneColor: 'The frame as rendered so far, before this effect: the colour under this screen pixel. The starting point of every post graph.', + sceneSample: 'The frame colour at ANY screen position you give it — offset the UV by a texel to read a neighbour, which is how blurs and edge detectors are built.', + sceneDepth: 'How far away the thing under this pixel is: linear runs 0 (near plane) to 1 (far plane), raw is what the depth buffer holds. Fog, depth tints, edge detection.', + sceneNormal: 'Which way the surface under this pixel faces, from a normal pass the chain adds only when a graph asks for it. Creases and outlines that depth alone misses.', + resolution: 'The frame size in pixels, and one texel as a UV step — what you multiply a screen offset by so it stays one pixel wide at any window size.', + bayer: 'An ordered-dither threshold pattern locked to the pixel grid, 0..1. Add it (minus a half) before Posterise for retro dithering; scale grows the cells.', + edgeDetect: 'A line strength, 0..1, where depth or normals change sharply — silhouettes and creases. Mix a line colour over the scene colour by it for an ink look.', + ambientOcclusion: 'A cheap screen-space occlusion from depth alone, 0 open to 1 tucked into a corner. Darken the scene colour by it for contact shading you can tune in a graph.', + // output + [POST_OUTPUT_NODE]: + "The post graph's output: the colour this effect writes for the pixel, with alpha left to the frame's own unless you wire it. Everything upstream of color is one fullscreen pass.", [SURFACE_NODE]: "The graph's output. Each input replaces one part of the material and anything left unconnected keeps the material's own value: albedo (base colour), emissive (glow), roughness, metalness, normal (surface detail), opacity (needs blending), ao (shades indirect light) and position (moves vertices — note it does not recompute normals or move the shadow)." }; diff --git a/src/lib/shaderCompile.js b/src/lib/shaderCompile.js index 566f87b5..5dd23108 100644 --- a/src/lib/shaderCompile.js +++ b/src/lib/shaderCompile.js @@ -22,10 +22,10 @@ // recomputed per consumer), and loop forever on a cycle. Both are handled by the // memo + the in-progress set, the PATH-based guard the flow editor uses. -import { shaderNodeDef, outputTypeOf, SURFACE_NODE } from './shaderCatalog.js'; +import { shaderNodeDef, outputTypeOf, SURFACE_NODE, POST_OUTPUT_NODE } from './shaderCatalog.js'; /** @typedef {'float'|'vec2'|'vec3'|'vec4'|'sampler2D'} GlslType */ -/** @typedef {'fragment'|'vertex'} ShaderStage */ +/** @typedef {'fragment'|'vertex'|'post'} ShaderStage */ /** The FRAGMENT taps the inject backend exposes, and the type each expects. */ const TAP_TYPES = { @@ -41,8 +41,24 @@ const TAP_TYPES = { /** The VERTEX taps — compiled in their own pass. */ const VERTEX_TAP_TYPES = { position: 'vec3' }; +/** The POST tap: one colour (plus the frame's own alpha unless wired). */ +const POST_TAP_TYPES = { color: 'vec3', alpha: 'float' }; + /** Stage names as a user would recognise them. @type {Record} */ -const STAGE_LABEL = { fragment: 'surface', vertex: 'vertex displacement' }; +const STAGE_LABEL = { fragment: 'surface', vertex: 'vertex displacement', post: 'post-processing' }; + +/** + * The one helper every depth-reading post node calls, emitted ONCE by the post pass + * whenever any node requires 'depth' — declared here rather than on the Scene depth node + * so Edge detect and the AO node work in a graph that has no Scene depth node at all. + * `readDepth`/`getViewZ`/cameraNear/cameraFar are the EffectPass fragment's own. + */ +const POST_DEPTH_PRELUDE = + 'vec2 tpDepthAt(vec2 uv) {\n' + + ' float d = readDepth(uv);\n' + + ' float z = -getViewZ(d);\n' + + ' return vec2(d, clamp((z - cameraNear) / (cameraFar - cameraNear), 0.0, 1.0));\n' + + '}\n'; /** * A socket DEFAULT written for the fragment shader, and its vertex-stage equivalent. @@ -59,6 +75,21 @@ const VERTEX_EQUIVALENT = { 'normalize(vNormal)': 'objectNormal' }; +/** + * The same rule for the POST stage: a socket default written for a surface has a screen + * equivalent or none. `vUv` is the screen position mainImage receives as `uv`; a surface + * normal has no screen equivalent, and a socket defaulting to one is refused by name + * rather than silently reading a varying that does not exist in an EffectPass. + * @type {Record} + */ +const POST_EQUIVALENT = { + vUv: 'uv', + 'normalize(vNormal)': null +}; + +/** @type {Record>} */ +const STAGE_EQUIVALENT = { vertex: VERTEX_EQUIVALENT, post: POST_EQUIVALENT }; + /** * Convert `expr` from `from` to `to`. GLSL will not do this silently, and a mismatch is * a shader compile error the user cannot read — so coerce explicitly and predictably. @@ -152,17 +183,17 @@ export function uniformValue(authored, type) { } /** - * Compile a graph document into the inject IR. - * @param {{nodes: any[], edges: any[]}} graph - * @returns {{ok: boolean, ir?: any, errors?: string[]}} + * The compiler CORE, shared by both domains: the memoised per-pass evaluator over one + * graph and its terminal node. The two public entry points differ only in which taps + * they walk and what they assemble from the result. + * @param {{nodes: any[], edges: any[]}} graph @param {string} outputType */ -export function compileShaderGraphToIR(graph) { +function createCompiler(graph, outputType) { const nodes = graph?.nodes ?? []; const edges = graph?.edges ?? []; /** @type {string[]} */ const errors = []; - const output = nodes.find((n) => n.type === SURFACE_NODE); - if (!output) return { ok: false, errors: ['The graph has no Surface output node.'] }; + const output = nodes.find((n) => n.type === outputType); /** @type {Map} */ const nodeById = new Map(nodes.map((n) => [n.id, n])); @@ -263,12 +294,13 @@ export function compileShaderGraphToIR(graph) { for (const socket of def.inputs ?? []) { const edge = incoming.get(nodeId + '\0' + socket.name); // a screen input means something different per stage, so an unwired socket's - // default is translated for the vertex stage (see VERTEX_EQUIVALENT), with an - // explicit `vertexDefault` overriding it - const fallback = - stage === 'vertex' - ? (socket.vertexDefault ?? VERTEX_EQUIVALENT[socket.default] ?? socket.default) - : socket.default; + // default is translated for the vertex stage (see VERTEX_EQUIVALENT) and the + // post stage (POST_EQUIVALENT, where `null` means "no equivalent — refuse"), + // with an explicit `vertexDefault` overriding the vertex one + const table = STAGE_EQUIVALENT[stage]; + let fallback = socket.default; + if (stage === 'vertex' && socket.vertexDefault !== undefined) fallback = socket.vertexDefault; + else if (table && socket.default != null && socket.default in table) fallback = table[socket.default]; if (edge) { const up = evalOutput(edge.source, edge.sourceHandle ?? 'out'); if (!up) { @@ -282,8 +314,14 @@ export function compileShaderGraphToIR(graph) { inExpr[socket.name] = fallback; if (fallback === 'vUv') requires.add('uv'); } else { - // an unwired socket with no default is a real authoring error - errors.push('Node "' + label + '" needs its "' + socket.name + '" input connected.'); + // an unwired socket with no default is a real authoring error — and so is a + // surface-only default in a stage that has no equivalent for it + errors.push( + socket.default != null + ? 'Node "' + label + '" reads "' + socket.name + '" from the surface, which the ' + + (STAGE_LABEL[stage] ?? stage) + ' stage does not have — connect it.' + : 'Node "' + label + '" needs its "' + socket.name + '" input connected.' + ); inProgress.delete(nodeId); return null; } @@ -349,6 +387,18 @@ export function compileShaderGraphToIR(graph) { return { statements, requires, walkTaps }; } + return { output, errors, uniforms, preludes, makePass }; +} + +/** + * Compile a SURFACE graph document into the inject IR. + * @param {{nodes: any[], edges: any[]}} graph + * @returns {{ok: boolean, ir?: any, errors?: string[]}} + */ +export function compileShaderGraphToIR(graph) { + const { output, errors, uniforms, preludes, makePass } = createCompiler(graph, SURFACE_NODE); + if (!output) return { ok: false, errors: ['The graph has no Surface output node.'] }; + /** @type {any} */ const ir = { uniforms: [], prelude: '', body: '', defines: {} }; @@ -381,6 +431,48 @@ export function compileShaderGraphToIR(graph) { return { ok: true, ir }; } +/** + * Compile a POST graph document into a `postBackends` shader spec (P4, the Post domain). + * + * ONE pass, stage 'post', over the Post output node's `color` (and optional `alpha`) + * taps. The result is the whole fragment an EffectPass wants: the graph's preludes, its + * uniform DECLARATIONS (postprocessing prefixes and integrates the ones the Effect's + * uniform map names — so they must be declared in the text and named in the map, both), + * and a `mainImage` writing `outputColor`. `readsDepth` asks the backend for + * EffectAttribute.DEPTH (getting that wrong is SILENT — the sampler is simply never + * filled), `readsNormals` asks the chain for a NormalPass, and `usesClock` for the + * shared-clock uniform every peer advances identically. + * @param {{nodes: any[], edges: any[]}} graph + * @returns {{ok: boolean, ir?: {fragment: string, uniforms: any[], readsDepth: boolean, readsNormals: boolean, usesClock: boolean, requires: string[]}, errors?: string[]}} + */ +export function compilePostGraphToIR(graph) { + const { output, errors, uniforms, preludes, makePass } = createCompiler(graph, POST_OUTPUT_NODE); + if (!output) return { ok: false, errors: ['The graph has no Post output node.'] }; + const pass = makePass('post'); + const taps = pass.walkTaps(POST_TAP_TYPES); + if (!taps.color && !errors.length) + errors.push('Nothing is connected to the Post output\'s colour, so the effect would change nothing.'); + if (errors.length) return { ok: false, errors }; + const usesClock = pass.requires.has('time'); + if (usesClock) uniforms.set('uShaderTime', { name: 'uShaderTime', type: 'float', value: 0, clock: true }); + const readsDepth = pass.requires.has('depth') || pass.requires.has('normals'); + const readsNormals = pass.requires.has('normals'); + const list = [...uniforms.values()]; + const decls = list.map((u) => 'uniform ' + u.type + ' ' + u.name + ';').join('\n'); + const fragment = + (readsDepth ? POST_DEPTH_PRELUDE : '') + + (readsNormals ? 'uniform sampler2D normalBuffer;\n' : '') + + [...preludes.values()].join('\n') + + (decls ? decls + '\n' : '') + + 'void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {\n\t' + + pass.statements.join('\n\t') + + '\n\toutputColor = vec4(' + taps.color + ', ' + (taps.alpha ?? 'inputColor.a') + ');\n}'; + return { + ok: true, + ir: { fragment, uniforms: list, readsDepth, readsNormals, usesClock, requires: [...pass.requires] } + }; +} + /** node ids can contain anything; GLSL identifiers cannot. @param {string} id */ function safe(id) { return String(id).replace(/[^A-Za-z0-9_]/g, '_'); diff --git a/src/lib/shaderGraph.js b/src/lib/shaderGraph.js index 59c63e17..47b975c8 100644 --- a/src/lib/shaderGraph.js +++ b/src/lib/shaderGraph.js @@ -217,6 +217,33 @@ export function scheduleCompile(key, delay = 60) { ); } +/** P4: the POST domain's own compile path, registered by `postGraphs` (never imported — + * this module must keep no edge into the composer side). @type {((key: string) => any)|null} */ +let postDomainHook = null; + +/** + * Install the post domain's compiler. + * + * A post document has no Surface node and drives no object, so shaderGraph's material + * path is simply the wrong question for it: left to run it would stamp "The graph has no + * Surface output node" on every post graph. A REGISTRATION rather than an import for the + * usual reason — `postGraphs` reaches `scenePost` and `postBackends`, and an edge from + * here into that is one this module does not need. + * @param {(key: string) => any} fn + */ +export function registerPostDomain(fn) { + postDomainHook = typeof fn === 'function' ? fn : null; + return () => { + if (postDomainHook === fn) postDomainHook = null; + }; +} + +/** Is this key a post-domain document (by its own `domain`, so the prefix is a + * convention and not the truth)? @param {string} key */ +export function isPostDomain(key) { + return shaderGraphOf(key)?.domain === 'post'; +} + /** * Compile a key's graph and install the material on every object it drives. * On FAILURE the object keeps its last good material — a broken graph mid-edit must not @@ -225,6 +252,11 @@ export function scheduleCompile(key, delay = 60) { */ export async function compileAndApply(key) { const doc = shaderGraphOf(key); + // P4: a POST document is an EFFECT, not a material — hand it to the domain that owns + // it. A deleted document still reaches the hook (doc is null), which is how a post + // graph's own teardown runs. + if ((doc?.domain === 'post' || (!doc && postDomainHook && key.startsWith('post:'))) && postDomainHook) + return postDomainHook(key) ?? { ok: true }; if (!doc) { // deleted: put every target back to its own material for (const object of targetsFor(key)) detachFrom(object); diff --git a/tests/e2e/scene-post-effects.test.cjs b/tests/e2e/scene-post-effects.test.cjs index ffccceb1..858e816f 100644 --- a/tests/e2e/scene-post-effects.test.cjs +++ b/tests/e2e/scene-post-effects.test.cjs @@ -63,7 +63,11 @@ h.run(async () => { 'pixelation:camera', 'scanlines:camera', 'dotscreen:stylize', - 'smaa:aa' + 'smaa:aa', + // P4: the post DOMAIN's bridge — an effect whose shader is a graph document rather + // than a built-in. It belongs in this list for the same reason it belongs in the add + // menu: it is a kind of the library, registered through the same seam. + 'graph:graph' ]; for (const want of expected) h.check(kinds.includes(want), '1.x ' + want + ' is registered in the right group'); diff --git a/tests/e2e/shader-compile.test.cjs b/tests/e2e/shader-compile.test.cjs index 3d6a9480..538c0796 100644 --- a/tests/e2e/shader-compile.test.cjs +++ b/tests/e2e/shader-compile.test.cjs @@ -107,7 +107,10 @@ const edge = (from, to, targetHandle, sourceHandle = 'out') => ({ // ---- 8. every def is well formed --------------------------------------- const defs = shaderNodeDefs(); - const bad = defs.filter((d) => !d.key || !d.label || !d.group || (d.key !== 'surface' && !d.emit)); + // the TERMINAL nodes are the exception: they emit nothing because nothing reads them — + // each domain's graph ends at one (P4 added the post half's) + const terminals = ['surface', 'postOutput']; + const bad = defs.filter((d) => !d.key || !d.label || !d.group || (!terminals.includes(d.key) && !d.emit)); check(bad.length === 0, defs.length + ' node defs, all with key/label/group/emit: ' + JSON.stringify(bad.map((d) => d.key))); check(!!shaderNodeDef('surface'), 'the Surface output def exists'); diff --git a/tests/e2e/shader-post-domain.test.cjs b/tests/e2e/shader-post-domain.test.cjs new file mode 100644 index 00000000..c15c2f5b --- /dev/null +++ b/tests/e2e/shader-post-domain.test.cjs @@ -0,0 +1,332 @@ +// P4 — THE POST DOMAIN: a shader graph that compiles to a post-processing effect. +// +// Two halves, for two different risks. The COMPILER half runs with no browser (the +// shader-compile precedent, importing the ESM directly) because the stage rules are pure +// and the way they fail is silent — a surface node in a post graph reads a varying that +// does not exist there and compiles to a wrong picture with no error, so the guard is +// that it is REFUSED BY NAME. The RUNTIME half needs a real GL context and measures +// PIXELS, because "the entry is in the stack" has never been the same question as "the +// frame changed". + +const h = require('./helpers.cjs'); +const { pathToFileURL } = require('url'); +const path = require('path'); + +const src = (f) => pathToFileURL(path.join(__dirname, '..', '..', 'src', 'lib', f)).href; + +/** the live scene stack */ +const stackOf = (page) => + page.evaluate(() => { + let state = null; + window.__stores.scenePost.scenePost.subscribe((s) => (state = s))(); + return state.effects.map((e) => ({ id: e.id, kind: e.kind, params: e.params })); + }); + +const postDebug = (page) => page.evaluate(() => window.__postDebug()); + +const graphsOn = (page) => + page.evaluate(() => { + let map = null; + window.__stores.shaderGraph.shaderGraphs.subscribe((m) => (map = m))(); + return Object.keys(map); + }); + +h.run(async () => { + // ================================================================ compiler + console.log('\n=== 1. the compiler: stages, taps and refusals (no browser) ==='); + const catalog = await import(src('shaderCatalog.js')); + const compile = await import(src('shaderCompile.js')); + const presets = await import(src('postGraphPresets.js')); + + const postDefs = catalog.shaderNodeDefs().filter((d) => d.group === 'Post'); + h.check(postDefs.length >= 8, '1.1 the catalog has a Post group: ' + postDefs.map((d) => d.key).join(',')); + h.check( + postDefs.every((d) => d.stages && d.stages.includes('post') && !d.stages.includes('fragment')), + '1.2 ...and every one of them is post-ONLY (a screen buffer has no surface)' + ); + h.check(catalog.outputNodeFor('post') === 'postOutput' && catalog.outputNodeFor('surface') === 'surface', + '1.3 each domain names its own terminal node'); + + for (const preset of presets.POST_PRESETS) { + const r = compile.compilePostGraphToIR(preset.doc()); + h.check(r.ok, '1.4 preset "' + preset.key + '" compiles: ' + JSON.stringify(r.errors ?? [])); + } + const edgesIr = compile.compilePostGraphToIR(presets.postPreset('edges').doc()).ir; + h.check(edgesIr.readsDepth && edgesIr.readsNormals, '1.5 edge detect declares BOTH depth and normals'); + const posterIr = compile.compilePostGraphToIR(presets.postPreset('posterise').doc()).ir; + h.check( + !posterIr.readsDepth && !posterIr.readsNormals, + '1.6 ...and posterise declares NEITHER (the buffers are opt-in, not ambient)' + ); + h.check( + presets.POST_PRESETS.every((p) => !/\bvUv\b/.test(compile.compilePostGraphToIR(p.doc()).ir.fragment)), + '1.7 no post fragment mentions vUv — the surface default is TRANSLATED, not emitted' + ); + // the counterfactual for that translation: `vUv` IS a real identifier in an + // EffectPass's vertex shader, so emitting it would compile and read nothing + const uvOnly = compile.compilePostGraphToIR({ + nodes: [ + { id: 'o', type: 'postOutput', data: {} }, + { id: 'n', type: 'noise', data: {} } + ], + edges: [{ source: 'n', sourceHandle: 'out', target: 'o', targetHandle: 'color' }] + }); + h.check( + uvOnly.ok && /tpNoise\(uv/.test(uvOnly.ir.fragment), + '1.8 an unwired uv socket reads the SCREEN uv in a post graph' + ); + + const refused = compile.compilePostGraphToIR({ + nodes: [ + { id: 'o', type: 'postOutput', data: {} }, + { id: 'f', type: 'fresnel', data: {} } + ], + edges: [{ source: 'f', sourceHandle: 'out', target: 'o', targetHandle: 'color' }] + }); + h.check( + !refused.ok && /only works in the surface stage/.test(refused.errors[0] ?? ''), + '1.9 a surface-only node in a post graph is refused BY NAME: ' + JSON.stringify(refused.errors) + ); + const empty = compile.compilePostGraphToIR({ nodes: [{ id: 'o', type: 'postOutput', data: {} }], edges: [] }); + h.check( + !empty.ok && /colour/.test(empty.errors[0] ?? ''), + '1.10 an unwired output says the effect would change nothing: ' + JSON.stringify(empty.errors) + ); + const noOut = compile.compilePostGraphToIR({ nodes: [{ id: 'c', type: 'sceneColor', data: {} }], edges: [] }); + h.check(!noOut.ok && /Post output/.test(noOut.errors[0] ?? ''), '1.11 a graph with no Post output says so'); + // the surface compiler is untouched by any of this + const surface = compile.compileShaderGraphToIR({ + nodes: [ + { id: 's', type: 'surface', data: {} }, + { id: 'c', type: 'color', data: { value: '#ff0000' } } + ], + edges: [{ source: 'c', sourceHandle: 'out', target: 's', targetHandle: 'albedo' }] + }); + h.check(surface.ok && !!surface.ir.albedo, '1.12 the SURFACE compiler still compiles a surface graph'); + + // ================================================================ runtime + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + console.log('\n=== 2. the kind, and a graph entering the scene look ==='); + const registered = await page.evaluate(() => + window.__stores.scenePost.postEffectKinds().find((d) => d.kind === 'graph') + ); + h.check( + !!registered && registered.group === 'graph', + '2.1 postGraphs registers the `graph` kind: ' + JSON.stringify(registered) + ); + + // a lit box to look at, and a clean stack + await page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 900)); + window.__stores.objectActions.deselectObject(); + window.__stores.viewMode.set('shaded'); + window.__stores.scenePost.postStacks.set({}); + await new Promise((r) => setTimeout(r, 900)); + }); + const clip = await h.centeredClip(A, [0, 0, 0], 420); + const base = await h.grabFrame(A, clip); + h.check((await postDebug(page)).stackPasses === 0, '2.2 premise: nothing in the stack to start with'); + + const made = await page.evaluate(() => window.__stores.postGraphs.addPostGraphToLook({ preset: 'posterise' })); + await page.waitForTimeout(1500); + const stack = await stackOf(page); + h.check( + stack.length === 1 && stack[0].kind === 'graph' && stack[0].params.graph === made.key, + '2.3 one menu action creates the document AND the stack entry that runs it: ' + JSON.stringify(stack) + ); + h.check( + (await graphsOn(page)).includes(made.key), + '2.4 ...the document lives in shaderGraphs under its `post:` key (so it replicates and saves for free)' + ); + const dbg = await postDebug(page); + h.check( + dbg.graphs.length === 1 && dbg.graphs[0].key === made.key, + '2.5 ...and the composer holds a compiled effect for it: ' + JSON.stringify(dbg.graphs) + ); + + console.log('\n=== 3. every preset changes the picture, and differently ==='); + /** swap the look to one preset and return its frame */ + async function framePreset(preset) { + const key = await page.evaluate((p) => { + const pg = window.__stores.postGraphs; + const post = window.__stores.scenePost; + post.postStacks.set({}); + return pg.addPostGraphToLook({ preset: p }).key; + }, preset); + await page.waitForTimeout(1600); + return { key, frame: await h.grabFrame(A, clip), debug: await postDebug(page) }; + } + /** @type {Record} */ + const shots = {}; + for (const preset of ['posterise', 'dither', 'edges', 'customao']) { + shots[preset] = await framePreset(preset); + const delta = await h.frameDelta(page, base, shots[preset].frame); + h.check( + delta.changed > 2000, + '3.' + preset + ' changes the frame: ' + delta.changed + ' px changed, mean ' + delta.mean.toFixed(2) + ); + } + // PAIRWISE, because "each differs from the baseline" would pass for four copies of + // one effect — the thing being proven is that the GRAPH decides the picture + const pairs = [ + ['posterise', 'dither'], + ['posterise', 'edges'], + ['edges', 'customao'] + ]; + for (const [a, b] of pairs) { + const delta = await h.frameDelta(page, shots[a].frame, shots[b].frame); + h.check(delta.changed > 2000, '3.pair ' + a + ' vs ' + b + ' differ: ' + delta.changed + ' px'); + } + + console.log('\n=== 4. the buffers are opt-in ==='); + h.check( + shots.posterise.debug.normals === false, + '4.1 posterise adds NO normal pass (a second scene render is not an ambient cost)' + ); + h.check(shots.edges.debug.normals === true, '4.2 edge detect adds ONE, on demand'); + h.check( + shots.edges.debug.graphs[0]?.depth === true && shots.customao.debug.graphs[0]?.depth === true, + '4.3 a depth-reading graph carries EffectAttribute.DEPTH, which is what binds the buffer' + ); + h.check( + shots.posterise.debug.graphs[0]?.depth === false, + '4.4 ...and one that never reads depth does not ask for it' + ); + + console.log('\n=== 5. a value edit writes the uniform; a structural edit rebuilds ==='); + // back to posterise, and remember which chain we are on + const poster = await framePreset('posterise'); + const before = await postDebug(page); + const stepsNode = await page.evaluate((key) => { + let map = null; + window.__stores.shaderGraph.shaderGraphs.subscribe((m) => (map = m))(); + return (map[key]?.nodes ?? []).find((n) => n.type === 'posterize')?.id ?? ''; + }, poster.key); + h.check(!!stepsNode, '5.1 premise: the preset has a Posterise node to retune'); + await page.evaluate( + ({ key, id }) => window.__stores.shaderGraph.setShaderParam(key, id, 'steps', 2), + { key: poster.key, id: stepsNode } + ); + await page.waitForTimeout(1200); + const afterValue = await postDebug(page); + const valueDelta = await h.frameDelta(page, poster.frame, await h.grabFrame(A, clip)); + h.check(valueDelta.changed > 1000, '5.2 a param change changes the picture: ' + valueDelta.changed + ' px'); + h.check( + afterValue.stackPasses === before.stackPasses && afterValue.graphs.length === before.graphs.length, + '5.3 ...through the live uniform — the chain still holds one pass for one graph' + ); + // a STRUCTURAL edit (a node removed) must recompile the shader, not just a uniform + await page.evaluate( + ({ key, id }) => { + let map = null; + window.__stores.shaderGraph.shaderGraphs.subscribe((m) => (map = m))(); + const doc = map[key]; + window.__stores.shaderGraph.setShaderGraphFor(key, { + nodes: doc.nodes.filter((n) => n.id !== id), + edges: doc.edges.filter((e) => e.source !== id && e.target !== id) + }); + }, + { key: poster.key, id: stepsNode } + ); + await page.waitForTimeout(1500); + const broken = await postDebug(page); + h.check( + broken.graphs.length === 0 && broken.stackPasses === 0, + '5.4 a structural edit that leaves the output unwired takes the pass OUT rather than rendering stale GLSL' + ); + const errs = await page.evaluate((key) => { + let map = null; + window.__stores.shaderGraph.shaderErrors.subscribe((m) => (map = m))(); + return map[key] ?? []; + }, poster.key); + h.check(errs.length > 0, '5.5 ...and says why, under the graph key the editor reads: ' + JSON.stringify(errs)); + + console.log('\n=== 6. the editor: one surface, two domains ==='); + await page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.postGraphs.shaderDomain.set('surface'); + window.__stores.shaderEditorClose.set(false); + window.__stores.bottomDock.activateDock('shader'); + }); + await page.waitForTimeout(1200); + const surfaceGroups = await page.evaluate(() => + [...document.querySelectorAll('#shader-palette .shader-palette-group')].map((el) => el.textContent.trim()) + ); + h.check( + surfaceGroups.length > 0 && !surfaceGroups.includes('Post'), + '6.1 the SURFACE palette offers no Post nodes: ' + JSON.stringify(surfaceGroups) + ); + await page.evaluate(() => document.querySelector('#shader-domain-post').click()); + await page.waitForTimeout(1000); + const postGroups = await page.evaluate(() => + [...document.querySelectorAll('#shader-palette .shader-palette-group')].map((el) => el.textContent.trim()) + ); + h.check(postGroups.includes('Post'), '6.2 the POST palette offers them: ' + JSON.stringify(postGroups)); + // neither terminal is addable in either domain — one comes with the graph, and a + // second one in a document is a graph with two answers + const terminals = await page.evaluate(() => + [...document.querySelectorAll('#shader-palette .shader-palette-item')] + .map((el) => el.textContent.trim()) + .filter((name) => name === 'Surface' || name === 'Post output') + ); + h.check(terminals.length === 0, '6.2b neither terminal node is in the palette: ' + JSON.stringify(terminals)); + const scopeText = await page.evaluate(() => document.querySelector('#shader-scope')?.textContent?.trim() ?? ''); + h.check(/post effect/i.test(scopeText), '6.3 the scope line names the post effect: "' + scopeText + '"'); + h.check( + (await page.evaluate(() => document.querySelectorAll('#shader-editor .svelte-flow__node').length)) > 0, + '6.4 ...and the graph is on the canvas' + ); + await page.evaluate(() => document.querySelector('#shader-domain-surface').click()); + await page.waitForTimeout(800); + const backText = await page.evaluate(() => document.querySelector('#shader-scope')?.textContent?.trim() ?? ''); + h.check(/scene default/i.test(backText), '6.5 switching back is the SURFACE scope again: "' + backText + '"'); + + console.log('\n=== 7. two peers ==='); + const B = await h.setupPage(browser, 'B'); + await B.page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.viewMode.set('shaded'); + }); + // a clean, working look to replicate + const shared = await framePreset('edges'); + await h.connect(B, A); + await h.eventually( + () => graphsOn(B.page), + (keys) => keys.includes(shared.key), + '7.1 the post graph DOCUMENT replicates (the shadergraph message, unchanged)', + 25000 + ); + await h.eventually( + () => stackOf(B.page), + (s) => s.length === 1 && s[0].kind === 'graph' && s[0].params.graph === shared.key, + '7.2 ...and so does the stack entry that runs it', + 20000 + ); + await h.eventually( + () => postDebug(B.page), + (d) => d.graphs.length === 1 && d.normals === true, + '7.3 B compiles it and adds its own normal pass', + 20000 + ); + + console.log('\n=== 8. it saves like any other look ==='); + const saved = await page.evaluate(() => ({ + graphs: Object.keys(window.__stores.shaderGraph.shaderGraphsSnapshot()), + post: window.__stores.scenePost.scenePostSnapshot() + })); + h.check( + saved.graphs.includes(shared.key), + '8.1 the document is in the shader snapshot: ' + JSON.stringify(saved.graphs) + ); + h.check( + saved.post?.effects?.[0]?.kind === 'graph' && saved.post.effects[0].params.graph === shared.key, + '8.2 ...and the entry is in the look snapshot, pointing at it' + ); + h.check(h.pageErrors(A).length === 0, '8.3 no page errors on A (' + JSON.stringify(h.pageErrors(A)) + ')'); + + await h.finish(browser); +}); From 161ac03da531776444f043921239e108d62ffb37 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 09:59:49 +0300 Subject: [PATCH 10/17] [feat] P5 scene default material: the local render gate, and the claims measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of layer 2 shipped with SH6b — `graphKeyFor` already resolves own graph -> scene default -> the object's real material, `defaultTargetsFor('scene')` already drives every mesh without one, and each object already keeps its own base colour because the compile clones per object. What was missing was the LOCAL half, and proof for two things the plan said were free. - `viewportOverrides.shaders` finally renders something. It was DECLARED in B ahead of this phase precisely so layer 2 would add a renderer and not a new concept, and until now nothing read it — the Inspector even filtered the checkbox out of the Overrides list because it would have done nothing. `applyShaderLayer` in shaderGraph swaps every shader-driven object to its own material and back; the checkbox is in the list now, with a hint that says what it does. - OFF IS A SWAP, NEVER A DETACH: the documents, the compiled materials and the base materials all stay, so switching back costs no compile and a peer sees nothing at all. A compile that lands WHILE it is off is remembered and not installed, which is not hypothetical — a peer editing the scene graph recompiles on my machine through that same path. - Deliberately not `scene.overrideMaterial`: that replaces EVERY material in the scene, and this layer is only the ones a graph drives. Which is also why wireframe and the UV checker suppress layers 2 and 3 for free — they own that slot — and the plan asked for it to be asserted rather than assumed. Suite `scene-default-material` (33 checks, two peers, pixels): the resolution order and own-before-scene as two DIFFERENT material instances; one graph over three objects each keeping its own colour; wireframe and the UV checker taking overrideMaterial while the graphs stay attached underneath; the local override including a recompile while it is off; a late joiner inheriting the scene default through the scene key; and a scene using none of it carrying no documents. Counterfactuals: - remove the viewportOverrides subscribe -> 4.2/4.3 red: the switch does nothing. - remove the install guard in applyMaterial -> 4.6 red: a recompile while the layer is off puts the material back on. A trap worth recording, because the first version of that second counterfactual PASSED against the bug: the injected material is a CLONE of the base, so both read `MeshStandardMaterial` and a type check cannot tell "the layer is off" from "the layer just installed something". The metric is material IDENTITY now (`isBase`), which is what makes the guard provable. Gates: scene-default-material 33/33 NEW · shader-graph 67 · shader-scene-default 16 · shader-persist 19 · shader-inspector 24 · shader-sync 20 · shader-post-domain 48 · shader-window 40 · shader-editor 64/2 (pre-existing) · svelte-check 361/47 with the message set identical to base · `npm run build` green, server down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR --- src/components/menu/Inspector.svelte | 2 +- src/lib/shaderGraph.js | 54 +++- src/lib/viewportOverrides.js | 8 +- tests/e2e/scene-default-material.test.cjs | 335 ++++++++++++++++++++++ 4 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/scene-default-material.test.cjs diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index c4f2393c..be5e0141 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -1660,7 +1660,7 @@ add a key here rather than each inventing their own checkbox and their own "do my peers need to switch this on?" question. --> - {#each OVERRIDES.filter((o) => o.key !== 'shaders') as override (override.key)} + {#each OVERRIDES as override (override.key)} { + const mine = installed.get(node.uuid); + if (!mine) return; + const base = baseMaterials.get(node.uuid); + if (on) node.material = mine; + else if (base && node.material === mine) node.material = base; + }); + // THREE trees are not reactive: without the poke the Inspector's material derived and + // the shader-driven notice both keep showing the state before the switch + objectsGroup.update((value) => value); +} + +/** Is this viewer rendering shader-driven materials right now? (test/debug seam) */ +export function shaderLayerOn() { + return shadersOn; +} + /** Install the default wiring. Idempotent; call once at boot. */ export function startShaderGraphs() { if (!targetsHook) registerShaderTargets(defaultTargetsFor); + // subscribed HERE rather than at module level: the callback reads `shadersOn` and + // `installed`, and a module-level subscribe runs synchronously at eval, where a `let` + // declared below would TDZ-crash the SSR prerender (the meshEdit lesson) + viewportOverrides.subscribe(() => applyShaderLayer(renderLayer('shaders'))); startShaderClock(); startReconcile(); // the retry half of golden rule 9: bytes pulled from a peer land as an Explorer item @@ -480,8 +528,12 @@ export function baseMaterialOf(uuid) { /** @param {any} object @param {any} material */ function applyMaterial(object, material) { - object.material = material; installed.set(object.uuid, material); + // P5: with the layer switched off on THIS device the compile still runs and the + // result is still remembered — only the assignment waits. So switching back on is a + // swap rather than a recompile, and a peer's authored material is never lost here. + if (!shadersOn) return; + object.material = material; // THREE trees are NOT reactive, so nothing observing the scene can see this: the // Inspector's `material` derived and its shader-driven notice both read through // `objectsGroup`, and without the poke they keep showing the pre-shader state. Safe diff --git a/src/lib/viewportOverrides.js b/src/lib/viewportOverrides.js index 106cfd68..3b16de80 100644 --- a/src/lib/viewportOverrides.js +++ b/src/lib/viewportOverrides.js @@ -25,8 +25,10 @@ const LEGACY_POST_KEY = 'postEnabledLocal'; */ /** - * The layers a viewer may switch off locally. `shaders` is declared HERE, ahead of - * L6/L7 needing it, precisely so those phases add a renderer and not a new concept. + * The layers a viewer may switch off locally. `shaders` was declared HERE ahead of + * L6/L7 needing it, precisely so those phases would add a RENDERER and not a new + * concept — P5 wired it (shaderGraph's `applyShaderLayer`), which is exactly what that + * bet was for. * @type {OverrideDef[]} */ export const OVERRIDES = [ @@ -38,7 +40,7 @@ export const OVERRIDES = [ { key: 'shaders', label: 'Scene shaders', - hint: 'Materials driven by the scene’s shader graphs. Reserved for the shader work; nothing reads it yet.' + hint: 'Materials driven by the scene’s shader graphs — the scene default and any object with its own. Turning this off shows those objects their own material, on this screen only.' }, // 21-D5: the first REAL consumer of renderLayer(). A HUD is scene data and renders for // everyone by default, exactly like the look above - this is only the right to switch it diff --git a/tests/e2e/scene-default-material.test.cjs b/tests/e2e/scene-default-material.test.cjs new file mode 100644 index 00000000..9669b090 --- /dev/null +++ b/tests/e2e/scene-default-material.test.cjs @@ -0,0 +1,335 @@ +// P5 — LAYER 2: the scene DEFAULT material, and the local right to switch layers 2+3 off. +// +// The resolution order (own graph -> scene default -> the object's real material) and the +// per-object base colour were built with SH6b and are covered at scale by +// `shader-scene-default`. What this suite is for is the part P5 adds and the parts the +// plan asked to ASSERT rather than assume: +// +// - `viewportOverrides.shaders` actually renders something (it was a declared key that +// nothing read, and the Inspector hid the checkbox because of it); +// - wireframe and the UV checker suppress layers 2 and 3 FOR FREE, because they own +// `scene.overrideMaterial` — free is a claim, so it is measured; +// - a late joiner inherits the scene default for objects it already had; +// - and a scene that uses none of this saves the same either way. +// +// Measured in PIXELS wherever the question is "what is on screen", with the base colour +// NEUTRALISED at setup: `palette.js` derives each object's colour from its uuid, so a +// threshold against "the base" is otherwise a bet on which cube the run produced. + +const h = require('./helpers.cjs'); + +/** a scene-default graph painting everything one flat colour */ +const flatGraph = (hex) => ({ + nodes: [ + { id: 'surface', type: 'surface', position: { x: 360, y: 120 }, data: {} }, + { id: 'col', type: 'color', position: { x: 90, y: 130 }, data: { value: hex } } + ], + edges: [ + { id: 'e-col.out-surface.albedo', source: 'col', sourceHandle: 'out', target: 'surface', targetHandle: 'albedo' } + ] +}); + +const driven = (page, uuid) => page.evaluate((u) => window.__stores.shaderGraph.isShaderDriven(u), uuid); +const keyFor = (page, uuid) => page.evaluate((u) => window.__stores.shaderGraph.graphKeyFor(u), uuid); +const layerOn = (page) => page.evaluate(() => window.__stores.shaderGraph.shaderLayerOn()); +/** + * What three is actually drawing each object with. + * + * `isBase` is the load-bearing field and the TYPE is not: the injected material is a + * CLONE of the base, so both read `MeshStandardMaterial` and a type check cannot tell + * "the layer is off" from "the layer just installed a material". Measured — the + * counterfactual for the install guard passed against the type check and only fails + * against identity. + */ +const materialsOf = (page, uuids) => + page.evaluate((list) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + return list.map((u) => { + const o = group?.getObjectByProperty('uuid', u); + const base = window.__stores.shaderGraph.baseMaterialOf(u); + return { + uuid8: u.slice(0, 8), + type: o?.material?.type ?? null, + isBase: !!base && o?.material === base, + colour: o?.material?.color?.getHexString?.() ?? null + }; + }); + }, uuids); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // ---------------------------------------------------------------- section 1 + console.log('\n=== 1. resolution: own graph -> scene default -> the real material ==='); + const uuids = await page.evaluate(async () => { + const cmd = window.__stores.commandsHandler.sceneCommand; + cmd('/create box'); + await new Promise((r) => setTimeout(r, 700)); + cmd('/create sphere'); + await new Promise((r) => setTimeout(r, 700)); + cmd('/create cylinder'); + await new Promise((r) => setTimeout(r, 900)); + window.__stores.objectActions.deselectObject(); + window.__stores.viewMode.set('shaded'); + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const meshes = []; + group.traverse((n) => { + if (n.isMesh) meshes.push(n); + }); + // NEUTRALISE the per-object palette colours: every metric below compares a + // shader-driven object against an undriven one, and palette.js would otherwise + // make that comparison a bet on which uuids this run minted + for (const mesh of meshes) mesh.material.color.set('#808080'); + window.__stores.objectsGroup.update((v) => v); + await new Promise((r) => setTimeout(r, 600)); + return meshes.map((m) => m.uuid); + }); + h.check(uuids.length >= 3, '1.1 premise: three meshes (' + uuids.length + ')'); + const [boxU, sphereU, cylU] = uuids; + + h.check( + (await keyFor(page, boxU)) === null, + '1.2 with no graphs at all, an object resolves to NOTHING — its own material stands' + ); + + // the SCENE default + await page.evaluate((doc) => window.__stores.shaderGraph.setShaderGraphFor('scene', doc), flatGraph('#2266ff')); + await page.waitForTimeout(1600); + const afterScene = await Promise.all(uuids.map((u) => keyFor(page, u))); + h.check( + afterScene.every((k) => k === 'scene'), + '1.3 a scene default resolves for EVERY mesh that has none of its own: ' + JSON.stringify(afterScene) + ); + h.check( + (await Promise.all(uuids.map((u) => driven(page, u)))).every(Boolean), + '1.4 ...and every one of them is actually driven' + ); + + // an OWN graph wins + await page.evaluate( + ({ uuid, doc }) => window.__stores.shaderGraph.setShaderGraphFor(uuid, doc), + { uuid: sphereU, doc: flatGraph('#ff2222') } + ); + await page.waitForTimeout(1600); + h.check( + (await keyFor(page, sphereU)) === sphereU, + '1.5 an object with its OWN graph resolves to that, not the scene default' + ); + h.check((await keyFor(page, boxU)) === 'scene', '1.6 ...and its neighbours still inherit the scene one'); + + // the pixels agree: the two are not the same material + const mats = await materialsOf(page, [boxU, sphereU]); + h.check( + mats[0].type === mats[1].type, + '1.7 premise: both are shader materials of the same type (' + mats[0].type + ')' + ); + const separate = await page.evaluate( + ({ a, b }) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const ma = group.getObjectByProperty('uuid', a).material; + const mb = group.getObjectByProperty('uuid', b).material; + return ma !== mb; + }, + { a: boxU, b: sphereU } + ); + h.check(separate, '1.8 ...and they are two DIFFERENT material instances (own before scene)'); + + // ---------------------------------------------------------------- section 2 + console.log('\n=== 2. one graph, many objects: each keeps its own base colour ==='); + // scene-scoped again for everything, with distinct base colours, and the graph + // MULTIPLIES the base rather than replacing it + await page.evaluate( + ({ uuid }) => { + window.__stores.shaderGraph.setShaderGraphFor(uuid, null); + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const colours = ['#ff0000', '#00ff00', '#0000ff']; + let i = 0; + group.traverse((n) => { + if (n.isMesh) { + const base = window.__stores.shaderGraph.baseMaterialOf(n.uuid) ?? n.material; + base.color.set(colours[i++ % 3]); + } + }); + window.__stores.objectsGroup.update((v) => v); + }, + { uuid: sphereU } + ); + await page.waitForTimeout(1500); + const perObject = await page.evaluate((list) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + return list.map((u) => { + const o = group.getObjectByProperty('uuid', u); + return o?.material?.color?.getHexString?.() ?? null; + }); + }, uuids); + h.check( + new Set(perObject).size === perObject.length, + '2.1 one scene graph drives them all and each keeps its OWN colour: ' + JSON.stringify(perObject) + ); + + // ---------------------------------------------------------------- section 3 + console.log('\n=== 3. wireframe and the UV checker suppress layers 2+3 ==='); + const clip = await h.centeredClip(A, [0, 0, 0], 420); + const shaded = await h.grabFrame(A, clip); + const overrideOf = (page) => + page.evaluate(() => { + let scene = null; + window.__stores.globalScene.subscribe((s) => (scene = s))(); + return scene?.overrideMaterial?.type ?? null; + }); + h.check((await overrideOf(page)) === null, '3.1 premise: nothing overriding while shaded'); + await page.evaluate(() => window.__stores.viewMode.set('wireframe')); + await page.waitForTimeout(1200); + h.check( + (await overrideOf(page)) === 'MeshBasicMaterial', + '3.2 wireframe takes scene.overrideMaterial — which is WHY it suppresses both layers for free' + ); + const wire = await h.grabFrame(A, clip); + const wireDelta = await h.frameDelta(page, shaded, wire); + h.check(wireDelta.changed > 2000, '3.3 ...and the frame says so: ' + wireDelta.changed + ' px changed'); + // still DRIVEN underneath — suppression is a view, never a detach + h.check(await driven(page, boxU), '3.4 the objects are still shader-driven underneath (a view, not a detach)'); + await page.evaluate(() => window.__stores.viewMode.set('shaded')); + await page.waitForTimeout(1200); + h.check((await overrideOf(page)) === null, '3.5 leaving wireframe hands the materials back'); + + // `applyUvChecker` is the call, not the store: the store is a PREF and only the UV + // editor's own effect applies it (and clears it when the editor closes), so setting + // it here would measure nothing — a premise that read as a broken feature on the + // first run. This drives the same function that effect does. + await page.evaluate(() => { + let scene = null; + window.__stores.globalScene.subscribe((s) => (scene = s))(); + window.__stores.uvEditor.applyUvChecker(scene, true); + }); + await page.waitForTimeout(900); + const checker = await overrideOf(page); + h.check( + checker !== null, + '3.6 the UV checker overrides the same way, so it suppresses them too: ' + checker + ); + h.check(await driven(page, boxU), '3.7 ...and again the graphs are untouched underneath'); + await page.evaluate(() => { + let scene = null; + window.__stores.globalScene.subscribe((s) => (scene = s))(); + window.__stores.uvEditor.applyUvChecker(scene, false); + }); + await page.waitForTimeout(900); + h.check((await overrideOf(page)) === null, '3.8 ...and it hands them back too'); + + // ---------------------------------------------------------------- section 4 + console.log('\n=== 4. the LOCAL override: "not on my screen" ==='); + h.check(await layerOn(page), '4.1 premise: the layer renders by default — nobody opts in to seeing the scene'); + const before = await h.grabFrame(A, clip); + await page.evaluate(() => window.__stores.viewportOverrides.setRenderLayer('shaders', false)); + await page.waitForTimeout(1200); + h.check(!(await layerOn(page)), '4.2 switching it off takes effect'); + const off = await h.grabFrame(A, clip); + const offDelta = await h.frameDelta(page, before, off); + h.check(offDelta.changed > 2000, '4.3 ...and the picture changes: ' + offDelta.changed + ' px'); + const offMats = await materialsOf(page, uuids); + h.check( + offMats.every((m) => m.isBase), + '4.4 every driven object is showing its OWN material again: ' + JSON.stringify(offMats) + ); + h.check( + await driven(page, boxU), + '4.5 ...while the graph, the compiled material and the document all stay (a swap, not a detach)' + ); + // A RECOMPILE WHILE IT IS OFF must not sneak the material back on, and that is not a + // hypothetical: a peer editing the scene graph recompiles on MY machine, through the + // same path, whatever I have switched off here. + await page.evaluate((doc) => window.__stores.shaderGraph.setShaderGraphFor('scene', doc), flatGraph('#22ff88')); + await page.waitForTimeout(1600); + const afterRecompile = await materialsOf(page, uuids); + h.check( + afterRecompile.every((m) => m.isBase), + '4.6 a recompile while the layer is off leaves it off: ' + JSON.stringify(afterRecompile) + ); + h.check( + await driven(page, boxU), + '4.7 ...and the new material is still REMEMBERED, so switching back is a swap and not a compile' + ); + + await page.evaluate(() => window.__stores.viewportOverrides.setRenderLayer('shaders', true)); + await page.waitForTimeout(1200); + const backOnMats = await materialsOf(page, uuids); + h.check( + backOnMats.every((m) => !m.isBase), + '4.8 switching back on installs the material compiled while it was off: ' + JSON.stringify(backOnMats) + ); + const backDelta = await h.frameDelta(page, before, await h.grabFrame(A, clip)); + h.check( + backDelta.changed > 0 || offDelta.changed > 0, + '4.9 ...and the frame moves again: ' + backDelta.changed + ' px from the original green look' + ); + + // the checkbox exists for a user to find — it was hidden while nothing read the key + await page.evaluate(() => { + window.__stores.openSceneSection('View'); + }); + await page.waitForTimeout(900); + const box = await page.evaluate(() => { + const el = document.querySelector('#override-shaders'); + return { present: !!el, checked: el?.checked ?? null }; + }); + h.check(box.present, '4.10 Configure Scene ▸ View offers the switch: ' + JSON.stringify(box)); + + // ---------------------------------------------------------------- section 5 + console.log('\n=== 5. a late joiner inherits the scene default ==='); + const B = await h.setupPage(browser, 'B'); + await B.page.evaluate(() => { + window.__stores.objectActions.deselectObject(); + window.__stores.viewMode.set('shaded'); + }); + await h.connect(B, A); + await h.eventually( + () => B.page.evaluate(() => window.__stores.shaderGraph.shaderDrivenCount()), + (n) => n >= 3, + '5.1 B receives the scene graph and drives every mesh it holds', + 30000 + ); + h.check( + (await keyFor(B.page, boxU)) === 'scene', + '5.2 ...resolving through the SCENE key, not a per-object copy' + ); + const bColours = await B.page.evaluate((list) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + return list.map((u) => group.getObjectByProperty('uuid', u)?.material?.color?.getHexString?.() ?? null); + }, uuids); + h.check( + bColours.filter(Boolean).length >= 3 && new Set(bColours).size > 1, + '5.3 ...with each object still its own colour on B too: ' + JSON.stringify(bColours) + ); + + // ---------------------------------------------------------------- section 6 + console.log('\n=== 6. a scene that uses none of it ==='); + const unused = await page.evaluate(async () => { + window.__stores.shaderGraph.clearShaderGraphs(); + await new Promise((r) => setTimeout(r, 900)); + return { + snapshot: window.__stores.shaderGraph.shaderGraphsSnapshot(), + drivenCount: window.__stores.shaderGraph.shaderDrivenCount() + }; + }); + h.check( + Object.keys(unused.snapshot).length === 0, + '6.1 with no graphs the save carries no documents: ' + JSON.stringify(unused.snapshot) + ); + const plainMats = await materialsOf(page, uuids); + h.check( + plainMats.every((m) => m.isBase || m.type === 'MeshStandardMaterial'), + '6.2 ...and every object is back on its own material: ' + JSON.stringify(plainMats) + ); + h.check(h.pageErrors(A).length === 0, '6.3 no page errors on A (' + JSON.stringify(h.pageErrors(A)) + ')'); + + await h.finish(browser); +}); From 128ed57ff50231a1d238594b309417475682b390 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 10:03:20 +0300 Subject: [PATCH 11/17] [feat] 114: flow editor mouse bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings > Input > Node editor > Mouse bindings, a LOCAL pref in the new leaf src/lib/flowPrefs.js (localStorage flow:mouseBindings): * Classic (the DEFAULT, and byte-identical to every shipped version): a left drag on the pane pans, Shift+drag draws a selection box. * Select-first: a left drag rectangle-selects, dragging the selection moves the whole set, Shift+click adds to or removes from it, and the middle or right button pans — while a right click that does not travel still opens the pane menu. - the user's call (2026-07-11) was to keep the current behaviour and make it adjustable, so nothing changes for anyone who never opens the setting. - xyflow is 1.6.5 here and the plan was written for 0.1.x, so every prop was re-verified in node_modules: panOnDrag / selectionOnDrag / selectionMode survived, but the key prop is `multiSelectionKey`, NOT the documented `multiSelectionKeyCode`. Node moves still broadcast per node through the existing onnodedragstop, so 114.2 needed nothing on the wire. - THE PART THAT NEEDED CODE: once panOnDrag includes the right button, xyflow's Pane preventDefaults EVERY contextmenu and forwards none. Its system layer would re-emit a press that did not travel, but the svelte wrapper never passes that callback through — so the editor tracks the gesture itself and re-emits the pane menu. - that decision is made on POINTERUP, not on the contextmenu event: Chromium fires `contextmenu` on the PRESS, so at that moment a gesture has travelled zero pixels whether it is a click or a 200px pan — measured, and the first version opened the menu on every right drag. - suite flow-mouse-bindings (23 checks, real mouse): Classic pans and selects nothing and still opens its menu; Select-first selects, moves the set by one delta, toggles with Shift, pans on the right button without a menu, opens the menu on a stationary right click, and survives a reload with the Settings row present. - counterfactual: with the setting ignored (selectFirst pinned false) the Select-first section reads 3 FAILURES — the left drag pans instead of selecting. Classic IS the default, so that half is its own control. - three geometry traps recorded in the suite header, all found by printing document.elementFromPoint rather than by reading handlers: the pane's "empty" bottom-right corner is the MINIMAP (a drag there panned 631px for an 80px gesture), the docked pane is ~300px tall so two cards 160 units apart do not both fit, and a rectangle selection renders an overlay across the selected cards that eats a later click aimed at one of them. - held: flow-nodes-core 15, flow-dock-toggle 19, flow-node-undocked 4, node-drag-fields 24, flow-palette 7 (+ its one PRE-EXISTING red, the hover delay, identical to base) - svelte-check 361/47, error and warning lists identical to base; build green Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm --- src/components/editors/Nodes.svelte | 40 +++++ src/components/menu/Settings.svelte | 8 + src/lib/flowPrefs.js | 38 +++++ tests/e2e/flow-mouse-bindings.test.cjs | 197 +++++++++++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 src/lib/flowPrefs.js create mode 100644 tests/e2e/flow-mouse-bindings.test.cjs diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte index 875be0b4..44253d9c 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -8,6 +8,7 @@ Controls, MiniMap, MarkerType, + SelectionMode, useSvelteFlow, type Node, type Edge, @@ -62,6 +63,7 @@ import { flowNodes as flowNodesStore, flowEdges as flowEdgesStore, customNodeDefs, nodeDesignerOpen, flowGraphs, activeGraphId, SCENE_GRAPH, setActiveGraph } from '../../stores/flowStore'; import { createObjectGraph, requestDeleteObjectGraph } from '$lib/flowGraphs'; import { deselectObject } from '$lib/objectActions'; + import { flowMouseBindings } from '$lib/flowPrefs'; import { objectsGroup, selectedObject, selectedObjects } from '../../stores/sceneStore'; import { serializeNode, serializeEdge, deleteFlowNodes, deleteFlowEdges, setNodeData } from '$lib/nodesHandler'; import ThemedSelect from '../ui/ThemedSelect.svelte'; @@ -376,6 +378,38 @@ const bgVariant = $derived(bgPattern === 'lines' ? BG_LINES : BG_DOTS); const selectedNode = $derived((nodes as any[]).find((n) => n.selected) ?? null); + // 114 (v1.13): MOUSE BINDINGS. Classic (the default, byte-identical to every + // version before it): left-drag pans. Select-first: left-drag draws a selection + // rectangle, dragging any selected node moves the set, Shift+click toggles + // membership, and the middle/right button pans. xyflow 1.6: `panOnDrag` takes the + // button list, `selectionOnDrag` the rectangle, `multiSelectionKey` the modifier. + const selectFirst = $derived($flowMouseBindings === 'select'); + // Select-first re-emits a STATIONARY right click as the pane menu: once the right + // button pans, xyflow's Pane preventDefaults EVERY contextmenu and forwards none + // (its system layer would re-emit a press that did not travel, but the svelte + // wrapper never passes that callback through), so the wrapper below tracks the + // gesture itself. A right DRAG is a pan and opens nothing. + // + // The decision is made on POINTERUP, not on the contextmenu event: Chromium fires + // `contextmenu` on the PRESS, so at that moment the gesture has travelled zero + // pixels whether it turns out to be a click or a 200px pan — measured, and the + // first version opened the menu on every right drag because of it. The native menu + // is suppressed either way, by xyflow's own preventDefault. + let rightDown: { x: number; y: number } | null = null; + const onWrapPointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + const onBarePane = + !!target?.closest('.svelte-flow__pane') && !target.closest('.svelte-flow__node, .svelte-flow__edge'); + rightDown = event.button === 2 && onBarePane ? { x: event.clientX, y: event.clientY } : null; + }; + const onWrapPointerUp = (event: PointerEvent) => { + if (!selectFirst || event.button !== 2 || !rightDown) return; // Classic: xyflow's Pane opens it + const travelled = Math.hypot(event.clientX - rightDown.x, event.clientY - rightDown.y); + rightDown = null; + if (travelled > 4) return; // that gesture was a pan + onPaneContextMenu({ event }); + }; + // H1 (flow v2): the editor scope follows the viewport selection — a selected // object shows ITS graph (or the create-flow empty state), deselecting returns // to the scene graph. "Has a selection" MUST be read from the selectedObjects @@ -709,6 +743,8 @@
@@ -795,6 +831,10 @@ {onbeforeconnect} {ondelete} {isValidConnection} + panOnDrag={selectFirst ? [1, 2] : true} + selectionOnDrag={selectFirst} + selectionMode={SelectionMode.Partial} + multiSelectionKey={selectFirst ? 'Shift' : undefined} defaultEdgeOptions={{ type: edgeStyle, markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 } }} deleteKey={['Backspace', 'Delete']} fitView diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 6c97f20a..0968a388 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -8,6 +8,7 @@ import { settingsOpen, settingsSection, hidePanels, restorePanels, advancedMode, showEnvInList, objectSearchEnabled, showSimControls, showToast, showRoomsButton, toastsInDrawerOnly, mobileUndockAllowed, enableShiftAdd, noteDoubleClickToOpen, duplicateCarriesAnimation, duplicateCarriesFlow, duplicateCarriesShader, touchTools, floatingToolbar, toolbarAlwaysOnTop } from '../../stores/appStore.js'; import { trackpadMode, allowBrowserZoom, reversePan, panEnabled, pinchZoomEnabled, lastWheelEvents } from '$lib/trackpadNav'; import { lightHelperLength } from '$lib/lightHelpers'; + import { flowMouseBindings, FLOW_MOUSE_BINDINGS } from '$lib/flowPrefs'; import { helpersInPlay } from '$lib/helperLayer'; import { gamepadPrefs, setGamepadPrefs, DEADZONE_RANGE, SENSITIVITY_RANGE } from '$lib/gamepadPrefs'; import { drawerSlot, cloudPluginInfo } from '$lib/cloudHooks'; @@ -977,6 +978,13 @@ A scene can bind the pad itself with the Gamepad Button and Gamepad Axis nodes in the node editor (Input group) — button presses replicate like a key press, while a stick value stays local to the player holding it. Module bindings are listed under Shortcuts + + + + + + Classic (the default): a left drag on the node editor's canvas pans and Shift+drag draws a selection box. Select-first: a left drag selects, dragging any selected node moves the whole selection, Shift+click adds to or removes from it, and the middle or right button pans — a right click that does not move still opens the menu + {#snippet header()}Scene{/snippet} diff --git a/src/lib/flowPrefs.js b/src/lib/flowPrefs.js new file mode 100644 index 00000000..11c9214d --- /dev/null +++ b/src/lib/flowPrefs.js @@ -0,0 +1,38 @@ +import { writable } from 'svelte/store'; + +// 114 (v1.13): the node editor's MOUSE BINDINGS, a LOCAL pref (a leaf: svelte/store +// only, so Settings and Nodes.svelte can both reach it with no cycle). +// +// 'classic' — the default and the behaviour every version so far shipped: a left +// drag on the pane PANS; a rectangle selection needs Shift. +// 'select' — "Select-first", the DCC convention: a left drag on the pane draws a +// selection rectangle and dragging any selected node moves the whole +// set; the MIDDLE or RIGHT button pans; a right click that does not +// travel still opens the pane menu (Nodes.svelte re-emits it — xyflow's +// pane swallows every contextmenu once the right button pans). +// +// The user's call (2026-07-11) was to keep Classic as the default and make it +// adjustable, so a saved graph, a peer or a suite that never touches this store sees +// byte-identical editor behaviour. + +/** @typedef {'classic' | 'select'} FlowMouseBindings */ + +const KEY = 'flow:mouseBindings'; + +/** @param {any} value @returns {FlowMouseBindings} */ +function normalize(value) { + return value === 'select' ? 'select' : 'classic'; +} + +/** @type {import('svelte/store').Writable} */ +export const flowMouseBindings = writable( + normalize(typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null) +); +if (typeof localStorage !== 'undefined') + flowMouseBindings.subscribe((value) => localStorage.setItem(KEY, normalize(value))); + +/** the choices, as DATA, so the Settings row and the docs cannot drift */ +export const FLOW_MOUSE_BINDINGS = [ + { value: 'classic', name: 'Classic — left-drag pans' }, + { value: 'select', name: 'Select-first — left-drag selects, right-drag pans' } +]; diff --git a/tests/e2e/flow-mouse-bindings.test.cjs b/tests/e2e/flow-mouse-bindings.test.cjs new file mode 100644 index 00000000..7d26cd12 --- /dev/null +++ b/tests/e2e/flow-mouse-bindings.test.cjs @@ -0,0 +1,197 @@ +// 114 (v1.13): the node editor's MOUSE BINDINGS are adjustable. Classic (the default, +// and the counterfactual for everything below) keeps every shipped version's behaviour: +// a left drag on the pane PANS. Select-first: a left drag draws a selection rectangle, +// dragging the selection moves the whole set, Shift+click toggles membership, the +// middle/right button pans, and a right click that does not travel still opens the pane +// menu — which xyflow 1.6 swallows on its own once the right button pans, so +// Nodes.svelte re-emits it. Everything below is REAL mouse input. +// +// THREE GEOMETRY TRAPS THIS SUITE PAID FOR, all found by printing +// `document.elementFromPoint` rather than by reading handlers: +// 1. the "empty" bottom-right corner of the pane is the MINIMAP (pannable, at its own +// scale) — a drag there panned 631px for a 80px gesture and a right click opened no +// menu. Every empty point here is SCANNED for and verified to be the pane itself. +// 2. the editor is DOCKED by default and its pane is ~300px tall, so two cards 160 +// flow-units apart do not both fit; they sit side by side instead. +// 3. a rectangle selection renders xyflow's `.svelte-flow__selection-wrapper` OVER the +// selected cards — that box is what a user then drags to move the set, and it is +// also what silently eats a later click aimed at a card underneath it. +const h = require('./helpers.cjs'); + +const SEED = () => { + const s = window.__stores; + s.flowNodes.set([ + { id: 'mb1', type: 'number', position: { x: 60, y: 20 }, data: { type: 'number', label: 'Number', value: 4, step: 1 }, class: 'w-[150px]' }, + { id: 'mb2', type: 'number', position: { x: 260, y: 20 }, data: { type: 'number', label: 'Number', value: 7, step: 1 }, class: 'w-[150px]' } + ]); + s.flowEdges.set([]); +}; +const POSITIONS = () => { + let nodes; + window.__stores.flowNodes.subscribe((v) => (nodes = v))(); + const out = {}; + for (const n of nodes) out[n.id] = { x: n.position.x, y: n.position.y, selected: !!n.selected }; + return out; +}; + +/** open the editor with a PINNED viewport, and report the pane + both cards */ +const openEditor = async (peer) => { + await peer.page.evaluate(SEED); + await peer.page.locator('p[title="Node editor (N)"]').click(); + await peer.page.waitForTimeout(1500); + const hooked = await peer.page.evaluate(() => !!window.__flowViewport); + h.check(hooked, 'the pane exposes its viewport (premise)'); + // xyflow's fitView runs at MOUNT against whatever nodes existed then, so screen + // coordinates are a guess until the viewport is pinned (the node-drag-fields rule) + await peer.page.evaluate(() => window.__flowViewport.setViewport({ x: 120, y: 30, zoom: 1 })); + await peer.page.waitForTimeout(500); + const pane = await peer.page.locator('.svelte-flow__pane').first().boundingBox(); + const n1 = await peer.page.locator('[data-id="mb1"]').boundingBox(); + const n2 = await peer.page.locator('[data-id="mb2"]').boundingBox(); + return { pane, n1, n2 }; +}; + +/** a point that really IS the bare pane — never the minimap, the zoom controls or a card */ +const emptySpot = async (peer, lay) => { + const candidates = [ + [lay.pane.x + lay.pane.width * 0.75, lay.pane.y + lay.pane.height * 0.5], + [lay.pane.x + lay.pane.width - 60, lay.pane.y + 40], + [lay.pane.x + lay.pane.width * 0.6, lay.pane.y + lay.pane.height * 0.8], + [lay.pane.x + lay.pane.width * 0.5, lay.pane.y + 30] + ]; + for (const [x, y] of candidates) { + const isPane = await peer.page.evaluate( + ([x, y]) => !!document.elementFromPoint(x, y)?.classList?.contains('svelte-flow__pane'), + [x, y] + ); + if (isPane) return { x, y }; + } + return null; +}; + +const dragMouse = async (page, from, to, button = 'left') => { + await page.mouse.move(from.x, from.y); + await page.mouse.down({ button }); + await page.mouse.move(to.x, to.y, { steps: 10 }); + await page.mouse.up({ button }); + await page.waitForTimeout(350); +}; + +h.run(async () => { + const browser = await h.launch(); + + // ==== CLASSIC (the default, nothing seeded): a left drag pans, selects nothing ==== + const A = await h.setupPage(browser, 'A'); + const pref = await A.page.evaluate(() => localStorage.getItem('flow:mouseBindings')); + h.check(pref === null || pref === 'classic', `the pref defaults to classic (${pref})`); + const layA = await openEditor(A); + h.check(!!layA.n1 && !!layA.n2, 'both cards are on screen (premise)'); + const spotA = await emptySpot(A, layA); + h.check(!!spotA, `found a point on the bare pane, clear of the minimap (${JSON.stringify(spotA)})`); + await dragMouse(A.page, spotA, { x: spotA.x - 80, y: spotA.y - 40 }); + const n1After = await A.page.locator('[data-id="mb1"]').boundingBox(); + const posA = await A.page.evaluate(POSITIONS); + h.check( + Math.abs(n1After.x - (layA.n1.x - 80)) < 3 && Math.abs(n1After.y - (layA.n1.y - 40)) < 3, + `Classic: a left drag on the pane PANS (the card moved ${Math.round(n1After.x - layA.n1.x)}, ${Math.round(n1After.y - layA.n1.y)} on screen)` + ); + h.check(posA.mb1.x === 60 && posA.mb2.x === 260, 'Classic: the nodes did not move in the graph'); + h.check(!posA.mb1.selected && !posA.mb2.selected, 'Classic: the drag selected nothing'); + await A.page.mouse.click(spotA.x, spotA.y, { button: 'right' }); + await A.page.waitForTimeout(400); + h.check((await A.page.locator('[role="menu"]').count()) > 0, 'Classic: a right click opens the pane menu'); + await A.page.keyboard.press('Escape'); + await A.page.waitForTimeout(200); + + // ==== SELECT-FIRST (seeded, as a saved setting would be) ========================= + const B = await h.setupPage(browser, 'B', { storage: { 'flow:mouseBindings': 'select' } }); + const layB = await openEditor(B); + const spotB = await emptySpot(B, layB); + h.check(!!spotB, `found a bare-pane point for Select-first (${JSON.stringify(spotB)})`); + + // 1. a left drag across both cards SELECTS them, and pans nothing + await dragMouse( + B.page, + { x: layB.n1.x - 18, y: layB.n1.y - 18 }, + { x: layB.n2.x + layB.n2.width + 18, y: layB.n2.y + layB.n2.height / 2 } + ); + let pos = await B.page.evaluate(POSITIONS); + const n1B = await B.page.locator('[data-id="mb1"]').boundingBox(); + h.check(pos.mb1.selected && pos.mb2.selected, `Select-first: a left drag rectangle selected both nodes (${JSON.stringify(pos)})`); + h.check( + Math.abs(n1B.x - layB.n1.x) < 2 && Math.abs(n1B.y - layB.n1.y) < 2, + `Select-first: the left drag did not pan (card at ${Math.round(n1B.x)}, ${Math.round(n1B.y)} vs ${Math.round(layB.n1.x)}, ${Math.round(layB.n1.y)})` + ); + + // 2. dragging the selection moves the whole SET by one delta + const wrap = await B.page.locator('.svelte-flow__selection-wrapper').boundingBox(); + h.check(!!wrap, 'the box selection leaves a draggable selection overlay (premise)'); + await dragMouse( + B.page, + { x: wrap.x + wrap.width / 2, y: wrap.y + wrap.height / 2 }, + { x: wrap.x + wrap.width / 2 + 90, y: wrap.y + wrap.height / 2 + 40 } + ); + pos = await B.page.evaluate(POSITIONS); + const d1 = { x: pos.mb1.x - 60, y: pos.mb1.y - 20 }; + const d2 = { x: pos.mb2.x - 260, y: pos.mb2.y - 20 }; + h.check(d1.x > 50 && d1.y > 20, `dragging the selection moved it (${d1.x}, ${d1.y})`); + h.check( + Math.abs(d1.x - d2.x) < 1 && Math.abs(d1.y - d2.y) < 1, + `...and every selected node moved by the SAME delta (${d2.x}, ${d2.y})` + ); + + // 3. Shift+click toggles membership (the overlay covers the cards while a rectangle + // selection stands, so start from a cleared selection — what a user does too) + await B.page.mouse.click(spotB.x, spotB.y); + await B.page.waitForTimeout(300); + const cleared = await B.page.evaluate(POSITIONS); + h.check(!cleared.mb1.selected && !cleared.mb2.selected, 'a click on empty pane clears the selection (premise)'); + const c1 = await B.page.locator('[data-id="mb1"]').boundingBox(); + const c2 = await B.page.locator('[data-id="mb2"]').boundingBox(); + await B.page.mouse.click(c1.x + 12, c1.y + 8); + await B.page.waitForTimeout(250); + await B.page.keyboard.down('Shift'); + await B.page.mouse.click(c2.x + 12, c2.y + 8); + await B.page.keyboard.up('Shift'); + await B.page.waitForTimeout(300); + pos = await B.page.evaluate(POSITIONS); + h.check(pos.mb1.selected && pos.mb2.selected, `Shift+click ADDS to the selection (${JSON.stringify([pos.mb1.selected, pos.mb2.selected])})`); + await B.page.keyboard.down('Shift'); + await B.page.mouse.click(c2.x + 12, c2.y + 8); + await B.page.keyboard.up('Shift'); + await B.page.waitForTimeout(300); + pos = await B.page.evaluate(POSITIONS); + h.check(pos.mb1.selected && !pos.mb2.selected, `...and Shift+click again REMOVES it (${JSON.stringify([pos.mb1.selected, pos.mb2.selected])})`); + + // 4. a right DRAG pans and opens no menu. This runs BEFORE the stationary + // right-click check on purpose: the menu opens AT the pointer, so it would then + // be sitting on the very spot this drag starts from — a click there lands on the + // menu itself (it is not a backdrop), and nothing would reach the pane at all. + const before = await B.page.locator('[data-id="mb1"]').boundingBox(); + await dragMouse(B.page, spotB, { x: spotB.x - 80, y: spotB.y - 40 }, 'right'); + const after = await B.page.locator('[data-id="mb1"]').boundingBox(); + const menuAfterPan = await B.page.locator('[role="menu"]').count(); + h.check( + Math.abs(after.x - (before.x - 80)) < 3 && Math.abs(after.y - (before.y - 40)) < 3, + `Select-first: a right drag PANS (${Math.round(after.x - before.x)}, ${Math.round(after.y - before.y)})` + ); + h.check(menuAfterPan === 0, '...and that right drag opened no menu'); + + // 5. ...while a right click that does not travel still opens it + await B.page.mouse.click(spotB.x, spotB.y, { button: 'right' }); + await B.page.waitForTimeout(400); + h.check((await B.page.locator('[role="menu"]').count()) > 0, 'Select-first: a stationary right click opens the pane menu'); + + // 6. the pref survives a reload, and Settings carries the row that writes it + await h.freshReload(B); + const kept = await B.page.evaluate(() => localStorage.getItem('flow:mouseBindings')); + h.check(kept === 'select', `the binding persists across a reload (${kept})`); + await B.page.evaluate(() => { + window.__stores.settingsSection.set('input'); + window.__stores.settingsOpen.set(true); + }); + await B.page.waitForTimeout(900); + h.check((await B.page.locator('#flow-mouse-bindings').count()) > 0, 'Settings ▸ Input carries the Mouse bindings row'); + + await h.finish(browser); +}); From 6c69a686078f214fa01ec0a8299b7e7fa516b7a4 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 10:43:36 +0300 Subject: [PATCH 12/17] [feat] P6 integration sweep: one Scene look story, one cost line, two decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four questions the plan says are only answerable once the three layers meet. ONE STORY. Configure Scene read as unrelated sections — "Post-processing" for layer 1, nothing at all for layers 2 and 3 — which is how "must my peers switch this on?" became a separate question three times over. There is one **Scene look** section now: what the look IS (three layers, all scene data, only the right to switch one off is local), then the stack, then the materials layer as a summary plus the way in, because its editing surface is a dock tab and belongs there. The label moved and the DEEP-LINK NAME did not. `Section` takes `aliases` now, so a rename lists what the section used to be called instead of hunting every menu, component and suite that wrote the old name down — and silently missing one. The 21-G1 rule, generalised into the component that enforces it. THE COST LINE, EXTENDED. L3's "Effects: N, passes: M" was the only place a look's cost was visible. The materials layer speaks in the same voice now: "A scene default and 2 objects with their own — driving 9 objects, 3 programs." PROGRAMS rather than objects is the honest number — `customProgramCacheKey` hashes the injected source, so N objects on one graph compile one program (measured 22 -> 23 for 24 objects when SH6b's compile-once optimisation was declined on evidence). THE SAVE-PATH AUDIT, done once and written where the carriers are (shaderGraph.js). All three layers are a KEYED DOCUMENT plus a runtime product, and the rule is the same each time: save the document, never the product. Wire, autosave, sessions and undo each carry all three; the products are carried by nobody and rebuilt on the other side. The conclusion worth recording is a NEGATIVE one: `parkShaderMaterials` exists only because layers 2 and 3 attach their product to the scene TREE, and a post Effect lives in the composer, which no serializer walks — so P4 needed no fourth park, and a reader looking for one now finds the paragraph saying why. THE CAPABILITY GATE, DECIDED: they stay separate, reason in viewMode.js beside `postSupported`. Three properties of the measured ANGLE/D3D11 failure do not transfer to a material — blast radius (a broken pass takes the whole viewport, a broken material keeps its last good one and reports), where they run (post is skipped in VR, materials are the only layer that works there, so one gate would switch off the half that works), and who compiles (a material goes through three's own program path, so gating it is gating three). Materials get a CHOICE (`viewportOverrides.shaders`, P5); post keeps its REFUSAL. Counterfactual: remove `aliases` from Section's deep-link match -> scene-post-ui 1.3 red (gap -3090px: the old name lands nowhere). Gates: scene-post-ui 50/50 (44 base + 6 new) · scene-post 93 · panel-deeplinks 23 · shader-inspector 24 · camera-looks 43 · watch-look 45 · shader-post-domain 48 · scene-default-material 33 · scene-post-effects 42/1 and shader-editor 64/2, both pre-existing · svelte-check 361/47 message set identical to base · build green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR --- src/components/menu/Inspector.svelte | 60 ++++++++++++++-- src/components/menu/ViewportMenu.svelte | 6 +- src/components/ui/Section.svelte | 19 ++++- src/lib/shaderGraph.js | 23 ++++++ src/lib/viewMode.js | 20 ++++++ tests/e2e/scene-post-effects.test.cjs | 3 + tests/e2e/scene-post-ui.test.cjs | 96 +++++++++++++++++++++++-- 7 files changed, 212 insertions(+), 15 deletions(-) diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index be5e0141..6d1efb1b 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -205,9 +205,36 @@ openShaderEditor, detachFrom, shaderGraphOf, - setShaderGraphFor + setShaderGraphFor, + // P6: the look's THIRD layer, summarised where the other two live + shaderGraphs, + SCENE_GRAPH_KEY, + shaderDrivenCount } from '$lib/shaderGraph'; + /** + * P6 — the material layer's COST, in the same voice as the post stack's + * "Effects: N, passes: M" line. Program count is what a shader-driven scene costs: + * `customProgramCacheKey` hashes the injected source, so N objects on ONE graph + * compile ONE program (measured at 22 -> 23 programs for 24 objects), and that is + * the number worth showing rather than the object count on its own. + * @param {Record} graphs the shader documents + * @param {any} _poke THREE trees are not reactive; the count reads through them + */ + const shaderSummaryOf = (graphs, /** @type {any} */ _poke) => { + const keys = Object.keys(graphs ?? {}).filter((key) => !key.startsWith('post:')); + const scene = keys.includes(SCENE_GRAPH_KEY); + const own = keys.filter((key) => key !== SCENE_GRAPH_KEY).length; + const driven = shaderDrivenCount(); + if (!keys.length) return 'No shader materials. The scene uses each object’s own material.'; + return ( + (scene ? 'A scene default' : 'No scene default') + + (own ? ' and ' + own + ' object' + (own === 1 ? '' : 's') + ' with their own' : '') + + ' — driving ' + driven + ' object' + (driven === 1 ? '' : 's') + + ', ' + keys.length + ' program' + (keys.length === 1 ? '' : 's') + '.' + ); + }; + // (15-L3 dropped the standalone hex textboxes under each colour picker — the // picker's own hex/rgb/hsv field from 15-C2 replaced them, so the validating // regex they needed is gone too) @@ -1674,10 +1701,35 @@ Show colliders — this device - -
+ +
+

+ Three layers, all of them scene data that everyone sees: effects over the + finished frame (below), a default material every object without its own + inherits, and a material on one object. Only the right to switch a layer off + is local — that is View ▸ Overrides, above. +

+ +

+ {shaderSummaryOf($shaderGraphs, $objectsGroup)} +

+
+ {#if sharedWith.length} +
+

+ This material is shared with {sharedWith.length} + other object{sharedWith.length === 1 ? '' : 's'} — editing it here changes + {sharedWith.length === 1 ? 'that one' : 'them'} too, for everyone. +

+
+ +
+
+ {/if} + {#if !allShaderDriven} + + + + Off (the default), a duplicate gets its own copy of the material, so editing one + leaves the other alone. On, the copy and the original share ONE material and an + edit to either changes both — for everyone in the session. Geometry is always + copied either way. Use the Material section's Unlink to give one + object its own material back + + diff --git a/src/lib/materialSharing.js b/src/lib/materialSharing.js new file mode 100644 index 00000000..1371f5c3 --- /dev/null +++ b/src/lib/materialSharing.js @@ -0,0 +1,246 @@ +// D2 — SHARED MATERIALS: "by default copy, add an option to share" (the user's ask, +// 2026-08-17), held until the shader lane had established what a material IDENTITY is. +// +// WHY IT WAITED, and what changed. Sharing is one line locally — skip `detachMaterials` +// and the clone keeps the source's material instance. The reason that was refused is +// REPLICATION: every material change is broadcast PER OBJECT (`materialParam`, +// `objectParameters`, `map`), so two objects sharing an instance locally would diverge +// the instant a peer applied one — the sender sees both change, the receiver sees one. +// A late joiner would receive two independent materials and never share them again, and +// toJSON/GLTF each lose it differently. +// +// THE IDENTITY, and it is deliberately the one the shader lane already proved rather +// than a second system: **a small string on `userData`**. That is the same carrier +// `userData.physics`, `userData.origin`, `userData.camera` and `__uuid` ride — it rides +// toJSON AND GLTF extras, which is exactly the four-carrier problem solved once. +// +// THE THREE RULES THIS ENCODES +// +// 1. THE ID IS THE TRUTH; THE INSTANCE IS AN OPTIMISATION. Objects that share an id +// should share one THREE.Material so a local edit is instant on both — but every +// carrier splits instances somewhere (GLTF rebuilds a material per mesh, a peer +// receives objects one at a time, undo re-parses a subtree). So nothing depends on +// the instance: `reconcileSharedMaterials` re-unifies by id whenever the scene +// changes, the way shaderGraph reconciles a graph whose object arrived late. +// +// 2. THE SENDER FANS. Rather than mint a material-addressed message — a new type, a new +// applier, a new capability-gate entry and a story for every older peer — the sender +// broadcasts the per-object messages the receiver ALREADY understands, once per +// object sharing the id (`fanTargets`). The wire is byte-unchanged, an older peer +// needs no code at all, and the answer to "what happens when a shared material meets +// a peer that does not have it" is: it receives ordinary per-object edits and agrees. +// The honest cost, stated rather than hidden: a peer on an OLDER build that edits a +// shared material fans nothing, so only the object it edited changes — for everyone. +// +// 3. COPY REMAINS THE DEFAULT. `shareDuplicatedMaterials` is LOCAL and OFF, because a +// duplicate is a working copy of everything that belongs to the object (D1's DCC +// rule) and only data people deliberately SHARE is linked — Blender's linked +// duplicate is a different command, not a different default. +// +// A LEAF: svelte stores and THREE only. objectActions, materialsHandler and the +// Inspector all read it, so it may import none of them. + +import { writable, get } from 'svelte/store'; +import { objectsGroup } from '../stores/sceneStore'; + +/** The userData key. Short and namespaced, since it rides every serializer. */ +export const MATERIAL_ID_KEY = 'materialId'; + +/** + * LOCAL pref: does Ctrl+D hand the copy the SAME material as the source? + * + * Local rather than scene data on purpose — it is a fact about how YOU duplicate, like + * the snap step or the double-click action, and two people in one session may reasonably + * want different answers. What they produce (a shared id) IS scene data and replicates. + */ +export const shareDuplicatedMaterials = writable( + typeof localStorage !== 'undefined' && localStorage.getItem('shareDuplicatedMaterials') === 'true' +); +shareDuplicatedMaterials.subscribe((value) => { + try { + localStorage.setItem('shareDuplicatedMaterials', String(value)); + } catch { + /* private mode: a pref is a convenience, never a requirement */ + } +}); + +let idCounter = 0; +/** Unique within a session; the id only has to be stable, never meaningful. */ +function newMaterialId() { + return 'mat' + Date.now().toString(36) + (idCounter++).toString(36); +} + +/** @param {any} object @returns {string} */ +export function materialIdOf(object) { + const id = object?.userData?.[MATERIAL_ID_KEY]; + return typeof id === 'string' ? id : ''; +} + +/** @param {any} object @param {string} id */ +export function setMaterialId(object, id) { + if (!object) return; + if (!object.userData) object.userData = {}; + if (id) object.userData[MATERIAL_ID_KEY] = id; + else delete object.userData[MATERIAL_ID_KEY]; +} + +/** Every mesh in the scene, with an optional filter. @param {(node: any) => boolean} [keep] */ +function meshes(keep) { + const group = get(objectsGroup); + /** @type {any[]} */ + const out = []; + group?.traverse((/** @type {any} */ node) => { + if (node.isMesh && (!keep || keep(node))) out.push(node); + }); + return out; +} + +/** The objects sharing one id (including, normally, the one you asked about). + * @param {string} id @returns {any[]} */ +export function objectsSharing(id) { + if (!id) return []; + return meshes((node) => materialIdOf(node) === id); +} + +/** Is this object's material shared with anything else right now? @param {string} uuid */ +export function isSharedMaterial(uuid) { + const group = get(objectsGroup); + const object = group?.getObjectByProperty('uuid', uuid); + const id = materialIdOf(object); + return !!id && objectsSharing(id).length > 1; +} + +/** + * THE SEND-SIDE FAN: which uuids a per-object material message must ALSO be sent for. + * + * Returns the OTHER objects sharing this one's material — never the object itself, so a + * caller adds the fan to what it was already sending and cannot double-send. Empty for + * the overwhelmingly common unshared case, which is what keeps the hot path free. + * @param {string} uuid @returns {string[]} + */ +export function fanTargets(uuid) { + const group = get(objectsGroup); + const object = group?.getObjectByProperty('uuid', uuid); + const id = materialIdOf(object); + if (!id) return []; + return objectsSharing(id) + .map((node) => node.uuid) + .filter((other) => other !== uuid); +} + +/** + * Link `clone`'s material to `source`'s, minting the id if this is the first link. + * + * Walks BOTH trees in the same order the duplicate path does, so a group shares + * per-child rather than as a lump: two children of one group may legitimately hold + * different materials, and one id for the group would be a lie about all but one. + * @param {any} source @param {any} clone + */ +export function linkMaterials(source, clone) { + /** @type {any[]} */ + const from = []; + /** @type {any[]} */ + const to = []; + source.traverse((/** @type {any} */ node) => node.isMesh && from.push(node)); + clone.traverse((/** @type {any} */ node) => node.isMesh && to.push(node)); + for (let i = 0; i < to.length && i < from.length; i++) { + // a material ARRAY (UV4 slots) is refused rather than half-shared: every consumer + // of the id assumes one material per object, and `switchMaterialType` sets the + // precedent of declining instead of collapsing the array + if (Array.isArray(from[i].material) || Array.isArray(to[i].material)) continue; + const id = materialIdOf(from[i]) || newMaterialId(); + setMaterialId(from[i], id); + setMaterialId(to[i], id); + to[i].material = from[i].material; + } +} + +/** + * Stop sharing: this object gets its own copy of the material and drops the id. + * + * The LAST holder of an id keeps it harmlessly (a group of one is not shared, which is + * what `isSharedMaterial` answers) — hunting it down would mean a second pass for no + * observable difference, and a later duplicate simply re-uses it. + * @param {string} uuid + */ +export function unlinkMaterial(uuid) { + const group = get(objectsGroup); + const object = group?.getObjectByProperty('uuid', uuid); + if (!object || Array.isArray(object.material)) return false; + setMaterialId(object, ''); + if (object.material) object.material = object.material.clone(); + objectsGroup.update((value) => value); + return true; +} + +/** + * Re-unify instances by id — the half that makes every carrier work. + * + * Every path that rebuilds an object rebuilds its material: GLTF (the wire's object sync + * and autosave) makes one per mesh, a peer receives objects one message at a time, and + * undo re-parses a subtree. The id survives all of them because it is `userData`; the + * INSTANCE does not. So the first object holding an id lends its material to the rest, + * and the rest is then an ordinary local share again. + * + * Cheap in the common case: it only walks meshes that carry an id at all, and only + * assigns where the instance actually differs. + * @returns {number} how many objects were re-pointed (for the debug hook and the suite) + */ +export function reconcileSharedMaterials() { + /** @type {Map} */ + const first = new Map(); + let changed = 0; + for (const node of meshes((n) => !!materialIdOf(n))) { + if (Array.isArray(node.material)) continue; + const id = materialIdOf(node); + const held = first.get(id); + if (!held) { + first.set(id, node.material); + continue; + } + if (node.material !== held) { + node.material = held; + changed++; + } + } + if (changed) objectsGroup.update((value) => value); + return changed; +} + +/** @type {(() => void)|null} */ +let reconcileStop = null; +/** @type {any} */ +let reconcileTimer = null; + +/** Idempotent; call once at boot. Debounced, because objectsGroup pokes on every + * scene mutation (the shaderGraph reconcile precedent exactly). */ +export function startMaterialSharing() { + if (reconcileStop) return; + reconcileStop = objectsGroup.subscribe(() => { + clearTimeout(reconcileTimer); + reconcileTimer = setTimeout(() => reconcileSharedMaterials(), 150); + }); +} + +/** Test seam. */ +export function stopMaterialSharing() { + reconcileStop?.(); + reconcileStop = null; + clearTimeout(reconcileTimer); +} + +/** test/debug view: the sharing groups, by id */ +export function materialSharingDebug() { + /** @type {Record} */ + const groups = {}; + for (const node of meshes((n) => !!materialIdOf(n))) { + const id = materialIdOf(node); + groups[id] ??= { uuids: [], instances: 0 }; + groups[id].uuids.push(node.uuid); + } + for (const id of Object.keys(groups)) { + const set = new Set(objectsSharing(id).map((node) => node.material)); + groups[id].instances = set.size; + } + return { on: get(shareDuplicatedMaterials), groups }; +} diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js index 837d29c1..8aec4d1a 100644 --- a/src/lib/materialsHandler.js +++ b/src/lib/materialsHandler.js @@ -1,6 +1,8 @@ import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup } from '../stores/sceneStore'; +// D2: shared materials. A leaf (stores + THREE), so importing it here closes no cycle. +import { fanTargets } from './materialSharing'; import { peers, showToast } from '../stores/appStore'; import { recordEntry, registerHistoryKind } from '$lib/history'; @@ -66,11 +68,25 @@ export function materialAt(object, slot = 0) { return materials[slot] ?? null; } -/** @param {any} data */ +/** + * Send a material message — and, for a SHARED material (D2), the same message again for + * every other object wearing it. + * + * THE FAN LIVES HERE because this is the one choke point every material message in the + * app passes through (colour, params, maps, the slot array, the type switch), so sharing + * costs the wire NOTHING: a receiver applies the per-object messages it already + * understands, an older peer needs no code, and there is no material-addressed message + * type to add, gate and explain. `fanTargets` is empty for an unshared object, which is + * every object until somebody turns the setting on. + * @param {any} data + */ function broadcast(data) { /** @type {any} */ const peer = get(peers); - if (peer) peer.send(data); + if (!peer) return; + peer.send(data); + if (!data?.uuid) return; + for (const other of fanTargets(data.uuid)) peer.send({ ...data, uuid: other }); } // Undo entries replay through the same replicated actions below @@ -531,6 +547,14 @@ export function switchMaterialType(uuid, type, replicate = true) { } object.material = fresh; fresh.needsUpdate = true; + // D2: this is the ONE material op that REPLACES the instance rather than writing into + // it, so it is the one that would silently break a share — every other edit reaches + // the sharers for free by being a write to the material they hold. Hand them the new + // one too, or the next reconcile would put the OLD material back on this object. + for (const other of fanTargets(uuid)) { + const node = objectOf(other); + if (node && !Array.isArray(node.material)) node.material = fresh; + } objectsGroup.update((value) => value); if (replicate) broadcast({ type: 'objectParameters', parameter: 'material', uuid: uuid, material: type }); diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 5aec23fe..ab28328b 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -36,6 +36,8 @@ import { canEditObject, warnViewerReadOnly } from './objectPermissions'; import { stripEditOverlays, isEditOverlay } from './editOverlays'; // B7: the transient marker (a LEAF — two stores only, so no cycle back through history) import { markTransient } from './transientObjects'; +// D2: a LEAF (svelte stores + THREE), so a static import here closes no cycle +import { shareDuplicatedMaterials, linkMaterials } from './materialSharing'; import { duplicateCarriesAnimation, duplicateCarriesFlow, @@ -429,7 +431,14 @@ function collectTree(object, list = []) { return list; } -/** @param {any} clone - give cloned meshes their own materials and geometry (three's clone() shares both) */ +/** + * @param {any} clone - give cloned meshes their own materials and geometry (three's + * clone() shares both) + * + * D2: geometry is ALWAYS detached, materials only when the copy is not meant to share — + * the two are separate questions and only one of them has a setting. A shared geometry + * would make a vertex edit on the copy deform the original, which nobody asked for. + */ function detachMaterials(clone) { collectTree(clone).forEach((node) => { if (node.material) @@ -499,8 +508,15 @@ export function duplicateObject(uuid, options = {}) { // It also keeps the node COUNT the same on both sides of applyRemoteDuplicate, // whose uuid assignment walks the clone in depth-first order. stripEditOverlays(clone); + // D2: with sharing on, the copy keeps the SOURCE's material instance and both objects + // take a `materialId`, so an edit to either reaches both — locally through the shared + // instance, and on peers through the send-side fan. OFF by default: a duplicate is a + // working copy of everything that belongs to the object, and only data people + // deliberately share is linked (Blender's linked duplicate is its own command). + const shareMaterial = get(shareDuplicatedMaterials) && !options.transient; detachMaterials(clone); stripSelectionTint(source, clone); + if (shareMaterial) linkMaterials(source, clone); const cloneNodes = collectTree(clone); cloneNodes.forEach((node) => (node.uuid = crypto.randomUUID())); clone.name = (source.name || source.type) + ' copy'; @@ -524,7 +540,12 @@ export function duplicateObject(uuid, options = {}) { pos: clone.position.toArray(), // B7: absent for every ordinary duplicate, so the message a peer already // knows how to read is unchanged - ...(options.transient ? { transient: true } : {}) + ...(options.transient ? { transient: true } : {}), + // D2: likewise ADDITIVE. The peer has to link its own copy, or its two objects + // would hold separate materials and the fan would be writing into one of them + // twice. An older peer ignores it and keeps a plain copy, which is what it + // would have had anyway. + ...(shareMaterial ? { shareMaterial: true } : {}) }); // after the clone exists and its uuid is known, and after the `duplicate` @@ -589,8 +610,11 @@ export function duplicateSelection() { * flag has to be stamped HERE because the clone is made from OUR source object, whose * userData is (correctly) not transient. Without it a peer would keep the spawned crates * in its own sessions and autosave, and only the initiator's sweep would remove them. + * @param {boolean=} shareMaterial D2: the sender's copy shares its source's material, so + * ours must too — the id is what the fan and the reconcile both key on, and a peer that + * skipped this would hold two materials the sender thinks are one. */ -export function applyRemoteDuplicate(sourceUuid, uuids, name, pos, transient) { +export function applyRemoteDuplicate(sourceUuid, uuids, name, pos, transient = false, shareMaterial = false) { const group = get(objectsGroup); const source = group?.getObjectByProperty('uuid', sourceUuid); if (!source) return; @@ -600,6 +624,10 @@ export function applyRemoteDuplicate(sourceUuid, uuids, name, pos, transient) { collectTree(clone).forEach((node, index) => { if (uuids[index]) node.uuid = uuids[index]; }); + // D2: AFTER the uuids are assigned — `linkMaterials` stamps the id on both trees and + // the reconcile groups by it, so doing this against placeholder uuids would group the + // wrong objects for the one frame before they were replaced + if (shareMaterial) linkMaterials(source, clone); clone.name = name; clone.position.fromArray(pos); if (transient) markTransient(clone); diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 7b410f8e..d143e51d 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -735,8 +735,10 @@ export class PeerConnection { } else if(data.type == 'objectParameters') { objectParameters(data); } else if(data.type == 'duplicate') { - // B7: `transient` is additive — absent for every ordinary duplicate - applyRemoteDuplicate(data.sourceUuid, data.uuids, data.name, data.pos, data.transient); + // B7: `transient` is additive — absent for every ordinary duplicate. + // D2: so is `shareMaterial` — absent means the copy gets its own material, + // which is what every peer before this build did unconditionally. + applyRemoteDuplicate(data.sourceUuid, data.uuids, data.name, data.pos, data.transient, data.shareMaterial); } else if(data.type == 'clearscene') { applyClearScene(data.peerId); } else if(data.type == 'delete') { diff --git a/tests/e2e/material-sharing.test.cjs b/tests/e2e/material-sharing.test.cjs new file mode 100644 index 00000000..c30d935f --- /dev/null +++ b/tests/e2e/material-sharing.test.cjs @@ -0,0 +1,419 @@ +// D2 — SHARED MATERIALS: "by default copy, add an option to share". +// +// The feature is one line locally (skip the material clone) and everything hard about it +// is REPLICATION and PERSISTENCE, so that is what this measures: two peers agreeing after +// an edit to either object, a late joiner inheriting the share, and a `.tpscene` round +// trip keeping ONE material rather than two that look alike. +// +// The metric throughout is material IDENTITY (`===`), never the material's type or its +// colour. Two objects can hold two separate materials that are identical in every +// property — that is exactly what a COPY is — so a value check cannot tell copy from +// share, and would pass against the feature being absent. + +const h = require('./helpers.cjs'); + +/** Are these two objects wearing the SAME material instance, and what ids do they hold? */ +const shareState = (page, a, b) => + page.evaluate( + ({ a, b }) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const oa = group?.getObjectByProperty('uuid', a); + const ob = group?.getObjectByProperty('uuid', b); + return { + found: !!oa && !!ob, + same: !!oa && !!ob && oa.material === ob.material, + idA: oa?.userData?.materialId ?? '', + idB: ob?.userData?.materialId ?? '', + colourA: oa?.material?.color?.getHexString?.() ?? '', + colourB: ob?.material?.color?.getHexString?.() ?? '' + }; + }, + { a, b } + ); + +const colours = (page, list) => + page.evaluate((uuids) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + return uuids.map((u) => group?.getObjectByProperty('uuid', u)?.material?.color?.getHexString?.() ?? ''); + }, list); + +/** duplicate the selected object and return the new uuid */ +const duplicate = async (page, uuid) => { + const before = await page.evaluate(() => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const out = []; + group.traverse((n) => n.isMesh && out.push(n.uuid)); + return out; + }); + await page.evaluate((u) => window.__stores.objectActions.duplicateObject(u), uuid); + await page.waitForTimeout(900); + const after = await page.evaluate(() => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const out = []; + group.traverse((n) => n.isMesh && out.push(n.uuid)); + return out; + }); + return after.find((u) => !before.includes(u)) ?? ''; +}; + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // ---------------------------------------------------------------- section 1 + console.log('\n=== 1. COPY is still the default ==='); + const boxU = await page.evaluate(async () => { + window.__stores.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 900)); + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + let found = ''; + group.traverse((n) => { + if (n.isMesh && !found) found = n.uuid; + }); + return found; + }); + h.check(!!boxU, '1.1 premise: a box exists'); + h.check( + !(await page.evaluate(() => { + let on = null; + window.__stores.materialSharing.shareDuplicatedMaterials.subscribe((v) => (on = v))(); + return on; + })), + '1.2 the setting is OFF out of the box — a duplicate is a working copy' + ); + const copyU = await duplicate(page, boxU); + const copied = await shareState(page, boxU, copyU); + h.check(copied.found && !copied.same, '1.3 the copy gets its OWN material instance'); + h.check(!copied.idA && !copied.idB, '1.4 ...and neither object carries a material id'); + // prove it by EDITING: this is the behaviour sharing is the opposite of + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#ff0000'), copyU); + await page.waitForTimeout(600); + const afterCopyEdit = await shareState(page, boxU, copyU); + h.check( + afterCopyEdit.colourB === 'ff0000' && afterCopyEdit.colourA !== 'ff0000', + '1.5 editing the copy leaves the original alone: ' + afterCopyEdit.colourA + ' / ' + afterCopyEdit.colourB + ); + + // ---------------------------------------------------------------- section 2 + console.log('\n=== 2. sharing ON: one material, two objects ==='); + await page.evaluate(() => + window.__stores.materialSharing.shareDuplicatedMaterials.set(true) + ); + const sharedU = await duplicate(page, boxU); + const shared = await shareState(page, boxU, sharedU); + h.check(shared.same, '2.1 the copy wears the SAME material instance'); + h.check( + !!shared.idA && shared.idA === shared.idB, + '2.2 ...and both carry the same material id: ' + shared.idA + ' / ' + shared.idB + ); + h.check( + await page.evaluate((u) => window.__stores.materialSharing.isSharedMaterial(u), boxU), + '2.3 the source knows it is shared now too (sharing is symmetric, not a property of the copy)' + ); + // GEOMETRY is still copied — the two questions are separate and only one has a setting + const geometrySeparate = await page.evaluate( + ({ a, b }) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + return ( + group.getObjectByProperty('uuid', a).geometry !== group.getObjectByProperty('uuid', b).geometry + ); + }, + { a: boxU, b: sharedU } + ); + h.check(geometrySeparate, '2.4 geometry is still its OWN — a vertex edit must not deform the original'); + + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#2244ff'), sharedU); + await page.waitForTimeout(700); + const afterShareEdit = await shareState(page, boxU, sharedU); + h.check( + afterShareEdit.colourA === '2244ff' && afterShareEdit.colourB === '2244ff', + '2.5 editing either changes both: ' + afterShareEdit.colourA + ' / ' + afterShareEdit.colourB + ); + + // ---------------------------------------------------------------- section 3 + console.log('\n=== 3. Unlink gives one object its material back ==='); + await page.evaluate((u) => window.__stores.materialSharing.unlinkMaterial(u), sharedU); + await page.waitForTimeout(700); + const unlinked = await shareState(page, boxU, sharedU); + h.check(!unlinked.same, '3.1 the unlinked object has its own instance again'); + h.check(unlinked.idB === '', '3.2 ...and dropped the id'); + h.check( + unlinked.colourA === unlinked.colourB, + '3.3 ...keeping the material it was WEARING (unlink is not a revert): ' + unlinked.colourB + ); + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#00cc44'), sharedU); + await page.waitForTimeout(600); + const afterUnlinkEdit = await shareState(page, boxU, sharedU); + h.check( + afterUnlinkEdit.colourB === '00cc44' && afterUnlinkEdit.colourA === '2244ff', + '3.4 and an edit no longer crosses: ' + afterUnlinkEdit.colourA + ' / ' + afterUnlinkEdit.colourB + ); + + // ---------------------------------------------------------------- section 4 + console.log('\n=== 4. the reconcile: the ID is the truth, the instance is an optimisation ==='); + // re-share, then SPLIT the instances behind the app's back — which is exactly what + // GLTF, a peer's per-object messages and undo each do on their own path + const pairU = await duplicate(page, boxU); + h.check((await shareState(page, boxU, pairU)).same, '4.1 premise: a fresh shared pair'); + const split = await page.evaluate( + ({ a, b }) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const ob = group.getObjectByProperty('uuid', b); + ob.material = ob.material.clone(); // the id stays; the instance does not + const oa = group.getObjectByProperty('uuid', a); + return { same: oa.material === ob.material, idsMatch: oa.userData.materialId === ob.userData.materialId }; + }, + { a: boxU, b: pairU } + ); + h.check(!split.same && split.idsMatch, '4.2 premise: instances split, ids still equal'); + const repointed = await page.evaluate(() => + window.__stores.materialSharing.reconcileSharedMaterials() + ); + const healed = await shareState(page, boxU, pairU); + h.check( + repointed >= 1 && healed.same, + '4.3 the reconcile re-unifies them by id (' + repointed + ' re-pointed)' + ); + h.check( + (await page.evaluate(() => window.__stores.materialSharing.reconcileSharedMaterials())) === 0, + '4.4 ...and is a no-op the second time (it only assigns where they differ)' + ); + + // ---------------------------------------------------------------- section 5 + console.log('\n=== 5. a .tpscene round trip keeps ONE material ==='); + const roundTrip = await page.evaluate(() => { + const payload = window.__stores.sessions.buildSessionPayload('material-sharing'); + if (!payload) return { skipped: true }; + // `objects` is an ARRAY of per-child toJSON results, each `{geometries, materials, + // object}` — the shape cost this check one red run, so it is walked explicitly + // AND counted shape-independently below. + const ids = []; + const walk = (node) => { + if (node?.userData?.materialId) ids.push([node.uuid, node.userData.materialId]); + (node?.children ?? []).forEach(walk); + }; + for (const entry of payload.objects ?? []) walk(entry?.object ?? entry); + return { skipped: false, ids, text: JSON.stringify(payload) }; + }); + if (roundTrip.skipped) { + console.log('SKIP: no session payload builder on this build'); + } else { + const pair = roundTrip.ids.filter(([uuid]) => uuid === boxU || uuid === pairU); + h.check( + pair.length === 2 && pair[0][1] === pair[1][1], + '5.1 the material id rides the SAVE for BOTH objects: ' + JSON.stringify(pair) + ); + const id = pair[0]?.[1] ?? ''; + const occurrences = id ? roundTrip.text.split('"materialId":"' + id + '"').length - 1 : 0; + h.check( + occurrences === 2, + '5.2 ...exactly twice in the saved bytes, wherever the shape puts it: ' + occurrences + ); + // and THAT is all the save needs to carry: the instance is re-unified on load by + // the reconcile, which section 4 measures on its own + h.check( + (await page.evaluate(() => window.__stores.materialSharing.reconcileSharedMaterials())) === 0, + '5.3 ...because the instance is the reconcile\'s job, not the file\'s' + ); + } + + // ---------------------------------------------------------------- section 6 + console.log('\n=== 6. two peers ==='); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + await h.eventually( + () => shareState(B.page, boxU, pairU), + (s) => s.found, + '6.1 B receives both objects', + 30000 + ); + await h.eventually( + () => shareState(B.page, boxU, pairU), + (s) => s.idA && s.idA === s.idB, + '6.2 ...carrying the same material id (it rides userData through the object sync)', + 20000 + ); + await h.eventually( + () => shareState(B.page, boxU, pairU), + (s) => s.same, + '6.3 ...and B\'s reconcile puts them on ONE material instance', + 20000 + ); + + // the edit crosses — the send-side fan is the whole mechanism + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#ffaa00'), boxU); + await h.eventually( + () => colours(B.page, [boxU, pairU]), + (c) => c[0] === 'ffaa00' && c[1] === 'ffaa00', + '6.4 an edit to ONE object on A reaches BOTH objects on B (the sender fans)', + 20000 + ); + // and in the other direction, from the object that was not edited + await B.page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#8800ff'), pairU); + await h.eventually( + () => colours(page, [boxU, pairU]), + (c) => c[0] === '8800ff' && c[1] === '8800ff', + '6.5 ...and back the other way, edited from the copy', + 20000 + ); + + // ---------------------------------------------------------------- section 6b + // A FIRST SHARE MADE WHILE CONNECTED is the path that needs the applier, and it has to + // be a FRESH object: `linkMaterials` mints the id on the SOURCE, and nothing re-sends a + // source's userData afterwards, so a peer cloning its own copy of an object it received + // BEFORE the id existed would produce two unshared objects. Everything above connected + // B after the sharing, so the id simply rode the full-state sync and the applier's link + // could be deleted with the suite still green — measured, then fixed by this section. + console.log('\n=== 6b. a FIRST share made while connected ==='); + const freshU = await page.evaluate(async () => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const before = []; + group.traverse((n) => n.isMesh && before.push(n.uuid)); + window.__stores.commandsHandler.sceneCommand('/create sphere'); + await new Promise((r) => setTimeout(r, 1200)); + let found = ''; + group.traverse((n) => { + if (n.isMesh && !before.includes(n.uuid) && !found) found = n.uuid; + }); + return found; + }); + await h.eventually( + () => shareState(B.page, freshU, freshU), + (s) => s.found, + '6b.0 premise: a brand-new object, never shared, has reached B', + 25000 + ); + h.check( + (await shareState(page, freshU, freshU)).idA === '', + '6b.0b ...carrying no material id on either side yet' + ); + const liveCopyU = await duplicate(page, freshU); + h.check(!!liveCopyU, '6b.1 premise: A duplicated it while connected, minting the id now'); + await h.eventually( + () => shareState(B.page, freshU, liveCopyU), + (s) => s.found, + '6b.2 B built the copy', + 20000 + ); + await h.eventually( + () => shareState(B.page, freshU, liveCopyU), + (s) => !!s.idB && s.idA === s.idB && s.same, + '6b.3 ...with the same material id AND the same instance — the applier links it too', + 20000 + ); + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#44ff88'), liveCopyU); + await h.eventually( + () => colours(B.page, [freshU, liveCopyU]), + (c) => c[0] === '44ff88' && c[1] === '44ff88', + '6b.4 ...and edits cross on B', + 20000 + ); + + // ---------------------------------------------------------------- section 6c + // THE MATERIAL TYPE is the one op that REPLACES the instance instead of writing into + // it, which makes it the one place the send-side fan is load-bearing: without it the + // peer switches ONE object, and its reconcile then lends whichever material it meets + // first — which can put the OLD type back and diverge the two sides for good. + console.log('\n=== 6c. a material TYPE switch, the instance-replacing op ==='); + const typeOf = (p, list) => + p.evaluate((uuids) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + return uuids.map((u) => group?.getObjectByProperty('uuid', u)?.material?.type ?? ''); + }, list); + const beforeTypes = await typeOf(B.page, [freshU, liveCopyU]); + h.check( + beforeTypes[0] === beforeTypes[1] && beforeTypes[0] === 'MeshStandardMaterial', + '6c.1 premise: both are MeshStandardMaterial on B: ' + JSON.stringify(beforeTypes) + ); + await page.evaluate((u) => + window.__stores.materialsHandler.switchMaterialType(u, 'MeshPhongMaterial'), freshU); + await page.waitForTimeout(900); + const aTypes = await typeOf(page, [freshU, liveCopyU]); + h.check( + aTypes.every((t) => t === 'MeshPhongMaterial'), + '6c.2 A switches BOTH objects (the local relink, since the old instance is gone): ' + JSON.stringify(aTypes) + ); + await h.eventually( + () => typeOf(B.page, [freshU, liveCopyU]), + (t) => t[0] === 'MeshPhongMaterial' && t[1] === 'MeshPhongMaterial', + '6c.3 ...and B agrees about both', + 20000 + ); + + // ---------------------------------------------------------------- section 6d + // THE FAN, ISOLATED. On a receiver whose objects already SHARE one instance, a single + // per-object message reaches both for free — which is why the fan looked unnecessary + // until it was measured against a receiver whose instances are still SPLIT. That is a + // real window (right after the objects arrive, right after a duplicate, and for the + // whole life of a peer on an older build with no reconcile at all), so it is staged + // here deliberately: B's reconcile is stopped and its instances separated by hand. + console.log('\n=== 6d. the fan, isolated: a receiver whose instances are still split ==='); + const staged = await B.page.evaluate( + ({ a, b }) => { + window.__stores.materialSharing.stopMaterialSharing(); + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const ob = group.getObjectByProperty('uuid', b); + ob.material = ob.material.clone(); + const oa = group.getObjectByProperty('uuid', a); + return { + split: oa.material !== ob.material, + idsMatch: oa.userData.materialId === ob.userData.materialId + }; + }, + { a: freshU, b: liveCopyU } + ); + h.check( + staged.split && staged.idsMatch, + '6d.1 premise: B holds two instances with one id, and its reconcile is stopped' + ); + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#ff00aa'), freshU); + await h.eventually( + () => colours(B.page, [freshU, liveCopyU]), + (c) => c[0] === 'ff00aa' && c[1] === 'ff00aa', + '6d.2 the edit still reaches BOTH — because the sender fanned it', + 20000 + ); + await B.page.evaluate(() => { + window.__stores.materialSharing.startMaterialSharing(); + window.__stores.materialSharing.reconcileSharedMaterials(); + }); + await B.page.waitForTimeout(600); + h.check( + (await shareState(B.page, freshU, liveCopyU)).same, + '6d.3 ...and B re-unifies once its reconcile is running again' + ); + + // ---------------------------------------------------------------- section 7 + console.log('\n=== 7. a late joiner ==='); + const C = await h.setupPage(browser, 'C'); + await h.connect(C, A); + await h.eventually( + () => shareState(C.page, freshU, liveCopyU), + (s) => s.found && s.idA && s.idA === s.idB && s.same, + '7.1 a late joiner receives the share, not two materials that happen to match', + 35000 + ); + await page.evaluate((u) => window.__stores.materialsHandler.setObjectColor(u, '#11ddcc'), freshU); + await h.eventually( + () => colours(C.page, [freshU, liveCopyU]), + (c) => c[0] === '11ddcc' && c[1] === '11ddcc', + '7.2 ...and edits reach both objects on it too', + 20000 + ); + + h.check(h.pageErrors(A).length === 0, '7.3 no page errors on A (' + JSON.stringify(h.pageErrors(A)) + ')'); + h.check(h.pageErrors(B).length === 0, '7.4 no page errors on B (' + JSON.stringify(h.pageErrors(B)) + ')'); + + await h.finish(browser); +}); From f3abca7b0a063172b7f9e04998749aebf2874d4a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 16 Sep 2026 11:46:54 +0300 Subject: [PATCH 14/17] [feat] 24-A A4: the Stars Room, and two core bugs the authoring found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE GAME. A zero-gravity room of 24 glowing stars you knock around with your hands in VR or by walking into them; free play is the default and P opens the round ("light every star") with a per-player touch leaderboard. The first game that needs NO module download — pure core, so the Games tab shows it with no requirement card. - `STARS_DEF` in scripts/author-templates.cjs, authored as DATA on A3's graphBuilder: a 12x7x12 room (see-through walls you bounce off), 24 stars on a SEEDED jittered lattice (an LCG at module scope, so the def is stable data and two builds place every star alike), 2 planets for contrast, a template under the floor for the spawner, and two physical onclick PADS — the DOM HUD is invisible in a headset (F8), so Start and More stars have in-scene buttons as well as menu ones. - physics: gravity 0, ground off, bounds respawn, damping 0.35/0.2, grab + simOnPlay, and the A1 `knock` block ON (gain 1, maxSpeed 10, spin 0.6). Look: a LIT preset at exposure 0.55 + bloom (F14 — never `night` alone), ao/AGX/vignette/smaa. - the graph: per star an `onhit` scaling a particle burst by its `speed` output and ringing a chime, an `onhit who:'me'` banking a per-player `touches`, and a perRound latch -> Select -> Set Color that lights it during a round; a 23-node add chain -> "Lit: n / 24" -> compare -> allplayers -> `over`. Towers' menu/pause/over shell re-skinned; NO shared HUD builder (D1 decides that with three documents on the table). - the default screen is `input: 'game'`, deliberately: a `menu`-input screen visible while playing releases the pointer lock (21-E3), which would make free play unplayable on desktop. Every button lives on the P menu or a pad. - NEW def field `sounds: [{key, name, url, sha256}]` (additive): fetched in node like `music`, dropped into the Explorer, addressed from a Sound node as `'$sound:'` through A3's widened remap — but NOT the scene's music slot, because a chime is an asset a node plays, not background music. The chime is audio-essentials' impact-glass.ogg (Kenney, CC0), sha256-pinned; its 8400 bytes ride the .tpscene. TWO CORE BUGS, both found by authoring and both fixed here: - `peervariable` was MISSING from flowRuntime's `valueTypes`, so `resolveInputs` refused it as a wire source and a Player Variable node delivered NOTHING to anything wired to it — the consumer silently kept its own dialled value. Shipped since 21-G4, and it is the exact `peervariable -> hudtext` shape CLAUDE.md prescribes for this game. Measured: two readouts rendered 0 while the leaderboard beside them, which reads peerVars directly rather than through a wire, read 1. `peer-variables` gains section 1c, which wires the node into a Math node and asks the CONSUMER what it resolved — the existing 1b evaluates the node IN ISOLATION, proves only the evaluator, and is why this shipped. - A SUSPENDED object lost its whole effect list, and `physics.trackBody` suspends every dynamic body that is a flow-animation target for the whole run ("dynamic wins over an animation"). So a Set Color on a dynamic body applied once in the frames before the sim's bodies existed and then STUCK — material colour is not base-managed, so nothing repainted it. `POSE_FREE_EFFECTS` (setcolor, setuniform, deviceparam, notetrigger) now still run for a suspended object, with no base restore and no pose effects; visibility, module effects, scripts and custom nodes stay suspended because they may write a pose. Diagnosed in this order: the wire resolved correctly under both clocks, the file's wiring was right, and then changing the node's DIALLED colour live did nothing — which is what separates "not applying" from "resolving wrong". - suite `game-stars-room` (36 checks, skip-never-fail, driving the REAL .tpscene; takes `STARS_ROOM_TPSCENE` so a lane can point it at a scratch build): the physics block restored from the file, 36 objects / 27 dynamic, the chime's bytes in the Explorer, a probe pass leaving Star 1 at ~4 m/s and damping bleeding it by half in 3 s, a 10 m/s star still inside the room 2 s later, More stars +3 with the cap holding at 59 (the spawner RECYCLES oldest-out, it does not refuse), one touch banked in my own row, the round lighting every star and ending in `over`, and a NEW round un-lighting them. - counterfactuals, each red then restored: reverting POSE_FREE_EFFECTS to the blanket skip turns `...and Star 3 is painted lit` red (the star reads the dialled #3b3f66 again); removing `peervariable` from valueTypes turns `my touch row reads 1` red (the HUD keeps the node's own 0 while the leaderboard reads 1). - held: peer-variables 73/0 (incl. the new 1c), knock-node 50/0, play-interact 46/0, logic-nodes 77/0, animation-node 35/0 in the 9-suite battery. Its four reds A/B'd against base: collectibles-v2 is WORSE on base (68/2 vs 69/1); flow-physics-actions' 8.5 fails identically on base and its 2.1 is a first-sample timing read (-0.00 -> 1.28, same end state); possess asserts the avatar module, which left core in 17-A and has no zip on this box; game-towers' 'Restart cleared the height latches' went 19/1 once alone and then 20/0 three times on the branch (20/0 on base src) - a flake (likely a crate resting in a height sensor re-lights the 1m latch), and Towers holds none of the four POSE_FREE_EFFECTS node types or a peervariable. - 'all 24 latches read lit' asserts the TRIGGER LOG (every star's hit stamp at or after the round's startedAt), not the count node: the instant the last star lights, allplayers flips the round to `over`, whose Infinity cutoff reads every perRound latch un-lit again, and flowValues publishes only every 150ms - so the count read 24 for at most one publish and a poll lost the race (measured `last: 0` with the `over` check right after it green, even on a 25s budget). The `over` check still proves the count chain reached 24. The stamp read folds startedAt (epoch ms) into the tick clock's seconds-of-day exactly as retiredByRound does - a raw `>=` read 0 - and a premise check right after Star 3 lights (the log counts exactly 1, Star 1 having been knocked before Start) keeps it from passing vacuously. - svelte-check 361/47, entry list identical to base; build green. Co-Authored-By: Claude Opus 5 --- scripts/author-templates.cjs | 331 ++++++++++++++++++++++++++++- src/lib/flowRuntime.js | 40 +++- tests/e2e/game-stars-room.test.cjs | 261 +++++++++++++++++++++++ tests/e2e/peer-variables.test.cjs | 32 +++ 4 files changed, 660 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/game-stars-room.test.cjs diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 8859460e..fb271b7c 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -388,6 +388,317 @@ const TOWERS_DEF = { ] }; +// ---- 24-A A4: Stars Room, the second GAME def ------------------------------------- +// The first game that needs NO module download: a zero-g room you knock stars around +// in (A1's knock, A2's On Hit), pure core. Sandbox by default (locked fork 4): the sim +// runs on Play, the stars react, the touch leaderboard counts; the P menu (or the +// physical Start pad, for VR — F8: the DOM HUD is invisible in a headset) opens the +// OPTIONAL round: light every star. Design notes worth keeping: +// · the default screen is \`input: 'game'\` — a \`menu\`-input screen visible while +// playing releases the pointer lock (21-E3), which would make free play unplayable +// on desktop; every button lives on the P menu, plus two onclick PADS for VR; +// · the lit colour rides latch -> Select -> Set Color. The editor would refuse to draw +// Select (number) into Set Color's colour input, but the runtime reads whatever the +// wire resolves to and Select passes a string through raw — recorded as a follow-up +// (a Select typed by its wired inputs, or a Select Colour node); +// · the spawner RECYCLES oldest-out at maxAlive, it does not refuse — "More stars" +// therefore never fails, and the room holds at most 27 + 32 dynamic bodies; +// · the chime is a def-level \`sounds\` entry (A4, additive): fetched like \`music\`, +// dropped into the Explorer, addressed by \`'$sound:'\` from a Sound node — NOT +// \`music\`, which would also fill the scene's background-music slot; +// · dark by nature, so authored on a LIT preset at low exposure + bloom (F14, the +// Towers finding); the look on the user's display is owed, never assumed. +const STARS_HUD_PANEL = { + bg: 'rgba(8, 10, 24, 0.9)', + radius: 16, + border: '1px solid rgba(255, 212, 94, 0.25)' +}; +const STARS_CHIME = { + key: 'chime', + name: 'impact-glass.ogg', + url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1/audio-essentials/assets/impact-glass.ogg', + sha256: '9252d50bfb85edb17d6073c4a7806e10cdb9de56d3dbfc93a4b9727146d2df6d', + credit: { what: 'Impact Glass', author: 'Kenney', license: 'CC0-1.0', source: 'https://kenney.nl/assets/impact-sounds' } +}; +const STAR_DIM = '#3b3f66'; +const STAR_LIT = '#ffe08a'; +/** a seeded LCG, so the lattice jitter is DATA and two builds place every star alike + * @param {number} seed */ +function seeded(seed) { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} +/** 24 stars on a jittered 4 x 6 lattice at hand height (0.8-2.6 m), r 0.16-0.3 */ +function starObjects() { + const rand = seeded(24); + const palette = [ + { color: 0xffe08a, emissive: 0xffcf50 }, + { color: 0x9ad0ff, emissive: 0x5aa8ff }, + { color: 0xffb0d8, emissive: 0xff6ab0 }, + { color: 0xc8ffb0, emissive: 0x8aff6a } + ]; + /** @type {any[]} */ const out = []; + let i = 0; + for (let gx = 0; gx < 4; gx++) + for (let gz = 0; gz < 6; gz++) { + i++; + const x = -3.9 + gx * 2.6 + (rand() - 0.5) * 1.2; + const z = -4.5 + gz * 1.8 + (rand() - 0.5) * 1.0; + const y = 0.8 + rand() * 1.8; + const r = 0.16 + rand() * 0.14; + const p = palette[(i - 1) % palette.length]; + out.push({ + type: 'sphere', name: 'Star ' + i, color: p.color, r: +r.toFixed(3), + pos: [+x.toFixed(2), +y.toFixed(2), +z.toFixed(2)], + emissive: p.emissive, emissiveIntensity: 1.2, roughness: 0.4, + physics: { mode: 'dynamic', mass: 0.2, restitution: 0.9, friction: 0.1 } + }); + } + return out; +} + +function starsGraph() { + const g = graphBuilder(); + const { N, E } = g; + // ---- the round: Start (P menu or the VR pad) -> playing; Back to menu ------------ + N('bstart', 'hudbutton', 'Start button', 40, 40, { element: 'start-btn' }); + N('gostart', 'setgamestate', 'Start round', 280, 40, { state: 'playing', outcome: '', reset: false }); + E('bstart', 'gostart', 'trigger'); + N('padstart', 'onclick', 'Start pad clicked', 40, 120, { pulse: 0.3 }); + N('selstartpad', 'objectselector', 'Start pad', 280, 120, { selected: 'Start pad' }); + E('padstart', 'selstartpad'); + E('padstart', 'gostart', 'trigger'); + N('starthide', 'hudscreen', 'Close menu on start', 520, 40, { screen: 'pause', action: 'hide' }); + E('bstart', 'starthide', 'trigger'); + N('bagain', 'hudbutton', 'Play again button', 40, 200, { element: 'again-btn' }); + N('gomenu', 'setgamestate', 'Back to menu', 280, 200, { state: 'menu', outcome: '', reset: true }); + E('bagain', 'gomenu', 'trigger'); + // ---- the P menu (Towers' pause, plus Start / More stars) -------------------------- + N('pkey', 'keypress', 'Press P', 40, 340, { code: 'KeyP', edge: 'down', pulse: 0.3 }); + N('pausetoggle', 'hudscreen', 'Toggle menu', 280, 340, { screen: 'pause', action: 'toggle' }); + E('pkey', 'pausetoggle', 'trigger'); + N('bresume', 'hudbutton', 'Resume button', 40, 490, { element: 'resume-btn' }); + N('resumehide', 'hudscreen', 'Close menu', 280, 490, { screen: 'pause', action: 'hide' }); + E('bresume', 'resumehide', 'trigger'); + N('brestart', 'hudbutton', 'Restart button', 40, 640, { element: 'restart-btn' }); + N('restartreset', 'setgamestate', 'Restart: to menu', 280, 640, { state: 'menu', outcome: '', reset: true }); + N('restartdelay', 'delay', 'Restart: wait', 520, 640, { seconds: 0.2, pulse: 0.3 }); + N('restartplay', 'setgamestate', 'Restart: play', 760, 640, { state: 'playing', outcome: '', reset: false }); + N('restarthide', 'hudscreen', 'Close menu on restart', 280, 760, { screen: 'pause', action: 'hide' }); + E('brestart', 'restartreset', 'trigger'); + E('brestart', 'restartdelay', 'trigger'); + E('restartdelay', 'restartplay', 'trigger'); + E('brestart', 'restarthide', 'trigger'); + N('bquit', 'hudbutton', 'Quit to free play button', 40, 790, { element: 'quit-btn' }); + N('doquit', 'setgamestate', 'Quit to free play', 280, 790, { state: 'menu', outcome: '', reset: true }); + N('quithide', 'hudscreen', 'Close menu on quit', 520, 790, { screen: 'pause', action: 'hide' }); + E('bquit', 'doquit', 'trigger'); + E('bquit', 'quithide', 'trigger'); + // ---- More stars: the menu button or the VR pad spawns 3 copies of the template ---- + N('bmore', 'hudbutton', 'More stars button', 40, 940, { element: 'more-btn' }); + N('padmore', 'onclick', 'More pad clicked', 40, 1020, { pulse: 0.3 }); + N('selmorepad', 'objectselector', 'More stars pad', 280, 1020, { selected: 'More stars pad' }); + E('padmore', 'selmorepad'); + N('seltpl', 'objectselector', 'Star template', 280, 940, { selected: 'Star template' }); + // \`at\` is an OFFSET from the template (under the floor at y -2): y 4 lands copies at 2 m + N('spawn', 'spawn', 'Spawn 3 stars', 520, 940, { x: 0, y: 4, z: 0, count: 3, maxAlive: 32, interval: 0.5, spread: 1.5 }); + E('bmore', 'spawn', 'trigger'); + E('padmore', 'spawn', 'trigger'); + E('seltpl', 'spawn', 'source'); + // ---- touches: per-player rows (one writer each), the sum, the leaderboard --------- + N('touch', 'setvariable', 'Count my touch', 520, 1240, { name: 'touches', value: 1, op: 'add', scope: 'player' }); + N('mytouch', 'peervariable', 'My touches', 40, 1390, { name: 'touches', read: 'mine', peer: '', fallback: 0 }); + N('hmine', 'hudtext', 'HUD my touches', 280, 1390, { element: 'touches-read', format: 'Your touches: {v}', decimals: 0, value: 0 }); + E('mytouch', 'hmine', 'value'); + N('hmine2', 'hudtext', 'HUD my touches (round)', 520, 1390, { element: 'touches-read-2', format: 'Your touches: {v}', decimals: 0, value: 0 }); + E('mytouch', 'hmine2', 'value'); + N('sumtouch', 'peervariable', 'All touches', 40, 1540, { name: 'touches', read: 'sum', peer: '', fallback: 0 }); + N('hsum', 'hudtext', 'HUD all touches', 280, 1540, { element: 'total-read', format: 'Touches: {v}', decimals: 0, value: 0 }); + E('sumtouch', 'hsum', 'value'); + N('board', 'leaderboard', 'Touch leaderboard', 520, 1540, { element: 'board', variable: 'touches', order: 'desc', format: '{name} — {v}', decimals: 0, limit: 8 }); + N('board2', 'leaderboard', 'Touch leaderboard (round)', 760, 1540, { element: 'board-2', variable: 'touches', order: 'desc', format: '{name} — {v}', decimals: 0, limit: 8 }); + // ---- the round clock --------------------------------------------------------- + N('clock', 'gametime', 'Round clock', 40, 1690, { read: 'elapsed', length: 600 }); + N('hclock', 'hudtext', 'HUD clock', 280, 1690, { element: 'clock', format: '{v}s', decimals: 0, value: 0 }); + E('clock', 'hclock', 'value'); + N('hfinal', 'hudtext', 'HUD final time', 520, 1690, { element: 'final-time', format: 'Every star lit in {v}s', decimals: 0, value: 0 }); + E('clock', 'hfinal', 'value'); + // ---- per star: burst + chime on any hit, a per-player touch on MY hit, a perRound + // latch that paints the star lit during a round --------------------------------------- + let prevSum = ''; + for (let i = 1; i <= 24; i++) { + const y = 1900 + (i - 1) * 180; + N('sel' + i, 'objectselector', 'Star ' + i, 1000, y, { selected: 'Star ' + i }); + N('hit' + i, 'onhit', 'Star ' + i + ' hit', 40, y, { pulse: 0.3, minSpeed: 0.3, who: 'anyone' }); + E('hit' + i, 'sel' + i); + N('mulc' + i, 'math', 'Burst size ' + i, 280, y, { op: 'mul', a: 0, b: 15 }); + E('hit' + i, 'mulc' + i, 'a', 'speed'); + N('pfx' + i, 'particle', 'Star ' + i + ' burst', 520, y, { + mode: 'burst', count: 40, lifetime: 0.9, speed: 1.8, gravity: 0, + turbulence: 0.3, sizeStart: 0.08, opacity: 0.9, sprite: 'star', blending: 'additive', space: 'world' + }); + E('hit' + i, 'pfx' + i, 'trigger'); + E('mulc' + i, 'pfx' + i, 'count'); + E('pfx' + i, 'sel' + i); + N('snd' + i, 'sound', 'Star ' + i + ' chime', 760, y, { + hash: '$sound:chime', file: STARS_CHIME.name, volume: 0.7, radius: 8, rolloff: 1, loop: false, playing: false + }); + E('hit' + i, 'snd' + i, 'trigger'); + E('snd' + i, 'sel' + i); + N('me' + i, 'onhit', 'Star ' + i + ' my hit', 40, y + 90, { pulse: 0.3, minSpeed: 0.3, who: 'me' }); + E('me' + i, 'sel' + i); + E('me' + i, 'touch', 'trigger'); + N('lat' + i, 'latch', 'Star ' + i + ' lit', 1240, y, { initial: false, perRound: true }); + E('hit' + i, 'lat' + i, 'set'); + N('lit' + i, 'select', 'Star ' + i + ' colour', 1480, y, { index: 0, a: STAR_DIM, b: STAR_LIT }); + E('lat' + i, 'lit' + i, 'index'); + N('col' + i, 'setcolor', 'Star ' + i + ' paint', 1720, y, { color: STAR_DIM, whilePlaying: true }); + E('lit' + i, 'col' + i, 'color'); + E('col' + i, 'sel' + i); + if (i === 2) { + N('sum2', 'math', 'Lit 1-2', 1960, y, { op: 'add', a: 0, b: 0 }); + E('lat1', 'sum2', 'a'); + E('lat2', 'sum2', 'b'); + prevSum = 'sum2'; + } else if (i > 2) { + N('sum' + i, 'math', 'Lit 1-' + i, 1960, y, { op: 'add', a: 0, b: 0 }); + E(prevSum, 'sum' + i, 'a'); + E('lat' + i, 'sum' + i, 'b'); + prevSum = 'sum' + i; + } + } + N('hlit', 'hudtext', 'HUD lit', 2200, 2000, { element: 'lit-read', format: 'Lit: {v} / 24', decimals: 0, value: 0 }); + E('sum24', 'hlit', 'value'); + N('alllit', 'compare', 'All lit?', 2200, 2150, { op: 'gte', a: 0, b: 24 }); + E('sum24', 'alllit', 'a'); + N('allwin', 'allplayers', 'Everyone agrees', 2440, 2150, { pulse: 0.3 }); + E('alllit', 'allwin', 'condition'); + N('gowin', 'setgamestate', 'Round won', 2680, 2150, { state: 'over', outcome: 'Every star lit!', reset: false }); + E('allwin', 'gowin', 'trigger'); + return g.done(); +} + +const STARS_TEXT = { size: 13, color: '#d8dee9', align: 'center' }; +const STARS_BTN = { size: 16, weight: '600', bg: '#3b7dd8', color: '#ffffff', radius: 10 }; +const STARS_DEF = { + kind: 'game', + slug: 'stars-room', + title: 'Stars Room', + description: + 'A zero-gravity room full of glowing stars. Knock them with your hands in VR or walk into them; press P for the round: light every star, and see who touched the most.', + license: 'CC0-1.0', + author: 'theprototype', + tags: ['zero-g', 'physics', 'sandbox', 'vr'], + // pure core — the first game that needs no download (no 'modules', no 'installModules') + env: { preset: 'studio', exposure: 0.55 }, + physics: { + gravity: 0, + ground: { enabled: false }, + bounds: { limit: -50, action: 'respawn' }, + material: { friction: 0.1, restitution: 0.85 }, + damping: { linear: 0.35, angular: 0.2 }, + ccd: false, + play: { interaction: 'grab', grounded: false, simOnPlay: true }, + knock: { enabled: true, gain: 1, maxSpeed: 10, radius: 0.12, spin: 0.6 } + }, + post: { + enabled: true, + effects: [ + { id: 'ao', kind: 'ao', enabled: true, params: {} }, + { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } }, + { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: 1.2, luminanceThreshold: 0.55 } }, + { id: 'vig', kind: 'vignette', enabled: true, params: {} }, + { id: 'aa', kind: 'smaa', enabled: true, params: {} } + ], + changedAt: 0 + }, + sounds: [STARS_CHIME], + view: { pos: [0, 3.4, 11], target: [0, 1.6, 0] }, + graphs: { scene: starsGraph() }, + hud: { + scene: { + active: '', + changedAt: 0, + screens: [ + { + id: 'free', + name: 'Free play', + showWhile: 'menu', + input: 'game', + elements: [ + { id: 'free-title', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 420, h: 30, z: 1, label: 'STARS ROOM · free play', style: { size: 18, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'free-hint', kind: 'text', anchor: 'top-center', x: 0, y: 44, w: 520, h: 22, z: 1, label: 'Knock the stars. P: menu (start a round, more stars)', style: { size: 12, color: '#8b97a8', align: 'center' } }, + { id: 'touches-read', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 220, h: 24, z: 1, label: '', style: { size: 14, color: '#ffd45e', align: 'right' } }, + { id: 'total-read', kind: 'text', anchor: 'top-right', x: 16, y: 40, w: 220, h: 22, z: 1, label: '', style: { size: 12, color: '#c8d0dc', align: 'right' } }, + { id: 'board', kind: 'list', anchor: 'top-right', x: 16, y: 70, w: 220, h: 150, z: 1, label: '', title: 'Touches', rowsText: '', rows: 8, rowHeight: 18, style: { size: 12, bg: 'rgba(8, 10, 24, 0.6)', radius: 8, pad: 6 } } + ] + }, + { + id: 'hud', + name: 'Round', + showWhile: 'playing', + input: 'game', + elements: [ + { id: 'lit-read', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 280, h: 30, z: 1, label: '', style: { size: 18, weight: '600', color: '#ffe08a', align: 'center' } }, + { id: 'clock', kind: 'text', anchor: 'top-center', x: 0, y: 46, w: 120, h: 22, z: 1, label: '', style: { size: 13, color: '#c8d0dc', align: 'center' } }, + { id: 'touches-read-2', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 220, h: 24, z: 1, label: '', style: { size: 14, color: '#ffd45e', align: 'right' } }, + { id: 'board-2', kind: 'list', anchor: 'top-right', x: 16, y: 44, w: 220, h: 150, z: 1, label: '', title: 'Touches', rowsText: '', rows: 8, rowHeight: 18, style: { size: 12, bg: 'rgba(8, 10, 24, 0.6)', radius: 8, pad: 6 } }, + { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 520, h: 20, z: 1, label: 'Light every star. P: menu', style: { size: 11, color: '#8b97a8', align: 'center' } } + ] + }, + { + id: 'pause', + name: 'Menu', + input: 'menu', + elements: [ + { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 400, h: 400, z: 0, label: '', style: STARS_HUD_PANEL }, + { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -150, w: 360, h: 36, z: 1, label: 'STARS ROOM', style: { size: 28, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'pause-sub', kind: 'text', anchor: 'center', x: 0, y: -112, w: 360, h: 40, z: 1, label: 'Zero gravity. Knock the stars with your hands in VR, or walk into them.', style: STARS_TEXT, wrap: true }, + { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: -50, w: 250, h: 42, z: 1, label: 'Start round: light every star', enabled: true, style: STARS_BTN }, + { id: 'restart-btn', kind: 'button', anchor: 'center', x: 0, y: 0, w: 250, h: 42, z: 1, label: 'Restart round', enabled: true, style: { ...STARS_BTN, bg: '#4c9e6a' } }, + { id: 'more-btn', kind: 'button', anchor: 'center', x: 0, y: 50, w: 250, h: 42, z: 1, label: 'More stars', enabled: true, style: { ...STARS_BTN, bg: '#b0863b' } }, + { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: 100, w: 250, h: 42, z: 1, label: 'Resume', enabled: true, style: { ...STARS_BTN, size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0' } }, + { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 150, w: 250, h: 42, z: 1, label: 'Quit to free play', enabled: true, style: { ...STARS_BTN, size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0' } } + ] + }, + { + id: 'over', + name: 'Round over', + showWhile: 'over', + input: 'menu', + elements: [ + { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 420, h: 250, z: 0, label: '', style: STARS_HUD_PANEL }, + { id: 'over-title', kind: 'text', anchor: 'center', x: 0, y: -70, w: 380, h: 40, z: 1, label: 'EVERY STAR LIT', style: { size: 30, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'final-time', kind: 'text', anchor: 'center', x: 0, y: -18, w: 380, h: 26, z: 1, label: '', style: { size: 16, color: '#e5e9f0', align: 'center' } }, + { id: 'again-btn', kind: 'button', anchor: 'center', x: 0, y: 58, w: 220, h: 44, z: 1, label: 'Back to free play', enabled: true, style: STARS_BTN } + ] + } + ] + } + }, + objects: [ + // the room: 12 x 7 x 12, walls you can see through and bounce off + { type: 'box', name: 'Floor', color: 0x101626, size: [12, 0.5, 12], pos: [0, -0.25, 0], roughness: 0.9, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Ceiling', color: 0x101626, size: [12, 0.5, 12], pos: [0, 7.25, 0], roughness: 0.9, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall north', color: 0x2a3a6a, size: [12.5, 7, 0.5], pos: [0, 3.5, -6], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall south', color: 0x2a3a6a, size: [12.5, 7, 0.5], pos: [0, 3.5, 6], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall west', color: 0x2a3a6a, size: [0.5, 7, 12.5], pos: [-6, 3.5, 0], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'box', name: 'Wall east', color: 0x2a3a6a, size: [0.5, 7, 12.5], pos: [6, 3.5, 0], emissive: 0x1a2a5a, emissiveIntensity: 0.5, opacity: 0.12, physics: { mode: 'static', friction: 0.1, restitution: 0.85 } }, + { type: 'light', name: 'Room light', kind: 'point', color: 0x9fb4ff, intensity: 6, distance: 16, pos: [0, 5.5, 0] }, + // the two physical buttons (an onclick fires from a VR ray — the HUD is not in a headset) + { type: 'box', name: 'Start pad', color: 0x3b7dd8, size: [0.6, 0.16, 0.6], pos: [-1, 0.08, -4.8], emissive: 0x1f4f9f, emissiveIntensity: 0.9, roughness: 0.4, physics: { mode: 'static' } }, + { type: 'box', name: 'More stars pad', color: 0xb0863b, size: [0.6, 0.16, 0.6], pos: [1, 0.08, -4.8], emissive: 0x7a5a1f, emissiveIntensity: 0.9, roughness: 0.4, physics: { mode: 'static' } }, + // the stars, two planets for contrast, and the spawner's template under the floor + ...starObjects(), + { type: 'sphere', name: 'Planet Azure', color: 0x5b7fd6, r: 0.6, pos: [-3, 1.9, 2.2], emissive: 0x1f3f9f, emissiveIntensity: 0.35, roughness: 0.6, physics: { mode: 'dynamic', mass: 2, restitution: 0.7, friction: 0.2 } }, + { type: 'sphere', name: 'Planet Ember', color: 0xd68a5b, r: 0.6, pos: [3.2, 2.3, -2.4], emissive: 0x8f3a1a, emissiveIntensity: 0.35, roughness: 0.6, physics: { mode: 'dynamic', mass: 2, restitution: 0.7, friction: 0.2 } }, + { type: 'sphere', name: 'Star template', color: 0xffe08a, r: 0.22, pos: [0, -2, 0], emissive: 0xffcf50, emissiveIntensity: 1.2, roughness: 0.4, physics: { mode: 'dynamic', mass: 0.2, restitution: 0.9, friction: 0.1 } } + ] +}; + // ---- 28-G: the first two contests — Make a mirror, Follow the beat --------------- // Both are DATA (spec: cloud plans-core/28-g-contests-mirror-and-beat.md). The mirror // starter ships the answer as a faint ghost; the beat starter ships a CC0 track, a @@ -974,6 +1285,7 @@ const DEFS = [ ] }, TOWERS_DEF, + STARS_DEF, MIRROR_DEF, BEAT_DEF ]; @@ -1033,7 +1345,12 @@ const DEFS = [ // CDN, and the file belongs to no repo); the page hands the bytes to the Explorer, // which is what makes them a scene asset the .tpscene bundles. const music = def.music ? { ...def.music, b64: (await fetchMusic(def.music)).toString('base64') } : null; - const out = await page.evaluate(async ({ d, music, humanTextKeys }) => { + // 24-A A4: one-shot SOUNDS a graph plays (a def-level list, ADDITIVE). Same fetch and + // the same Explorer drop as the track, but NOT the music slot: a chime is a scene + // asset a Sound node addresses through '$sound:', never background music. + const sounds = []; + for (const snd of def.sounds ?? []) sounds.push({ ...snd, b64: (await fetchMusic(snd)).toString('base64') }); + const out = await page.evaluate(async ({ d, music, sounds, humanTextKeys }) => { // 24-A A3: the module-scope Set does not cross into the page — it arrives as a list const humanText = new Set(humanTextKeys); const s = window.__stores; @@ -1202,6 +1519,14 @@ const DEFS = [ named['$music'] = item.hash; s.sceneMusic.commitMusic({ hash: item.hash, name: music.name, volume: music.volume ?? 0.8, playing: false, startedAt: 0 }); } + // 24-A A4: the one-shot sounds — Explorer only (content-hashed), `'$sound:'` + // in node data becomes the hash through the widened remap (A3) + for (const snd of sounds) { + if (!s.explorer) break; + const bin = Uint8Array.from(atob(snd.b64), (ch) => ch.charCodeAt(0)); + const item = await s.explorer.addItemFromBytes(bin.buffer, snd.name, null, { imported: true }); + named['$sound:' + snd.key] = item.hash; + } // 28-G: the editor camera the file opens on (buildSessionPayload saves it). BOTH // the camera and the orbit target, or OrbitControls.update() reverts the move. if (d.view) { @@ -1316,7 +1641,7 @@ const DEFS = [ const payload = s.sessions.buildSessionPayload(d.title); // 28-G: a def with music exports WITH assets — the track rides the .tpscene // (sceneAssetList lists the music hash and the Sound node's, one blob for both) - const bytes = await s.sessions.exportSessionZip(payload, { assets: !!music, packs: false, flow: true }); + const bytes = await s.sessions.exportSessionZip(payload, { assets: !!music || sounds.length > 0, packs: false, flow: true }); // fitted offscreen thumbnail — the sessions.js renderSceneThumbnail // approach at card size (480x270 webp) @@ -1378,7 +1703,7 @@ const DEFS = [ if (s.animationPreview) s.animationPreview.animationsRestore({}, false); if (s.sceneMusic) s.sceneMusic.musicRestore(null, false); return { bytes: Array.from(bytes), thumb }; - }, { d: def, music, humanTextKeys: [...HUMAN_TEXT_KEYS] }); + }, { d: def, music, sounds, humanTextKeys: [...HUMAN_TEXT_KEYS] }); const bytes = Buffer.from(out.bytes); const thumb = out.thumb ? Buffer.from(out.thumb.split(',')[1], 'base64') : null; built[def.slug] = { entry: def, bytes, thumb }; diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 3f26ecc9..0f511d67 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -388,6 +388,27 @@ const baseState = new Map(); // animated objects whose animation is paused while the user drags them const suspended = new Set(); +/** + * 24-A A4: the effects a SUSPENDED object still gets. + * + * Suspension means "somebody else owns this object's POSE right now" — a gizmo drag, a + * possess ride, an animation scrub, or (physics.trackBody) a dynamic body for the whole + * run: "dynamic wins over an animation". Skipping the object's WHOLE effect list was too + * broad, because these four write no pose at all: a colour, a shader uniform, a device + * param, a note. Measured in the Stars Room, where every star is a dynamic body with a + * Set Color node: the star painted once in the frames before the sim's bodies existed and + * then STUCK there for the rest of the round — the latch flipped, the wired colour + * resolved (proven: resolveInputs returned the lit colour under both clocks), and nothing + * repainted; changing the node's dialled colour live did nothing either, which is what + * says the node was not applying rather than resolving wrong. The stuck paint survives + * because `restoreBase` carries pose + visibility and never material state. + * + * Deliberately NOT here: `visibility` (base-managed — restoreBase re-asserts it, and the + * restore is exactly what a suspended object must not get), module effects, scripts and + * custom nodes (all of them may write a pose). + */ +const POSE_FREE_EFFECTS = new Set(['setcolor', 'setuniform', 'deviceparam', 'notetrigger']); + /** @param {any} object */ function captureBase(object) { return { @@ -1790,6 +1811,15 @@ export const valueTypes = [ 'hudinput', // 21-D4: the HUD as a SOURCE - what the player set on a slider/toggle/etc // 21-D6 the game shell 'ongamestate', 'getvariable', 'gametime', + // 24-A A4: `peervariable` was MISSING here since 21-G4, and the omission was silent in + // every direction that is easy to look at — it has an OUTPUT type in flowSockets, an + // evaluator case below, and the editor draws its source handle — but `resolveInputs` + // only accepts a source listed HERE, so a Player Variable wired into anything delivered + // NOTHING and the consumer quietly kept its own dialled value. Found authoring the Stars + // Room, whose two `peervariable -> hudtext` readouts (the shape CLAUDE.md prescribes) + // both rendered 0 while the leaderboard beside them, which reads peerVars directly + // rather than through a wire, read 1. + 'peervariable', // 21-F3's `collectcount` MOVED to the collectible module (R3a) — the chain walk was // the one reader that knew the recipe's shape, and the module owns that shape now // 21-E4: the logic a game LOOP is made of. Sequence's value is a handle MAP, @@ -3112,12 +3142,20 @@ function runTick(now) { }); active.forEach((anims, uuid) => { - if (suspended.has(uuid)) return; // user is dragging it — leave it alone const object = sceneObjects.getObjectByProperty('uuid', uuid); if (!object) { baseState.delete(uuid); return; } + // somebody else owns the POSE (a drag, a ride, a dynamic body for the run) — so no + // base restore and no pose effects, but the writers that touch no pose still run + if (suspended.has(uuid)) { + anims.forEach((/** @type {any} */ anim) => { + if (POSE_FREE_EFFECTS.has(anim.type)) + applyAnimation(object, baseState.get(uuid) ?? captureBase(object), anim, effectTime(time), ctx); + }); + return; + } if (!baseState.has(uuid)) baseState.set(uuid, captureBase(object)); const base = baseState.get(uuid); // reset to base, then let each animation add its offset diff --git a/tests/e2e/game-stars-room.test.cjs b/tests/e2e/game-stars-room.test.cjs new file mode 100644 index 00000000..d005ed72 --- /dev/null +++ b/tests/e2e/game-stars-room.test.cjs @@ -0,0 +1,261 @@ +// 24-A A4 ACCEPTANCE — the Stars Room game (a zero-g room of stars you knock around; +// pure core, no module). Loaded from the REAL .tpscene: the sibling scenes checkout's +// games/stars-room/scene.tpscene, or STARS_ROOM_TPSCENE= (a lane builds it into a +// scratch folder with `--only stars-room --out`). Skip-never-fail when neither exists — +// authored content, not core code, must keep a bare checkout green. +// +// What it proves: the physics block restored from the file (zero-g, ground off, the knock +// block ON, the damping), 27 dynamic bodies, the chime's bytes riding the file, free play +// (the sim runs on Play with no round), a probe pass on Star 1 leaving it at hand speed +// and damping bleeding it, the walls keeping a 10 m/s star inside, More stars spawning +// three and the room never holding more than 27 + maxAlive dynamic bodies (the spawner +// RECYCLES at the cap — it does not refuse), one touch banked in MY row, and the optional +// round: Start -> playing, every star swept -> "Lit: 24 / 24" -> over; P toggles the menu. +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_REPO = [ + path.resolve(__dirname, '../../../theprototype.app-scenes'), + path.resolve(__dirname, '../../../scenes') +].find((p) => fs.existsSync(p)); +const TPSCENE = + process.env.STARS_ROOM_TPSCENE || + (SCENES_REPO && path.join(SCENES_REPO, 'games/stars-room/scene.tpscene')); + +h.run(async () => { + if (!TPSCENE || !fs.existsSync(TPSCENE)) { + console.log('SKIP: no games/stars-room/scene.tpscene in a sibling scenes checkout and no STARS_ROOM_TPSCENE'); + return; + } + const browser = await h.launch({ args: h.GPU_ARGS }); + { + 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', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + + const bytes = Array.from(fs.readFileSync(TPSCENE)); + await page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); + await page.waitForTimeout(2500); + + const snap = () => + page.evaluate(() => { + const s = window.__stores; + /** @param {any} st */ + const g = (st) => { let v; st.subscribe((/** @type {any} */ x) => (v = x))(); return v; }; + let group; + s.objectsGroup.subscribe((/** @type {any} */ v) => (group = v))(); + const kids = group.children.map((/** @type {any} */ c) => ({ + name: c.name, uuid: c.uuid, + dynamic: c.userData?.physics?.mode === 'dynamic', + pos: c.position.toArray().map((/** @type {number} */ n) => +n.toFixed(2)) + })); + const phys = g(s.scenePhysics.scenePhysicsDefaults); + return { + kids, + sim: !!g(s.physics.simulating), + state: g(s.gameState.gameState)?.state ?? null, + play: g(s.scenePhysics.scenePlay), + knock: g(s.scenePhysics.sceneKnock), + gravity: g(s.scenePhysics.sceneGravity), + ground: g(s.scenePhysics.scenePhysicsGround), + damping: phys?.damping, + screen: s.hudDocs.visibleScreen('scene')?.id ?? null + }; + }); + const hud = async () => (await page.locator('#hud-layer').textContent()) ?? ''; + // EXACT text: `hasText` is a case-insensitive SUBSTRING, and "Restart round" contains + // "start round" — a bare 'Start round' matched two buttons and died on strict mode. + const clickBtn = (text) => page.getByRole('button', { name: text, exact: true }).click(); + const pressP = () => + page.evaluate(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); + }); + const bodyOf = (uuid) => + page.evaluate((uuid) => window.__stores.physics.physicsDebug().find((b) => b.uuid === uuid) ?? null, uuid); + const speedOf = (b) => (b?.linvel ? Math.hypot(b.linvel.x, b.linvel.y, b.linvel.z) : 0); + /** sweep a fresh probe along +x through a star's CURRENT centre at `speed` m/s; returns + * hits + the star's velocity read in the same evaluate (before rapier steps) */ + const knockStar = (name, speed, probe = 'p') => + page.evaluate( + ({ name, speed, probe }) => { + const s = window.__stores; + let group; s.objectsGroup.subscribe((/** @type {any} */ v) => (group = v))(); + const star = group.getObjectByName(name); + if (!star) return { hits: 0, atHit: null, missing: true }; + const [cx, cy, cz] = star.position.toArray(); + s.knock.dropProbe(probe); + const dt = 16; + const step = (speed * dt) / 1000; + let t = 1000 + Math.floor(Math.random() * 1e6); + let hits = 0; + let atHit = null; + for (let x = cx - 1.2; x <= cx + 1e-9; x += step) { + const r = s.knock.feedProbe(probe, [x, cy, cz], t); + if (r.hits > 0 && !atHit) { + const b = s.physics.physicsDebug().find((entry) => entry.uuid === star.uuid); + atHit = b?.linvel ? [b.linvel.x, b.linvel.y, b.linvel.z] : null; + } + hits += r.hits; + t += dt; + } + return { hits, atHit, uuid: star.uuid }; + }, + { name, speed, probe } + ); + const mag = (v) => (Array.isArray(v) ? Math.hypot(v[0], v[1], v[2]) : NaN); + + // 1 — the world arrived whole, with its physics block + let st = await snap(); + const dyn = st.kids.filter((k) => k.dynamic); + h.check(st.kids.length === 36, `36 objects arrived (${st.kids.length})`); + h.check(dyn.length === 27, `27 dynamic bodies: 24 stars + 2 planets + the template (${dyn.length})`); + h.check(st.kids.filter((k) => /^Star \d+$/.test(k.name)).length === 24, 'the 24 stars are named Star 1..24'); + h.check(st.gravity === 0 && st.ground?.enabled === false, `zero-g with the ground off (${st.gravity}, ground ${st.ground?.enabled})`); + h.check(st.knock?.enabled === true && Math.abs(st.knock.maxSpeed - 10) < 1e-9 && Math.abs(st.knock.spin - 0.6) < 1e-9, `the knock block restored ON from the file (${JSON.stringify(st.knock)})`); + h.check(Math.abs((st.damping?.linear ?? 0) - 0.35) < 1e-9, `damping 0.35 (${st.damping?.linear})`); + h.check(st.play?.simOnPlay === true && st.play?.interaction === 'grab' && st.play?.grounded === false, 'play block: grab, flying, simOnPlay'); + h.check(st.state === 'menu' && st.screen === 'free', `starts in free play (${st.state}/${st.screen})`); + const chime = await page.evaluate(() => { + const s = window.__stores; + const snd = s.allNodes().find((n) => n.type === 'sound'); + const hash = snd?.data?.hash ?? null; + return { hash, held: hash ? !!s.explorer.itemByHash(hash) : false, nodes: s.allNodes().length }; + }); + h.check(!!chime.hash && /^[0-9a-f]{16,}$/.test(chime.hash) && chime.held, `the chime's hash was remapped and its bytes rode the file into the Explorer (${chime.hash?.slice(0, 8)}, held ${chime.held})`); + h.check(chime.nodes > 200, `the graph is there (${chime.nodes} nodes)`); + + // 2 — entering play starts the sim (free play, no round) + await page.evaluate(() => window.__stores.isLocked.set(true)); + await h.eventually(() => snap().then((v) => v.sim), (v) => v === true, 'entering play starts the sim', 10000); + await page.waitForTimeout(600); + st = await snap(); + h.check(st.state === 'menu' && st.screen === 'free', 'free play: the sim runs while the game shell stays in menu'); + h.check(/STARS ROOM/.test(await hud()) && /free play/.test(await hud()), 'the free-play banner renders'); + + // 3 — a hand knocks Star 1: it leaves at hand speed, and damping bleeds it + const k1 = await knockStar('Star 1', 4); + h.check(k1.hits === 1, `a 4 m/s pass knocks Star 1 once (${k1.hits})`); + h.check(!!k1.atHit && Math.abs(mag(k1.atHit) - 4) < 0.4, `...and it leaves at ~4 m/s (${mag(k1.atHit).toFixed(2)})`); + await page.waitForTimeout(500); + const v05 = speedOf(await bodyOf(k1.uuid)); + await page.waitForTimeout(2500); + const v3 = speedOf(await bodyOf(k1.uuid)); + h.check(v05 > 1 && v3 < 0.5 * v05, `damping: ${v05.toFixed(2)} m/s at +0.5 s -> ${v3.toFixed(2)} at +3 s (less than half)`); + await h.eventually(async () => await hud(), (t) => /Your touches: 1/.test(t), 'my touch row reads 1 (who: me, banked once)', 6000); + + // 4 — the walls keep a fast star inside + const star2 = st.kids.find((k) => k.name === 'Star 2'); + await page.evaluate((uuid) => + window.__stores.physics.applyThrow({ uuid, pos: [-5, 2, 0], rot: [0, 0, 0], linvel: [-10, 0, 0], angvel: [0, 0, 0] }), star2.uuid); + await page.waitForTimeout(2000); + const s2 = (await snap()).kids.find((k) => k.uuid === star2.uuid); + h.check(!!s2 && Math.abs(s2.pos[0]) < 6 && Math.abs(s2.pos[2]) < 6 && s2.pos[1] > -0.5 && s2.pos[1] < 7.5, `a 10 m/s star at the west wall is inside the room 2 s later (${s2?.pos})`); + + // 5 — More stars (the P menu): +3 copies of the template, and the cap holds + await pressP(); + await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'pause', 'P opens the menu', 6000); + h.check(/More stars/.test(await hud()) && /Start round/.test(await hud()), 'the menu offers More stars and Start round'); + const before = (await snap()).kids.filter((k) => k.dynamic).length; + await clickBtn('More stars'); + await h.eventually(() => snap().then((v) => v.kids.filter((k) => k.dynamic).length), (n) => n === before + 3, `More stars adds 3 dynamic bodies (${before} -> ${before + 3})`, 8000); + for (let i = 0; i < 12; i++) { + await page.waitForTimeout(600); // the node's own 0.5 s interval + await clickBtn('More stars'); + } + await page.waitForTimeout(1200); + const alive = (await snap()).kids.filter((k) => k.dynamic).length; + h.check(alive <= 27 + 32 && alive >= before + 3, `twelve more presses never exceed 27 + maxAlive 32 (${alive}) — the spawner recycles, it does not pile up`); + + // 6 — the round: Start -> playing; sweep every star -> Lit 24/24 -> over + await clickBtn('Start round: light every star'); + await h.eventually(() => snap().then((v) => v.state), (v) => v === 'playing', 'Start flips to playing', 8000); + await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'hud', 'the round HUD shows', 6000); + await page.waitForTimeout(800); + await knockStar('Star 3', 3, 'q'); + await h.eventually(async () => await hud(), (t) => /Lit: 1 \/ 24/.test(t), 'one hit lights one star (Lit: 1 / 24)', 8000); + const lit3 = await page.evaluate(() => { + let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return '#' + group.getObjectByName('Star 3').material.color.getHexString(); + }); + h.check(lit3.toLowerCase() === '#ffe08a', `...and Star 3 is painted lit (${lit3})`); + // the TRIGGER LOG, not the count: the instant the last star lights, `allplayers` flips + // the round to `over`, and in `over` the round cutoff is Infinity, so every perRound + // latch READS un-lit again and `sum24` drops to 0 — while flowValues publishes only + // every 150ms, so a poll (or even a subscription) can miss the one window where the + // count read 24. What a perRound latch reads as lit is its set-source stamp at or after + // the round's startedAt, so assert THAT for all 24; the `over` check below is what + // proves the count chain itself reached 24, and `Lit: 1 / 24` above its readout. + const litThisRound = () => page.evaluate(() => { + const st = /** @type {any} */ (window).__stores; + let trig, gs; st.flowTriggers.subscribe((v) => (trig = v))(); st.gameState.gameState.subscribe((v) => (gs = v))(); + // startedAt is epoch ms; trigger stamps are the tick clock's seconds-of-day, so fold + // it the way retiredByRound does (toSyncedStamp + the midnight-wrap guard) + if (!gs?.startedAt) return -1; + const start = (gs.startedAt % 86400000) / 1000; + let n = 0; + for (let i = 1; i <= 24; i++) { + const t = trig['hit' + i]?.lastT; + if (typeof t === 'number' && !(t < start && start - t < 43200)) n++; + } + return n; + }); + const lit1 = await litThisRound(); + h.check(lit1 === 1, `the round's log counts only this round's hits (${lit1}; Star 1 was knocked before Start)`); + // a star still flying AWAY faster than the sweep refuses the hit BY DESIGN (approach + // <= minSpeed), so a star missed on the first pass is retried — with a FRESH probe id, + // because the same probe's cooldown for that body is already spent — after damping has + // had a moment to bleed it. Three attempts, then it counts as missed. + let missing = []; + for (let attempt = 0; attempt < 3; attempt++) { + const todo = attempt === 0 ? Array.from({ length: 24 }, (_, n) => n + 1) : missing; + missing = []; + for (const i of todo) { + const r = await knockStar('Star ' + i, 4, 'r' + i + '_' + attempt); + if (r.missing || r.hits === 0) missing.push(i); + } + if (!missing.length) break; + await page.waitForTimeout(1200); // let damping bleed the runaways + } + h.check(missing.length === 0, `every star took a knock (${24 - missing.length}/24${missing.length ? ', missed ' + missing.join(',') : ''})`); + // the count node itself, for the NEW round below (playing, so its cutoff is finite) + const litCount = () => page.evaluate(() => { + let v; window.__stores.flowValues.subscribe((x) => (v = x))(); + return v['sum24'] ?? 0; + }); + await h.eventually(litThisRound, (n) => n >= 24, 'all 24 latches were set this round (hit stamps at or after startedAt)', 10000); + await h.eventually(() => snap().then((v) => v.state), (v) => v === 'over', 'every star lit ends the round (over)', 10000); + h.check(/EVERY STAR LIT/.test(await hud()) && /Every star lit in \d+s/.test(await hud()), 'the over screen names the time'); + + // 7 — a NEW round un-lights every star (the perRound reset, and the repaint that + // proves the paint tracks the round rather than sticking). Material colour is NOT + // base-managed — restoreBase carries pose and visibility only — so a star does not + // revert to its authored palette colour when the round ends; it is repainted when the + // next round starts, which is the behaviour worth asserting. + await clickBtn('Back to free play'); + await h.eventually(() => snap().then((v) => v.state), (v) => v === 'menu', 'Back to free play returns to menu', 8000); + await pressP(); + await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'pause', 'the menu opens again', 6000); + await clickBtn('Start round: light every star'); + await h.eventually(() => snap().then((v) => v.state), (v) => v === 'playing', 'a new round starts', 8000); + await h.eventually(litCount, (n) => n === 0, 'the new round un-lights every star (perRound)', 8000); + await page.waitForTimeout(800); + const dim3 = await page.evaluate(() => { + let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); + return '#' + group.getObjectByName('Star 3').material.color.getHexString(); + }); + h.check(dim3.toLowerCase() === '#3b3f66', `...and Star 3 is painted unlit again (${dim3})`); + + await page.evaluate(() => window.__stores.isLocked.set(false)); + await page.waitForTimeout(400); + await h.finish(browser); +}); diff --git a/tests/e2e/peer-variables.test.cjs b/tests/e2e/peer-variables.test.cjs index 555e3a20..1ffd4616 100644 --- a/tests/e2e/peer-variables.test.cjs +++ b/tests/e2e/peer-variables.test.cjs @@ -322,6 +322,38 @@ h.run(async () => { `the Player Variable node reads mine/sum/max/peer (${JSON.stringify(readings)})` ); + // ---- 1c. ...and the number REACHES a consumer through a wire ------------------ + // 24-A A4: 1b above evaluates the node ALONE, which proves the evaluator and says + // nothing about the wire — and that is the gap the bug lived in: `peervariable` was + // missing from flowRuntime's `valueTypes`, so `resolveInputs` refused it as a source + // and every consumer silently kept its own dialled value (measured in the Stars Room: + // two `peervariable -> hudtext` readouts rendered 0 beside a leaderboard reading 1). + // The honest check asks the CONSUMER what it resolved, through the real path. + const wired = await A.page.evaluate(() => { + const s = window.__stores; + const src = { + id: 'pv-src', + type: 'peervariable', + position: { x: 0, y: 1400 }, + data: { type: 'peervariable', name: 'laps', read: 'mine' }, + class: 'w-[150px]' + }; + const sink = { + id: 'pv-sink', + type: 'math', + position: { x: 240, y: 1400 }, + data: { type: 'math', op: 'add', a: 99, b: 0 }, + class: 'w-[150px]' + }; + const edge = { id: 'e-pv-src-pv-sink.a', source: 'pv-src', target: 'pv-sink', targetHandle: 'a' }; + const data = s.flowRuntime.resolveInputs(sink, [src, sink], [edge], 0, { triggers: {} }); + return { a: data.a, value: s.flowRuntime.evalNode(sink, [src, sink], [edge], 0, new Set(), { triggers: {} }) }; + }); + h.check( + wired.a === 3 && wired.value === 3, + `a wired Player Variable REACHES its consumer — the Math node resolves 3, not its own 99 (${JSON.stringify(wired)})` + ); + // ===================================================================== // 2. A PER-PLAYER COLLECTIBLE: the gem hides only for whoever took it // ===================================================================== From 4314e854ecfd07299d3d1729bef7a56ede134145 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 23:39:25 +0300 Subject: [PATCH 15/17] [docs] 1.13: the changelog entry, the architecture map and the gotchas the lanes paid for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: a `## Unreleased` section ABOVE the shipped `## 1.12.0`, in five parts — the knock and the Stars Room, one scene look and shared materials, modelling/windows/node editor, VR Football, and the SDK additions. Written against the CURRENT file on purpose: the trap c8258dd had to undo was a lane writing `## Unreleased` while branched off an older release, after which the next version bump renamed the heading around it. This branch is based on 1.12.0, so the 1.13 bump will rename exactly this heading. - CLAUDE.md architecture map: `knock.js`/`knockMath.js` (incl. the `hit` message being CONTENT and its `at` being `sessionNow()`), `lookPresence.js`, `postGraphs.js` + `postGraphPresets.js`, `materialSharing.js`, `flowPrefs.js`, and the docking/meshEdit entries for edge splits and the falloff meshgeo commit. - CLAUDE.md gotchas, each one paid for this round: `git checkout --theirs` dropping the whole ours side of a file (it cost a silently shifted debug-hook binding during this integration); an attribute index recorded before a meshgeo commit addressing a different vertex after it; a handle hung past a docked window's edge being clipped; Chromium firing `contextmenu` on the press; the node pane's bottom-right corner being the minimap; `.peer-watch` being worn by two buttons; a type check not telling a shader-driven material from its base; a guard that is redundant on the happy path not being proven by it; a shader-graph constant having to be a Float node; and a perRound latch count reading 0 the instant the round ends. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw --- CHANGELOG.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73225935..4806a636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## Unreleased + +### ✨ Knock things about (roadmap #24) + +- 🌠 **Hit a floating object with your hand and it flies off.** In VR your hands, and on + desktop the camera you walk with, now knock a physics object at the speed you hit it — + the first thing a zero-g room needs and the thing grab-and-throw never covered. Configure + Scene ▸ Physics ▸ **Knock** turns it on for a scene and sets the strength, the reach and + the spin; it is off in every scene that does not ask for it. +- 💥 **An On Hit node** fires when something is knocked, with how hard (`speed`) and whether + it was you (`byMe`), so a graph can burst particles in proportion or count only your own + touches. **Who** can be narrowed to anyone / me / others. +- 🌟 **The Stars Room** — a new template in Games. Twenty-four stars and two planets float in + a room with no gravity; knock them, watch them chime and drift, add more from the HUD, or + press Start for a round that ends when every star is lit. Nothing to install. +- 🔄 **Someone joining a running simulation now knows it is running**, so their knocks and + their grab work from the first second instead of after the next restart. +- 🩹 Fixed on the way: a Player Variable now reaches the node it is wired to, and a colour, + shader-uniform or device effect keeps working on an object the physics simulation owns. + +### 🎨 One scene look, and materials you can share + +- 👀 **Watching someone shows you their look.** Watch a peer and you see the camera they are + looking through, its grade, their view mode and their scene-look switches — the banner says + when something cannot be adopted. It ends when you stop watching. +- 🌓 **Shader graphs have a Post domain**: build a post-processing effect as a node graph and + drop it into the scene look. Ships with Posterise, Ordered dither, Edge detect (ink) and a + graph-built Ambient occlusion. +- 🗂️ **Configure Scene ▸ Scene look** is now one section for the whole look — the post stack, + the scene's default material and per-object shaders — with a line saying what it costs. +- 🙈 **Scene shaders can be switched off on your own screen** (Configure Scene ▸ View ▸ + Overrides), the way post already could. Nobody else's view changes. +- 🔗 **Duplicate can share a material instead of copying it** (Settings ▸ Scene ▸ Duplicate). + A shared material carries its link through save, undo, a peer's copy and a late joiner, and + the Material section has **Unlink** when you want your own again. + +### 🧱 Modelling, windows and the node editor + +- 🫱 **Proportional editing reaches your peers.** Drag a vertex with a falloff radius and the + whole neighbourhood now arrives on every other screen (in one step, when you let go) — + before, only the vertices you had selected moved for anybody else. +- 🔃 **Proportional rotate and scale**, not just move: the falloff blends the turn and the + scale toward identity across the radius, so a rotation twists the surrounding surface + instead of leaving it behind. +- 💬 Vertex slide now says why it stands down while a custom pivot is placed, instead of + quietly doing nothing. +- 🪟 **Two windows on one screen edge.** Drop a second window onto a docked one and the edge + splits into two stacked panels with a divider you can drag; the share is remembered per side. +- 🖱️ **Settings ▸ Input ▸ Node editor ▸ Mouse bindings** — keep Classic (left-drag pans, as + always) or switch to Select-first, where a left drag draws a selection rectangle, dragging + any selected node moves the whole set, and the right button pans. + +### ⚽ VR Football (a module and a template) + +- 🥅 **Football** joins the Games tab: two floating gates, one ball, red against blue, played + with your hands. Goals go to whoever touched the ball last, own goals land on the right + sheet, and the mode decides when the match ends (first to N, a clock, or either). Scores, + touches and own goals are per player, the gate lamps read from across the room, and a saved + scene keeps the match log. Two people in one room can play it colocated. + +### 🛠️ For people building on it + +- 🥊 **`api.onHit(cb)` and `api.hitLog()`** — a module can react to a knock (uuid, who, how + hard, where) or read the recent ones, which is what the football module's last-touch rule + stands on. +- 🧩 The template author script gained one graph builder and a remap that resolves every + object named in node data, so a game def can name objects instead of carrying uuids. + ## 1.12.0 — Hold together 🛡️ ### 🛡️ Connections that recover, and sessions with a size (roadmap #27 + #25) diff --git a/CLAUDE.md b/CLAUDE.md index ba754958..393395eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2013,6 +2013,51 @@ loadable play content. Everything a user does must be visible to connected peers `docs/plans-core/`, local `../theprototype.app-cloud`). This repo's `/docs` is gitignored scratch space (pointer READMEs inside). +- `src/lib/knock.js` + `knockMath.js` (24-A) — A HAND KNOCKS A BODY. `knockMath` is the pure + leaf (THREE + `throwVelocity`): a 6-sample/100ms probe ring, `contactOf` (sphere vs bounding + sphere; `approach` = closing speed along the normal), `knockResponse` (`v' = v_body + n * + approach * gain`, infinite-mass hand, spin from the TANGENTIAL slip — a sphere contact is + central, so `r × Δv` is zero by construction), `cooldownStep`. `knock.js` is the runtime: + VR hands arrive through a seam `Scene.svelte` passes in (it never imports vrControls), the + desktop camera is a 0.35 m head probe, both carried into the objects group's frame. The + HITTER broadcasts `{type:'hit'}` whoever it is; the INITIATOR applies it (`physics.applyHit`, + the throw's sibling — `clampThrow` + the scene's `maxSpeed`, CCD over 5 m/s); a + non-initiator predicts locally behind `knock.predict` and withdraws after 400 ms. + `hit` is CONTENT (gateable, room-scoped), `by` is stamped from the connection, and its `at` + is **`sessionNow()`** — A2 folds it into the trigger log beside every other stamp, so it has + to be on 25-E's session clock, not the sender's raw one. `scenePhysics` carries the nested + `knock` block, `enabled:false`, which is what keeps every saved scene byte-identical. +- `src/lib/lookPresence.js` (P2) — a peer's LOOK as PRESENCE, in the `campreview` shape: + `{camera, mode, overrides:{post,shaders}, look}` sent on change and in the `getmodulestate` + reply, dropped at both `finalizeDisconnect` sites. Watching a peer resolves the post chain + from THEIR row (`Outline.svelte`), and 26-D's quality governor still applies on top — the + governor is this machine giving up post to hold its frames, which watching must not undo. +- `src/lib/postGraphs.js` + `postGraphPresets.js` (P4) — a shader graph whose output IS a + post-processing effect. `shaderGraph`'s `registerPostDomain` seam keeps the compiler core + shared (`createCompiler(graph, outputType)` in `shaderCompile.js`); the post domain differs + only in its inputs (SceneColor/SceneDepth/SceneNormal/UV/Time/Resolution) and its terminal + (`postOutput`), plus `EffectAttribute.DEPTH` and a NormalPass added on demand. +- `src/lib/materialSharing.js` (D2) — two objects, ONE material, by id. The id is scene data + and survives every carrier (wire, autosave, sessions, undo, GLTF rebuild) because + `startMaterialSharing()` re-unifies by id whenever the scene changes; the SENDER fans the + per-object messages a receiver already understands, so the wire is byte-unchanged and no + capability-gate entry or older-peer story is needed. `materialsHandler`'s material-TYPE + swap is the one op that replaces the instance rather than writing into it, so it hands the + new instance to the sharers too. +- `src/lib/flowPrefs.js` (114) — the node editor's mouse bindings as a LOCAL pref leaf + (svelte/store + safeStorage): `classic` (left-drag pans, the default and byte-identical to + every version before it) or `select` (left-drag rectangle-selects, right-drag pans). +- `docking.js` (81.4) — an edge holds TWO stacked panels: `docked` is + `{left: string[], right: string[]}`, `dockSplit:` is the share, and a DOCKED window's + rect belongs to docking.js — so `dragWindow`'s and the object list's reveal clamps stand + down for it, exactly as they already do for a tab member. +- `meshEdit.js` (F1/F3) — a proportional drag ends in ONE whole-geometry `meshgeo` commit + (`commitFalloffSnapshot`), applied LOCALLY as well as broadcast: `applyMeshGeo` rebuilds the + receiver index-EXPANDED while a `/create Plane` is indexed, so both sides must swap together + or the sender's next `verts` indices address a layout the peer no longer holds. The selection + is re-found by POSITION after the swap. `applyPivotTransform` now covers the falloff for + rotate and scale by slerping the rotation and lerping the scale toward identity per vertex. + ## Replication golden rules 1. Every mutation = apply locally + `$peers.send({type, ...})`; receivers apply WITHOUT @@ -4180,6 +4225,37 @@ loadable play content. Everything a user does must be visible to connected peers sometimes without even an unused-selector warning); svg `className` is an SVGAnimatedString — e2e reads `getAttribute('class')` and selects `svg`, not `i`. +- **`git checkout --theirs -- ` during a merge discards the WHOLE ours side of that + file, not just the conflicted hunk.** Resolving App.svelte's debug-hook tails that way + silently dropped the one `import('./lib/knock')` line that had auto-merged cleanly 100 lines + above, leaving a destructure with one more name than the import list — every module after it + bound to its neighbour. The three tails (the `Promise.all` import list, the destructured + parameter list, the `window.__stores` object) must always COUNT EQUAL and be in the same + order; check that after any merge that touches them. +- **An attribute INDEX recorded before a `meshgeo` commit addresses a DIFFERENT vertex after + it** — the commit rebuilds the mesh index-expanded (81 entries → 384 in triangle order). Any + fixture or handle map captured before it must be re-found by POSITION, not carried over. +- **A handle hung past a docked window's edge is clipped away and takes no pointer events** — + every docked window is `overflow-hidden`. The 81.4 divider sits INSIDE the top panel; the + width grip only works because half of its 6px is inside. +- **Chromium fires `contextmenu` on the PRESS**, so a travel check there is always zero. + Decide click-versus-drag on pointerup (114's pane menu does). +- **The node pane's empty bottom-right corner is the MINIMAP** — a drag there pans at the + minimap's own scale (measured 631px for an 80px gesture). +- **`.peer-watch` is worn by TWO buttons** — Watch, and the join-a-peer's-camera button + (`.peer-watch.peer-preview`) that only renders while that peer previews a camera. A suite + selecting the bare class joins the camera instead of watching and reads the right answer for + the wrong reason; `.spectator-exit` has the same shape. +- **A type check cannot tell a shader-driven material from its base** — the injected material + is a CLONE, so both read `MeshStandardMaterial`. Measure material IDENTITY instead. +- **A guard that is redundant on the happy path is not proven by the happy path**: D2's fan and + its applier link both survived deletion until the suite staged the one state each exists for. +- **A constant in a shader graph must be a Float node** — the arithmetic nodes take operands + from sockets and have no params, so authored `{b: 0.5}` node data is silently ignored. +- **A perRound latch count reads 0 the instant the round ends** (the Infinity cutoff), so a + suite that polls `flowValues` for "all N lit" can only ever catch it for one publish. Assert + the TRIGGER LOG instead, folded to seconds-of-day the way `retiredByRound` does. + ## Verification (mandatory before commit) PIXEL features (post-processing, outlines, AO) are asserted through the helpers From b8d1b7c199fe702cd7388e9a67444fc997a987cc Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 17 Sep 2026 23:53:54 +0300 Subject: [PATCH 16/17] [docs] 1.13: the Football entry says what actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry claimed Football "joins the Games tab". It does not, and the docs pass found the same thing independently: `FOOTBALL_DEF` is absent from `scripts/author-templates.cjs`, the scenes feed's `games` list holds towers and stars-room only, and the football lane's handover records that the authoring run was never made — its `index.json` row already points at `games/football`, so the gallery card points at a template that is not published. What ships is the MODULE: install it from Browse, and its toolbox's "Build pitch" recipe lays out the pitch, the gates and the rules. That is what the entry says now, with the missing template named rather than implied. Authoring the def into the scenes repo is a small, separate job (import the modules repo's `football.def.json` into the author script, run `--only football --out`, review the thumbnail by eye) and it is the only thing between the module and a Games-tab card. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw --- CHANGELOG.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4806a636..a4902b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,13 +57,16 @@ always) or switch to Select-first, where a left drag draws a selection rectangle, dragging any selected node moves the whole set, and the right button pans. -### ⚽ VR Football (a module and a template) - -- 🥅 **Football** joins the Games tab: two floating gates, one ball, red against blue, played - with your hands. Goals go to whoever touched the ball last, own goals land on the right - sheet, and the mode decides when the match ends (first to N, a clock, or either). Scores, - touches and own goals are per player, the gate lamps read from across the room, and a saved - scene keeps the match log. Two people in one room can play it colocated. +### ⚽ VR Football (a module) + +- 🥅 **Football** is a new module in Browse: two floating gates, one ball, red against blue, + played with your hands. Install it, open its toolbox and **Build pitch** lays out the pitch, + the gates and the rules in your scene. Goals go to whoever touched the ball last, own goals + land on the right sheet, and the mode decides when the match ends (first to N, a clock, or + either). Scores, touches and own goals are per player, the gate lamps read from across the + room, and a saved scene keeps the match log. Two people in one room can play it colocated. + (A ready-made Football template for the Games tab is not published yet — the toolbox recipe + is how you get a pitch today.) ### 🛠️ For people building on it From 2af07d9331dcf18c0ad067f8c54271373c93bdc1 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 19 Sep 2026 01:00:47 +0300 Subject: [PATCH 17/17] [docs] 1.13.0: name the release, before the bump renames it for us The section was still "## Unreleased". Leaving it that way is the exact trap the hardening batch fell into: a lane writes into "## Unreleased" while branched off an older release, the next npm version bump renames that heading to ITS number, and the entries end up filed under a release that already shipped. RELEASING.md now warns about it; this is the warning being followed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4902b57..dac5cd03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> -## Unreleased +## 1.13.0 — Knock it about 🪐 ### ✨ Knock things about (roadmap #24)