diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index 2676f8cb..da890116 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -31,14 +31,19 @@ The init script (helpers does it) sets `localStorage.debugStores='true'` + `hasSeenDisclaimer='true'`. App.svelte then publishes `window.__stores` = all stores spread + modules: `meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, -drawMode, pathCapture, lockControl, prefabs, physics, userModules, environment, -animatedImports, fileHandler, sceneBounds, cameraClip, ping, sessions, geometryEdit, -lightParams, themes, vrRadialMenu, vrPalette, vrWindowPoses, vrKeyboard, faceEdit, -avatarModel, explorer, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, -sceneAssets, THREE, GLTFExporterModule, snapping, flowSockets, networkQuality, packs, -customNodes, nodesHandler, objectMenu`. Also on the stores spread: `viewportMenuOpener` -(Scene registers its context-menu opener here — call `$viewportMenuOpener(x,y,forceEmpty)` -to open the viewport/create menu without a right-click). +drawMode, pathCapture, lockControl, prefabs, physics, joints, possess, handModels, +terrainSculpt, userModules, environment, sceneMusic, animatedImports, fileHandler, +sceneBounds, cameraClip, ping, sessions, geometryEdit, lightParams, shadowDefaults, +palette, viewModeCtl, inputRuntime, shortcutsRegistry, themes, vrRadialMenu, +vrPalette, vrWindowPoses, vrKeyboard, faceEdit, avatarModel, explorer, bottomDock, +explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets, THREE, +GLTFExporterModule, snapping, flowSockets, networkQuality, packs, customNodes, +nodesHandler, nodeCatalog, objectMenu`. Naming trap: `__stores.viewMode` is the +STORE (from the sceneStore spread); the viewMode MODULE is `viewModeCtl` — a module +key that shadows a same-named store silently breaks tests (#12 lesson). Also on the +stores spread: `viewportMenuOpener` (Scene registers its context-menu opener here — +call `$viewportMenuOpener(x,y,forceEmpty)` to open the viewport/create menu without +a right-click). **Never dynamic-import `/src/lib/x.js` from page code to reach a singleton** — once vite HMR-timestamps the app's copy you get a SECOND module instance (empty stores, @@ -80,13 +85,35 @@ Use `https://theprototype.app:5173/` — hosts-mapped to 127.0.0.1; the `.app` h makes peerjs use the **public cloud** (localhost tries ws://localhost:9001 and fails). `helpers.connect(B, A)` does: fill peer id → Connect → Approve on A → ~9s settle. Late joiners: connect a third context AFTER mutations, assert handshake state arrived -(objects/nodes/annotations/module state/env/custom defs). Voice: launch with -`--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`. +(objects/nodes/annotations/joints/module state/env/music/handmodel/custom defs). +Voice: launch with `--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`. +**B→A messaging works** since #12 (the adopted-inbound-conn fix) — tests may drive +mutations FROM the joiner (peer move streams, claims). When a suite needs the +dual-module-instance split collapsed, `freshReload(peer)` BEFORE `connect` and +re-read the id (`peer.id = await …peers.subscribe…peer.id`) — a reload mid-mesh +drops the P2P session. ## Known flakes / traps - First run after adding a dependency: vite re-optimizes and reloads mid-test — rerun. Lazy wasm (rapier) needs a throwaway prewarm page first (see physics.test.cjs). + Physics sims run REAL-time since #12 (fixed-timestep accumulator) — falls/settles + take wall-clock seconds even under a throttled rAF; don't compensate with huge waits. +- **Machine saturation**: headless pages can run at ~4fps with timers ~1.8x slow when + the host is loaded (dozens of user Chrome processes — do NOT kill them). Symptoms: + timeouts on waits that "always worked", missed one-shot flag reads. Cures: generous + `eventually` windows, BEHAVIORAL asserts (did the box move) over one-shot state + reads, and for flags that flicker (hold/claim booleans) an IN-PAGE sampling loop + (`setInterval` 50ms inside one `evaluate`) instead of round-trip polling. +- **Large plain-number arrays blow binarypack**: `conn.send` with a ~40k-element plain + array throws "Maximum call stack size exceeded" — and `broadcast()`'s try/catch + SWALLOWS it, so the message silently never leaves. Send raw bytes instead + (`new Float32Array(arr).buffer`) and normalize on receive (meshgeo/terrain do this). + If a big payload "never arrives" in a test, suspect this before the network. +- Pre-existing flakes (reproduce on a clean base — don't chase them into your diff): + add-menu search-Enter, sound-node Play overlap, connect-overlay querySelector, + scene-music byte-push timing. To PROVE a failure is pre-existing: + `git stash push -u`, run the suite on HEAD, `git stash pop`. - Phase-comparison asserts between two peers: two sequential evaluates skew ~150ms — tolerances ≥0.6 for fast oscillations, or compare Promise.all-sampled values. - Overlays intercept clicks (properties drawer covers right ~320px; modals block all; @@ -159,7 +186,7 @@ Late joiners: connect a third context AFTER mutations, assert handshake state ar (the runner just `node`s each file; see net-backoff.test.cjs). Track PASS/FAIL locally and `process.exit(1)` on failure (helpers.finish needs a browser). - svelte-check delta hunting: `npx svelte-check --output machine | grep `; - baseline 2026-07-18 = **502 errors / 77 warnings** (drifts down as flowbite/typed + baseline 2026-07-19 = **501 errors / 77 warnings** (drifts down as flowbite/typed code is removed — hold whatever it currently is; add no NEW). Note: in the big JS-mode `.svelte` files (Scene.svelte) `@param {T}` JSDoc on a function is NOT honored — give the param a default (`slot = 0`) to force the type, and prefer explicit locals diff --git a/.claude/skills/peer-feature/SKILL.md b/.claude/skills/peer-feature/SKILL.md index cca3969e..7c9b1a19 100644 --- a/.claude/skills/peer-feature/SKILL.md +++ b/.claude/skills/peer-feature/SKILL.md @@ -17,9 +17,12 @@ in the codebase — copy the referenced implementation. animated-import mixers. Seeded randomness only (`mulberry32`); no accumulation in effects (compute from `base` + `time`); no `Math.random()` in anything replicated. - **Authoritative** — one peer simulates and broadcasts results as plain messages; - others just apply. References: physics (initiator → `move` at ~10 Hz per awake body, - busy-guard message), pong (spawner owns the ball at ~12 Hz). Use when simulation - can't be deterministic; guard against two authorities. + others just apply. References: physics (initiator steps the world, movement-gated + `move` broadcasts, busy-guard message), pong (spawner owns the ball at ~12 Hz), the + car module (the blessed INPUT-FORWARDING recipe: every peer sends its inputs + `{op:'drive', throttle, steer}` at ~20 Hz, only the `api.physics.isInitiator()` + peer applies motors — result replicates as plain moves). Use when simulation can't + be deterministic; guard against two authorities. ## Not everything replicates — some state is deliberately LOCAL @@ -29,25 +32,38 @@ prune it in `handleDisconnected` (or derive the UI from live peers so stale entr can't render). References: `networkQuality.js` (per-peer RTT/relay from `getStats()`, polled locally), the Explorer **pack library** (imported packs stay local until an explicit future "Share"; only a *placed* object replicates through the normal import -path), and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of -thumb: if two peers would independently compute the same value, or it's a personal -setting, keep it off the wire. +path), input claims (`inputRuntime.claimInput` — a claim only pauses THIS peer's own +input consumers, nothing on the wire), view mode/shadow quality/sculpt brush prefs, +and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of thumb: +if two peers would independently compute the same value, or it's a personal setting, +keep it off the wire. ## Checklist for a new replicated feature 1. **State** in a store (`src/stores/*`) or module-level writable; uuid/id-keyed, plain-serializable (peerjs binarypack: ArrayBuffers OK — raw-bytes syncs like - `objectfile` ride on this; no class instances/functions). + `objectfile` ride on this; no class instances/functions). **Large numeric payloads + MUST go as raw bytes**: a plain array of ~40k numbers makes binarypack recurse to + "Maximum call stack size exceeded" — and `broadcast()`'s try/catch swallows it, so + the send silently vanishes. Send `new Float32Array(arr).buffer` and normalize + array/ArrayBuffer/typed-view on receive (meshgeo is the reference). 2. **Local mutation function** applies + broadcasts: `get(peers)?.send({ type: 'mything', ... })` (pattern: `annotationsHandler`). 3. **Receive case** in `peerHandler.svelte.js` `conn.on('data')` — applier does NOT re-broadcast. 4. **Late joiners**: `getmything` request in `sendHandshake()` + a full-state reply - that retries until `conn.open` (`sendNodes`/`sendNodeDefs`/`sendModuleStates` — + that retries until `conn.open` (`sendNodes`/`sendJoints`/`sendModuleStates` — never bare `setTimeout` sends: peerjs silently drops pre-open messages). Singleton - state (environment) instead pushes with a `changedAt` stamp, latest-wins — and any - symmetric pull needs a deterministic direction (nodesync: lower count pulls, - peer-id tiebreak) or drifted peers swap forever. + state (environment, sceneMusic) instead pushes with a `changedAt` stamp, + latest-wins — each singleton gets its OWN message type (music deliberately does + NOT piggyback on `environment` because env state round-trips through preset + export/import and would leak the track into presets) — and any symmetric pull + needs a deterministic direction (nodesync: lower count pulls, peer-id tiebreak) + or drifted peers swap forever. A replicated LIST of small defs (joints) copies + the annotations pattern: create/delete messages + full-list handshake reply + + sender-side delete-cascade + a presence-style history kind. Per-peer IDENTITY + choices (avatar photo, hand model) broadcast a content HASH with presence/ + userdata and receivers pull the bytes via assetShare (`handModels.js`). 5. **Where does it live in the scene?** `objectsGroup` children = replicated, listed, GLTF-synced, anyone edits. Scene-root groups (fixed `name`) = local/derived — helpers, env rig, module content; rebuild them from state; they need @@ -74,12 +90,13 @@ Throttle continuous streams (~10–20/s) with a final unthrottled send on gestur peers get stale-expiry cleanup (`drawlive` 5s, ping 4s). **Geometry/topology changes** can't ride a per-vertex channel — snapshot the FULL -geometry (`meshgeo`: uuid + positions array, size-capped ~45k floats, `faceEdit.js`). +geometry (`meshgeo`: uuid + positions, size-capped ~45k floats, `faceEdit.js`; the +WIRE format is raw `Float32Array.buffer` bytes per the binarypack rule above). Receivers swap the geometry wholesale, the history kind replays the same snapshot, and the receive applier must REBUILD any live edit-session caches (applyMeshGeo re-derives -its face groups — a stale cache after undo/remote swap corrupted gestures once). Live -reshape gestures stream throttled previews (~5/s) and commit ONE snapshot + undo entry -on release. +its face groups AND the terrain sculpt weld map — a stale cache after undo/remote swap +corrupted gestures once). Live reshape gestures stream throttled previews (~5/s) and +commit ONE snapshot + undo entry on release. ## Adding a VR panel (the follower-window pattern) @@ -113,8 +130,20 @@ entry)` (replicated `/create `), `registerClickHandler(fn(hitObject) => bo (desktop + VR trigger), `registerInteractiveGroup(name)`, `registerFrameTask(fn(time))`, `send(payload)`/`onMessage(fn)` (namespaced `{type:'module', moduleId}`), `registerStateSync({getState, applyState})` (late joiners), `registerMenu(label, fn)` -(renders on the module's manager card), accessors `scene() objectsGroup() peerId() -toast() now() THREE assetUrl(path)`. +(renders on the module's manager card), `registerVRMenuEntry({id, group, label, +action, closes})` (VR radial sector), accessors `scene() objectsGroup() peerId() +toast() now() THREE assetUrl(path) selectedUuid()`. #12 additions (reached via PRIMED +dynamic imports in moduleSDK — static edges close TDZ cycles): **input** — +`registerBindings` (Settings ▸ Shortcuts listing), `input()` snapshot +`{codes:Set, axes, vrButtons}`, `onInput(fn)`, `claimInput/releaseInput('keys'| +'locomotion')` (pauses the host's OWN consumers, LOCAL, always release); **physics** +— `api.physics.{isInitiator, applyImpulse, setJointMotor, joints()}` (mutations +initiator-only — forward inputs, see the authoritative car recipe above); +**possess** — `possess(uuid, {camera:'chase'|'orbit'|'none'})`/`releasePossess()` +(possessing = selecting = the lock; ONE undo per ride). A module KIND peers must +agree on derives from the replicated object NAME (car's 'Carbody'), never +locally-set userData. Worked examples: `src/modules/essentials/` (interactables) + +`src/modules/car/` (physics + input + claims). Version trust: peers exchange `[{id, version}]` on connect and toast on mismatch (advisory). Module viewport content = scene-root group rebuilt from state (see rule 5); diff --git a/CLAUDE.md b/CLAUDE.md index b54e97a5..57a738f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,35 @@ loadable play content. Everything a user does must be visible to connected peers #9 `Socket.svelte` wraps the xyflow Handle and paints `typeColor` by socket type; audit + verdicts in committed `NODES.md`), `objectMenu` (#9: `buildObjectMenuItems` — ONE object context menu shared by Controls' direct menu + ViewportMenu's "Selected" - submenu), `moduleSDK` + `userModules` (zip/URL installs), `physics` (rapier, - initiator-authoritative), `environment` (presets + scene-root rig, latest-wins sync, - `passthroughActive` local sky lift), `animatedImports` (raw-bytes objectfile sync), + submenu; #12: selection-aware — counted labels act on the SET, Group selection, + Physics ▸ Weld/Hinge, Sculpt terrain), `moduleSDK` + `userModules` (zip/URL installs), + `physics` (#12 rework: rapier steps as a flowRuntime **post-tick hook** — + flow poses → kinematic targets → step → write-back; fixed-timestep accumulator + (1/60, ≤8 substeps) so sim time tracks REAL time under throttled rAF; flow-animated + objects = KINEMATIC bodies w/ slerp-interpolated substep targets; dynamics have + sleep OFF + movement-gated broadcasts; drag/throw via holdBody/releaseBody; external + writes detected by write-back DEVIATION → 250ms kinematic hold; hull colliders + opt-in via userData.physics; Inspector Physics section; SimControls HUD + `P`) + + `joints` (#12: replicated sceneJoints defs — weld/revolute+motor, OBJECT-local + anchors → body-local at sim start, jointcreate/delete + getjoints handshake, + 'joint' history kind, sender-side delete cascade, sessions persist), + `inputRuntime` (#12: store-only SDK input — key codes + VR axes published by + vrControls, claims 'keys'/'locomotion' gate PointerLockControls/editorNavigation/ + VR stick; module bindings list in Settings), `possess` (#12: tank-controls drive of + any object + chase/orbit camera; possessing = selecting; ONE undo per ride), + `handModels` (#12: custom hand GLB = IDENTITY — hash on `handmodel` msg + handshake, + assetShare pull, rigid-at-wrist render), `terrainSculpt` (#12: brush raise/lower/ + smooth/flatten over the meshgeo channel; weld by quantized (x,z) COLUMNS, rebuilt in + the applyMeshGeo hook; one snapshot+undo per stroke; SculptToolbar pill), + `sceneMusic` (#12: ONE shared background track, latest-wins `music` singleton — + NOT piggybacked on environment; synced-clock loop offset; LOCAL volume/mute overlay), + `shadowDefaults` (#12: objectsGroup-sweep sets cast/receiveShadow on every mesh; + opt-out = userData.shadow=false) + `palette` (#12: paletteColorFor(uuid) deterministic + default colors) + `viewMode` (#12: LOCAL Shaded/Shaded+AO/Wireframe; + wireframe = scene.overrideMaterial, never per-material), + `environment` (presets + scene-root rig, latest-wins sync, + `passthroughActive` local sky lift; #12: sun casts w/ scene-fit frustum + + env-shadow-catcher ShadowMaterial disc), `animatedImports` (raw-bytes objectfile sync), `prefabs` (local IndexedDB library), `explorer` (LOCAL asset library: IndexedDB index + per-item blobs, content hashes, thumbnails) + `explorerDrop` (drag-out placement/ texturing) + `assetShare` (assetfile/getasset hash push+pull → 'Shared' folder) + @@ -70,14 +96,20 @@ loadable play content. Everything a user does must be visible to connected peers takes `{assets,packs,flow}` include-opts, adds a `packs/` section; `fileHandler` saves/ loads it, Sidebar Files = [GLTF | Scene | ⚙cog]), `measure`, `cameraBookmarks`, `editorNavigation`, `lightHelpers`. -- `src/modules/` — core modules (hello, button, dungeon, piano, pong) + `index.js` - `coreModules` list; manager enables/disables (live enable, reload to disable). +- `src/modules/` — core modules (hello, button, dungeon, piano, pong; #12: avatar = + possess-selected, essentials = 6 clickable interactables whose KIND derives from the + replicated object NAME, car = jointed drivable demo w/ click-claim + drive-op + forwarding) + `index.js` `coreModules` list; manager enables/disables (live enable, + reload to disable). - UI: `components/menu/*` (drawers/modals; visibility via stores + `hidePanels/ restorePanels`), `components/editors/*` (flow editor + CodeMirror panels), `components/play/*` (player, avatars — photo = billboard card; the VR follower panels: Menu/ObjectsPanel/PropertiesPanel/ColorPalette/PrefabsPanel/Keyboard/ ChatPanel/Stats — named `vr-*` control meshes, all grip-grabbable), - scene-overlay components (PingMarkers/PathWaypoints/LockHighlights), shared + scene-overlay components (PingMarkers/PingHighlights (#12: uuid-carrying pings flash + an object box)/PathWaypoints/LockHighlights), `SimControls`/`SculptToolbar` (#12: + runes-mode HUD pills — the MobileAddButton "own file so onclick doesn't mix with + on:" precedent), shared `ContextMenu.svelte` (caps to viewport + scrolls vertically when tall, never horizontally; per-submenu flip via left/right/top/bottom — no transform), `components/shared/WindowShell.svelte` (197: reusable window CHROME — collapsible/ @@ -107,10 +139,15 @@ loadable play content. Everything a user does must be visible to connected peers 6. Content that can't round-trip (skinned rigs) replicates as its **original file bytes** (`objectfile`), not through the per-node exporter (GLTFExporter is lossy and `sendObject` splits children, destroying rigs). Topology edits snapshot the FULL - geometry (`meshgeo` positions array, size-capped) — receivers swap it wholesale, and + geometry (`meshgeo`, size-capped 45k floats) — receivers swap it wholesale, and the applier must REBUILD any live edit-session caches (applyMeshGeo re-derives face - groups; a stale cache after undo/remote swap bit us). Live gestures stream throttled - previews (~5/s) and commit ONE final snapshot + undo entry. + groups + the sculpt weld map; a stale cache after undo/remote swap bit us). Live + gestures stream throttled previews (~5/s) and commit ONE final snapshot + undo entry. + **Big numeric payloads travel as RAW BYTES** (`new Float32Array(arr).buffer`), never + plain number arrays: binarypack recurses per element and a ~40k-number array throws + "Maximum call stack size exceeded" — which `broadcast()`'s catch SWALLOWS, so the + message silently never leaves (#12; large face-edits never replicated). applyMeshGeo + normalizes plain array / ArrayBuffer / typed-array view. 7. Singleton shared state (environment) syncs latest-wins via a `changedAt` stamp; symmetric pulls need a deterministic direction (nodesync: lower count pulls, peer-id tiebreak) or two drifted peers swap forever. @@ -122,7 +159,9 @@ loadable play content. Everything a user does must be visible to connected peers joiners/restores without handshake dumps. REPLY over your stable OUTGOING `peer.connections[peerId]`, never the incoming conn (it can be a stale duplicate from the connect dance). binarypack delivers Uint8Array **views** — slice - byteOffset..byteLength before hashing. + byteOffset..byteLength before hashing. #12: `connections[peerId]` may legitimately + BE an adopted inbound conn (see the connect-dance gotcha) — it's still the stable + channel; DataConnections are bidirectional and OUTGOING conns are wireData'd too. 10. Serializers (sendObjects, GLTF save, autosave, sessions) must `parkAnimatedAtBase()` first or receivers bake mid-swing poses as animation base; `restoreBase` calls `updateMatrix()` because toJSON/GLTFExporter read the matrix @@ -262,6 +301,28 @@ loadable play content. Everything a user does must be visible to connected peers init value is a truthy empty array. - The Bash tool's `cd` leaks into the shared shell cwd — `Set-Location` back to the repo root before PowerShell git/npm calls. +- **Connect dance (#12 fix)**: the host CLOSES the joiner's original conn pre-approval; + real WebRTC often never signals that close, and a fresh reopen can wedge mid-ICE — + the JOINER could never send anything to the host. peerHandler now ADOPTS an open + inbound conn as the send channel when the outgoing one is dead, wires the data + dispatcher (`wireData`) on OUTGOING conns too (the remote may talk back over them), + and `restoreConnection` retries with backoff after closing the stale conn first. +- **Physics ↔ rapier traps (#12)**: comparing quaternions with `dot()` reads |q|² — + rapier's f32 components leave the norm ~1e-9 off unit, so "unchanged" looks like a + deviation (compare COMPONENT-WISE). A kinematic platform moving UNDER a sleeping + dynamic body never wakes it — dynamics run `setCanSleep(false)` + movement-gated + broadcasts instead of `isSleeping()` gating. Kinematic substep targets must be + SLERP-INTERPOLATED per substep: feeding only the end pose gives full velocity on + substep 1 and zero after, so friction alternately drags and brakes (no net fling). + Sim speed must come from a fixed-timestep ACCUMULATOR — a per-frame dt clamp runs + slow-motion whenever rAF is throttled (background/headless tabs). +- Explorer `addItemFromBytes` TIME-BOXES its decorative thumbnail (Promise.race 4s) — + a wedged/slow GLB parse on the receiver used to silently block storing SHARED bytes. +- Static-import cycle map grew in #12: objectActions now imports geometries + (createGroup) and joints; multiTransform/objectMenu reach physics/terrainSculpt + DYNAMICALLY; moduleSDK reaches inputRuntime/physics/possess via PRIMED dynamic + imports (module-level refs resolved at boot). When adding an SDK capability, assume + a static edge into moduleSDK's consumers closes a cycle (flowRuntime → moduleSDK). - The dungeon module publishes gameplay data on its group's `userData.play` (grid/rooms/floorValue) — `dungeonPlay.js` consumes it; keep that contract stable. @@ -290,7 +351,26 @@ Two-peer tests run over the public PeerJS cloud via `https://theprototype.app:51 reposition on narrow; don't blanket-revert them anymore.) - VR phases: verify math/state headlessly, state clearly that on-device feel is the user's manual check. -- Status (2026-07-18): **Roadmap #9 SHIPPED** (release runway). B2 VR: 120Hz +- Status (2026-07-20): **Roadmap #12 "playground & polish" SHIPPED — ALL 19 phases** + on `feature/playground-polish` (off ai-scene-assistant; NOT merged). Opus set (11): + V-1 shadows-by-default + catcher, V-3 palette (kills 0x00ff00) + look tune, T-1 + Add▸Terrain, U-2 multi-select menu (groupSelection one-undo, multi prefab/delete, + desktop Ungroup), U-1 ping v2 (uuid object-highlight + radial Ping), R-2 VR + snap-angle unify + live labels, R-1 VR beam+reticle+hover shell, M-2 audio-pack + install + sound rolloff, M-1 sceneMusic singleton, V-2 N8AO + viewModes, U-3 toast + dedupe/cap + settings search. Fable set (8): P-A physics rework (post-tick hook, + kinematic flow bodies, accumulator, deviation holds, hulls, Inspector Physics, + SimControls), K-C SDK inputRuntime + claims + api.physics, P-B joints + (weld/hinge/motors + menu + sessions), K-D possess + avatar module, K-E essentials + (6 interactables), R-3 hand models (capsule style + GLB identity), T-2 terrain + sculpt (weld columns + smooth normals + SculptToolbar), K-F drivable car + (click-claim + drive-op forwarding; ~14m in e2e). THREE deep pre-existing bugs + fixed: joiner-cannot-send-to-host (adopted inbound conn), meshgeo big-array + binarypack stack overflow (raw-bytes wire format), Explorer thumbnail hang blocking + shared bytes. svelte-check ended **501/77** (new baseline — hold it). Plan: + docs/plan/roadmap-12-playground-polish.md (per-phase hashes). Backlog'd: articulated + hand retargeting, steered knuckles, VR sculpt, joint-clone-on-duplicate. + --- Earlier — Status (2026-07-18): **Roadmap #9 SHIPPED** (release runway). B2 VR: 120Hz (session.updateTargetFrameRate off supportedFrameRates on session start; vrTargetHz setting) + hands↔controllers switch fix (shouldSendHands forces a send on rep-flip — the `!moved && !hasJoints` gate ate the switch-back) + cuboid peer hands @@ -346,7 +426,17 @@ register(api)}`. api surface: registerNodeGroup (+custom components), registerEf (base-managed per-frame), registerPrimitive (replicated `/create`), registerClickHandler (desktop+VR), registerInteractiveGroup (scene-root click targets), registerFrameTask, send/onMessage (namespaced `{type:'module', moduleId}`), registerStateSync (late-joiner -handshake), registerMenu (manager card buttons), scene/objectsGroup/peerId/toast/now/ -THREE/assetUrl. User modules (zip/URL via the manager) must be self-contained — no -imports; guide in `MODULES.md` + `docs/sdk/`. Script nodes run arbitrary replicated -code deterministically (pure function of object/base/data/time) — never stream outputs. +handshake), registerMenu (manager card buttons), registerVRMenuEntry, +scene/objectsGroup/peerId/toast/now/THREE/assetUrl/selectedUuid. #12 additions: +**input** — registerBindings (lists in Settings ▸ Shortcuts), input() per-frame +snapshot {codes, axes, vrButtons}, onInput down/up events, claimInput/releaseInput +('keys'|'locomotion' pause the host's own consumers); **physics** — +api.physics.{isInitiator, applyImpulse, setJointMotor, joints()} (mutations are +INITIATOR-ONLY: forward inputs via api.send and let the stepping peer apply — the car +module is the worked recipe, pong's paddle pattern); **possess/releasePossess** +(tank-controls drive + follow camera; possessing = selecting). A module KIND that +must agree across peers derives from the replicated object NAME, never locally-set +userData (essentials + car). User modules (zip/URL via the manager) must be +self-contained — no imports; guide in `MODULES.md` + `docs/sdk/`. Script nodes run +arbitrary replicated code deterministically (pure function of object/base/data/time) +— never stream outputs. diff --git a/MODULES.md b/MODULES.md index 0d1de084..cd706d71 100644 --- a/MODULES.md +++ b/MODULES.md @@ -147,6 +147,57 @@ api.registerStateSync({ }); ``` +### Input (K-C) + +```js +// declare bindings so they LIST in Settings ▸ Shortcuts (display-only — +// you read the keys yourself via input()/onInput) +api.registerBindings([{ label: 'Drive forward', keys: 'W' }]); + +api.registerFrameTask(() => { + const { codes, axes } = api.input(); // codes: Set<'KeyW'...> (event.code), + if (codes.has('KeyW')) drive(1); // axes: {lx,ly,rx,ry} = VR stick axes +}); +api.onInput((kind, code) => {}); // 'down'/'up' events; returns unsubscribe + +// pause the HOST's use of an input scope while your module drives: +// 'keys' — WASD camera fly + play-mode movement +// 'locomotion' — VR left-stick locomotion +api.claimInput('keys'); // ALWAYS release when your mode ends +api.releaseInput('keys'); +``` + +### Physics (P-A) + +All mutations are INITIATOR-ONLY — the peer that started the simulation steps +the world (golden rule: authoritative, never mixed with deterministic). The +blessed recipe for driven physics (pong's paddle pattern): every peer forwards +its INPUT via `api.send({op:'drive', ...})` at ~20Hz, and only the peer where +`api.physics.isInitiator()` is true applies it. + +```js +api.physics.isInitiator(); // true while THIS peer runs the sim +api.physics.applyImpulse(uuid, [0, 5, 0]); // push a dynamic body (initiator-only) +api.physics.setJointMotor(jointId, vel, maxForce); // drive a revolute joint +api.physics.joints(); // Promise +``` + +The **car module** (`src/modules/car/`) is the worked example: replicated +primitives + motorized revolute joints, click-to-claim (pong's paddle +pattern), driver forwards `{op:'drive', throttle, steer}` at ~20Hz and only +the initiator applies wheel motors. + +### Possess (K-D) + +```js +// drive any object with WASD/arrows or the VR left stick (tank controls), +// chase camera by default; Esc releases. Possessing SELECTS the object +// (selection = lock), suspends its flow effects, and records ONE undo entry +// on release. Movement replicates as plain throttled moves. +api.possess(api.selectedUuid(), { camera: 'chase' }); // 'chase'|'orbit'|'none' +api.releasePossess(); +``` + ### Misc ```js diff --git a/PACKS.md b/PACKS.md index 204c5fbb..cdd71313 100644 --- a/PACKS.md +++ b/PACKS.md @@ -13,6 +13,27 @@ There are two kinds of pack: `PACKS_BASE` constant in `src/lib/packs.js` (e.g. a jsDelivr CDN URL over a GitHub repo), or drag a `.zip` in with **+ Import pack**. +### Default-list `.zip` packs (audio / SFX / mixed) — M-2 + +The default model packs use the model-list format (one item folder per glTF). +A default-list entry can instead point at a self-describing **`.zip`** with a +`zip` field — used for **audio / SFX packs** (or any mixed-kind pack), because the +`.zip` import path is kind-agnostic (`kindOf` stores audio as `audio`, textures as +`texture`, …). Such an entry shows an **⬇ Install pack** action in the Explorer +Packs list (right-click the pack) that fetches the `.zip` and imports it locally: + +```jsonc +// static/library/libraryList.json +{ "name": "starter-audio", "title": "Starter Music & SFX", "zip": "/library/starter-audio/pack.zip", + "license": "CC0-1.0" } +``` + +Drop the `.zip` at `static/library/starter-audio/pack.zip` (a normal pack `.zip`: +`manifest.json` + `assets/…mp3|ogg|wav`). Prefer **CC0** loops/one-shots (freesound +CC0, OpenGameArt CC0). Keep each file under the 5 MB share cap so it round-trips to +peers. Installed audio items appear in the Explorer library and can be assigned to +a **Sound** node (spatial) or the **Scene music** channel (global). + ## Repo / .zip structure ``` diff --git a/package-lock.json b/package-lock.json index e620ec7f..0b89f904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "codemirror": "^6.0.2", "fflate": "^0.8.3", "invert-color": "^2.0.0", + "n8ao": "^2.0.0", "peerjs": "^1.5.4", "postprocessing": "^6.36.4", "svelte-hamburgers": "^5.0.0", @@ -3135,6 +3136,16 @@ "thenify-all": "^1.0.0" } }, + "node_modules/n8ao": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-2.0.0.tgz", + "integrity": "sha512-7oajUGXk10jJIcjGxOgjRY2X/gy5wiLOY4eOiAfFJ51ljN6Djmsg+j8HMOip+SpqV7OkwzF9VCkDRLluxVlySA==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", diff --git a/package.json b/package.json index 5bace71f..55531381 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "codemirror": "^6.0.2", "fflate": "^0.8.3", "invert-color": "^2.0.0", + "n8ao": "^2.0.0", "peerjs": "^1.5.4", "postprocessing": "^6.36.4", "svelte-hamburgers": "^5.0.0", diff --git a/src/App.svelte b/src/App.svelte index 5db8e2f1..f8ea1a6d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,17 +12,24 @@ import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' import DrawToolbar from './components/menu/DrawToolbar.svelte' + import SculptToolbar from './components/menu/SculptToolbar.svelte' import { isLocked } from './stores/sceneStore' import { startFlowRuntime } from '$lib/flowRuntime' import { startNodeSync } from '$lib/nodesHandler' import { startLockSweep } from '$lib/lockControl' import { loadUserModules } from '$lib/userModules' import { startEnvironment } from '$lib/environment' + import { startSceneMusic } from '$lib/sceneMusic' import { startSceneBounds } from '$lib/sceneBounds' import { startShortcuts } from '$lib/shortcuts' import { startSnapping } from '$lib/snapping' import { startMultiTransform } from '$lib/multiTransform' import { startLightParams } from '$lib/lightParams' + import { startShadowDefaults } from '$lib/shadowDefaults' + import { startViewMode } from '$lib/viewMode' + import { startInputRuntime } from '$lib/inputRuntime' + import { startPossess } from '$lib/possess' + import { startHandModels } from '$lib/handModels' import { startAutosave } from '$lib/autosave' import { startSceneAssets } from '$lib/sceneAssets' import { startNetworkQuality } from '$lib/networkQuality' @@ -44,8 +51,14 @@ startLockSweep() startMultiTransform() startLightParams() + startShadowDefaults() + startViewMode() + startInputRuntime() + startPossess() + startHandModels() loadUserModules() startEnvironment() + startSceneMusic() startSceneBounds() startShortcuts() startSnapping() @@ -74,8 +87,13 @@ import('./lib/lockControl'), import('./lib/prefabs'), import('./lib/physics'), + import('./lib/joints'), + import('./lib/possess'), + import('./lib/handModels'), + import('./lib/terrainSculpt'), import('./lib/userModules'), import('./lib/environment'), + import('./lib/sceneMusic'), import('./lib/animatedImports'), import('./lib/fileHandler.svelte'), import('./lib/fileWindows'), @@ -85,6 +103,11 @@ import('./lib/sessions'), import('./lib/geometryEdit'), import('./lib/lightParams'), + import('./lib/shadowDefaults'), + import('./lib/palette'), + import('./lib/viewMode'), + import('./lib/inputRuntime'), + import('./lib/shortcuts'), import('./lib/themes'), import('./lib/vrRadialMenu'), import('./lib/vrPalette'), @@ -107,6 +130,7 @@ import('./lib/packs'), import('./lib/customNodes'), import('./lib/nodesHandler'), + import('./lib/nodeCatalog'), import('./lib/objectMenu'), import('./lib/animationPreview'), import('./lib/ai/providers'), @@ -114,8 +138,8 @@ import('./lib/ai/assistant'), import('./lib/ai/meshProviders'), import('./lib/ai/meshJobs') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib } }) } }) @@ -158,6 +182,7 @@ {/if} + diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte index 886ce28e..350eab75 100644 --- a/src/components/Outline.svelte +++ b/src/components/Outline.svelte @@ -1,5 +1,6 @@ + + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index ec2b4762..f7111932 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -7,10 +7,12 @@ import { spring } from 'svelte/motion'; import { peers, username, userdata, specatorMode, avatarConfig, viewportMenu, objectContextMenu, viewportMenuOpener } from '../stores/appStore'; import { get } from 'svelte/store'; - import { isLocked, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode } from '../stores/sceneStore'; + import { isLocked, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode } from '../stores/sceneStore'; import { selectObject, deselectObject, applySelectionSet, topLevelObjectOf } from '$lib/objectActions'; import { recordTransform } from '$lib/history'; import { suspendAnimation, resumeAnimation } from '$lib/flowRuntime'; + import { holdBody, releaseBody } from '$lib/physics'; + import { sculptObject, beginStroke, strokeMove, endStroke as sculptEndStroke, showCursorAt, hideCursor } from '$lib/terrainSculpt'; import { moduleClickHandlers, moduleInteractiveGroups } from '$lib/moduleSDK'; import { updateSpatialAudio } from '$lib/voiceChat'; import { tickAnimatedMixers } from '$lib/animatedImports'; @@ -45,6 +47,7 @@ import MeasureOverlay from './MeasureOverlay.svelte'; import AnnotationPins from './AnnotationPins.svelte'; import PingMarkers from './PingMarkers.svelte'; + import PingHighlights from './PingHighlights.svelte'; import PathWaypoints from './PathWaypoints.svelte'; import LockHighlights from './LockHighlights.svelte'; import Grid from '../extensions/Grid.svelte'; @@ -275,6 +278,8 @@ if (event.value) { // animated objects: park at their base so the gizmo edits the base transform suspendAnimation(object.uuid); + // P-A: mid-sim, a grabbed dynamic body follows the gizmo kinematically + holdBody(object.uuid); dragStartState = { uuid: object.uuid, pos: object.position.toArray(), @@ -283,6 +288,7 @@ }; } else if (dragStartState && dragStartState.uuid === object.uuid) { resumeAnimation(object.uuid); + releaseBody(object.uuid); // back to dynamic + throw velocity const after = { pos: object.position.toArray(), rot: object.rotation.toArray(), @@ -365,6 +371,8 @@ let downPosition = null; let downTime = 0; let strokeActive = false; + let sculptActive = false; // T-2 brush drag in progress + let lastSculptAt = 0; let marqueeStart = null; // shift-drag box select (13) let rightDown = null; // right-click TAP opens the Add/object menu (77) @@ -391,6 +399,21 @@ strokePointFromRay(selectionRaycaster); return; } + // T-2: sculpt mode — dragging brushes the terrain instead of orbiting + if ($sculptObject && !$isLocked && !$isVRMode) { + const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); + setRayFromEvent(event); + const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + if (hit) { + sculptActive = true; + lastSculptAt = performance.now(); + if ($orbitControls) $orbitControls.enabled = false; + beginStroke($sculptObject); + const local = terrain.worldToLocal(hit.point.clone()); + strokeMove($sculptObject, local.x, local.z); + } + return; + } // Shift+drag = marquee select (13) — orbit pauses for the gesture if (event.shiftKey && !$isLocked && !$isVRMode && !$specatorMode && !$editingObject && !$faceEditObject) { marqueeStart = [event.clientX, event.clientY]; @@ -409,6 +432,23 @@ y1: Math.max(marqueeStart[1], event.clientY) }; } + // T-2: the brush cursor tracks the terrain; a held button keeps sculpting + if ($sculptObject) { + const terrain = $objectsGroup?.getObjectByProperty('uuid', $sculptObject); + setRayFromEvent(event); + const hit = terrain ? selectionRaycaster.intersectObject(terrain, false)[0] : null; + if (hit) { + showCursorAt(hit.point); + if (sculptActive) { + const now = performance.now(); + const dt = Math.min((now - lastSculptAt) / 1000, 0.1); + lastSculptAt = now; + const local = terrain.worldToLocal(hit.point.clone()); + strokeMove($sculptObject, local.x, local.z, dt); + } + } else hideCursor(); + if (sculptActive) return; + } if (!strokeActive) return; setRayFromEvent(event); strokePointFromRay(selectionRaycaster); @@ -451,6 +491,12 @@ $marqueeRect = null; // fall through: a stationary shift-click toggles the hit object } + if (sculptActive && event.button === 0) { + sculptActive = false; + if ($orbitControls) $orbitControls.enabled = true; + sculptEndStroke(); // flush the pending preview + ONE undoable snapshot + return; + } if (strokeActive && event.button === 0) { strokeActive = false; if ($orbitControls) $orbitControls.enabled = true; @@ -881,13 +927,14 @@ - + + diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 4568e4ec..11154c71 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -35,6 +35,7 @@ loadPackItems, packByName, importPackZip, + installDefaultPackZip, removeImportedPack, licenseLabel, rememberThumb @@ -259,6 +260,21 @@ function packRowMenu(e: MouseEvent, pack: any) { e.preventDefault(); const items: any[] = [{ label: 'ⓘ Attribution / license', action: () => showPackAttribution(pack) }]; + // M-2: a default-list .zip pack (e.g. audio/SFX) installs on demand + if (pack.source === 'default' && pack.zip) + items.push({ + label: '⬇ Install pack', + action: async () => { + try { + const imported = await installDefaultPackZip(pack); + packsExpanded = true; + openFolder('pack:' + imported.name); + showToast(`Installed "${imported.title}"`); + } catch (err: any) { + showToast('Install failed: ' + (err?.message ?? 'bad .zip')); + } + } + }); if (pack.source === 'imported') items.push({ label: '🗑 Delete pack', diff --git a/src/components/editors/nodes/SoundNode.svelte b/src/components/editors/nodes/SoundNode.svelte index 063be075..3824f34a 100644 --- a/src/components/editors/nodes/SoundNode.svelte +++ b/src/components/editors/nodes/SoundNode.svelte @@ -57,12 +57,24 @@ class="nodrag accent-[#ff4000]" type="range" min="1" - max="30" + max="60" step="1" value={data.radius ?? 5} on:input={(e) => setNodeData(id, { radius: +e.currentTarget.value })} /> +
+ {#if $musicBlocked && $music.playing} + click anywhere to enable audio + {/if} +
+ setMusicVolume(v)} /> + + musicLocalVolume.set(v)} /> + Mute music on this device +

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

+ +
+ +
+ {#each [['shaded', 'Shaded'], ['shaded-ao', 'Shaded + AO'], ['wireframe', 'Wireframe']] as [mode, label] (mode)} + + {/each} +
+

+ Local render mode (ambient occlusion + wireframe are desktop-only; not shown to peers). +

Show light helpers
{/if} + + {#if !$selectedObject.isLight} +
+
+ Body + setPhysics({ mode: v })} + /> +
+ {#if ($selectedObject.userData.physics?.mode ?? 'auto') === 'dynamic'} + setPhysics({ mass: v })} /> + {/if} + setPhysics({ restitution: v })} /> + setPhysics({ friction: v })} /> +
+ Collider + setPhysics({ collider: v })} + /> +
+

+ Dynamic bodies fall and collide when a simulation runs; flow Mass/Bounciness/Friction nodes override these. +

+
+ {/if} {/if} diff --git a/src/components/menu/SculptToolbar.svelte b/src/components/menu/SculptToolbar.svelte new file mode 100644 index 00000000..ed6c4cd8 --- /dev/null +++ b/src/components/menu/SculptToolbar.svelte @@ -0,0 +1,77 @@ + + + + +{#if $sculptObject} +
+ ⛰ Sculpt +
+ {#each OPS as o (o.op)} + + {/each} +
+ + + +
+{/if} diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 067ba6d0..d7a0b2a6 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -7,6 +7,8 @@ import { syncedAnimations } from '../../stores/flowStore'; import { spatialVoice } from '$lib/voiceChat'; import { shadowQuality } from '$lib/lightParams'; + import { myHandModel, setMyHandModel } from '$lib/handModels'; + import { explorerItems } from '$lib/explorer'; import { pingColor, pingSound } from '$lib/ping'; import { PING_SOUNDS, playPing } from '$lib/pingAudio'; import { @@ -242,6 +244,24 @@ restorePanels(); $settingsSection = null; } + + // U-3: filter the (numerous) settings rows by a search query. A `use:` action + // keeps it legacy-mode safe — it toggles each row's display without touching + // the heterogeneous markup. Rows carry the `.setting-row` class; inner controls + // live in

, so hiding a row never hides a control inside a shown row. + let settingsQuery = ''; + /** @param {HTMLElement} node @param {string} query */ + function filterSettings(node: HTMLElement, query: string) { + const apply = (q: string) => { + const needle = (q || '').trim().toLowerCase(); + node.querySelectorAll('.setting-row').forEach((row) => { + const text = (row.textContent || '').toLowerCase(); + (row as HTMLElement).style.display = !needle || text.includes(needle) ? '' : 'none'; + }); + }; + apply(query); + return { update: apply }; + }