diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73225935..dac5cd03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,77 @@
per release, newest first. HTML comments like this one are stripped before
rendering, so maintainer notes stay out of the user-facing window. -->
+## 1.13.0 โ Knock it about ๐ช
+
+### โจ 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)
+
+- ๐ฅ **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
+
+- ๐ฅ **`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
diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs
index e120d62e..fb271b7c 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 = {
@@ -354,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
@@ -563,25 +908,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 +991,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 = {
@@ -957,6 +1285,7 @@ const DEFS = [
]
},
TOWERS_DEF,
+ STARS_DEF,
MIRROR_DEF,
BEAT_DEF
];
@@ -1016,7 +1345,14 @@ 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 }) => {
+ // 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;
const T = s.THREE;
s.commandsHandler.sceneCommand('/clear all');
@@ -1183,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) {
@@ -1200,14 +1544,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 = {};
@@ -1288,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)
@@ -1350,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 });
+ }, { 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/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);
diff --git a/src/App.svelte b/src/App.svelte
index 70a4490e..996e2c81 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -85,6 +85,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
import { startHelperLayer, helpersInPlay } from '$lib/helperLayer'
import { startCloudPlugin } from '$lib/cloudPlugin'
import { startShaderGraphs } from '$lib/shaderGraph'
+ import { startMaterialSharing } from '$lib/materialSharing'
import { startShaderSync } from '$lib/shaderSync'
import { startHudSync } from '$lib/hudSync'
import { startHudImages } from '$lib/hudImages'
@@ -218,6 +219,10 @@ import { startMusicToolbox } from './lib/musicToolbox'
startHelperLayer()
// shader graphs: the compile/target wiring + the replication and history seams
startShaderGraphs()
+ // D2: re-unifies shared materials by id whenever the scene changes โ every carrier
+ // (GLTF, a peer's per-object messages, undo) rebuilds materials, and the id survives
+ // where the instance does not
+ startMaterialSharing()
startShaderSync()
startHudSync()
startHudImages()
@@ -346,6 +351,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'),
@@ -449,6 +455,10 @@ import { startMusicToolbox } from './lib/musicToolbox'
import('./lib/inviteLinks'),
import('./lib/helperLayer'),
import('./lib/explorerClipboard'),
+ import('./lib/lookPresence'),
+ import('./lib/postGraphs'),
+ import('./lib/postGraphPresets'),
+ import('./lib/materialSharing'),
import('./lib/diagnostics'),
import('./lib/wireValidate'),
import('./lib/wireErrors'),
@@ -456,8 +466,8 @@ import { startMusicToolbox } from './lib/musicToolbox'
import('./lib/sceneBudget'),
import('./lib/overloadGuard'),
import('./lib/qualityGovernor')
- ]).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, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib]) => {
- 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, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib }
+ ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngineLib, musicClockLib, audioDevicesLib, audioPatchLib, vrPatchLib, musicToolboxLib, micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, knockLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, filePreviewLib, saveAsLib, windowTabsLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, mountedVolumesLib, storageUsageLib, scenePrivacyLib, touchControlsLib, playModeLib, objectListNavLib, inviteLinksLib, helperLayerLib, explorerClipboardLib, lookPresenceLib, postGraphsLib, postGraphPresetsLib, materialSharingLib, diagnosticsLib, wireValidateLib, wireErrorsLib, safeStorageLib, sceneBudgetLib, overloadGuardLib, qualityGovernorLib]) => {
+ window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, audioEngine: audioEngineLib, musicClock: musicClockLib, audioDevices: audioDevicesLib, audioPatch: audioPatchLib, vrPatch: vrPatchLib, musicToolbox: musicToolboxLib, micCapture: micCaptureLib, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, knock: knockLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, filePreview: filePreviewLib, saveAs: saveAsLib, windowTabs: windowTabsLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, mountedVolumes: mountedVolumesLib, storageUsage: storageUsageLib, scenePrivacy: scenePrivacyLib, touchControls: touchControlsLib, playMode: playModeLib, objectListNav: objectListNavLib, inviteLinks: inviteLinksLib, helperLayer: helperLayerLib, explorerClipboard: explorerClipboardLib, lookPresence: lookPresenceLib, postGraphs: postGraphsLib, postGraphPresets: postGraphPresetsLib, materialSharing: materialSharingLib, diagnostics: diagnosticsLib, wireValidate: wireValidateLib, wireErrors: wireErrorsLib, safeStorage: safeStorageLib, sceneBudget: sceneBudgetLib, overloadGuard: overloadGuardLib, qualityGovernor: qualityGovernorLib }
})
}
})
diff --git a/src/components/Outline.svelte b/src/components/Outline.svelte
index d60fda87..760800b0 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,
@@ -22,6 +26,10 @@
// side-effecting import: registers the built-in effect kinds. It also owns the
// postprocessing/n8ao imports, which is what keeps scenePost.js a pure leaf.
import { compilePostStack, disposePostStack } from '$lib/postEffects';
+ // P4: side-effecting too โ registers the `graph` kind, so a post-domain shader graph
+ // is just another entry in the stack above. This component owns the one thing that
+ // module cannot: the NormalPass, added ON DEMAND below when a graph reads normals.
+ import '$lib/postGraphs';
import { registerOutlineLayer } from '$lib/editOverlays';
import { faceEditObject, meshEditOutline } from '$lib/faceEdit';
import { editingObject } from '$lib/meshEdit';
@@ -33,6 +41,7 @@
BlendFunction,
EffectComposer,
EffectPass,
+ NormalPass,
OutlineEffect,
RenderPass
} from 'postprocessing';
@@ -117,6 +126,9 @@
let stackSkipped: any[] = [];
/** L4: does the built stack map the frame itself? (environment reads this) */
let stackTonemaps = false;
+ /** P4: the ONE normal pass, built only while something in the stack reads normals โ
+ * a second scene render per frame is not a cost to pay for a chain that never asks */
+ let normalPass: any = null;
// "would the compiled chain differ?" โ a param scrub that changes nothing must
// not thrash the composer, and the effect below re-runs on every store write
let stackSignature = '';
@@ -143,6 +155,11 @@
function rebuildStack(entries: any[]) {
for (const pass of stackPasses) (composer as any).removePass(pass);
disposePostStack(stackPasses, stackInstances);
+ if (normalPass) {
+ (composer as any).removePass(normalPass);
+ normalPass.dispose?.();
+ normalPass = null;
+ }
// `size.current` / `camera.current` are PLAIN property reads on threlte's
// CurrentWritable, so they register no dependency โ deliberate: a resize or a
// camera swap must not rebuild the whole chain, they have their own effects.
@@ -162,7 +179,24 @@
// index 1.. = after RenderPass, BEFORE the two outline passes. postprocessing's
// addPass(pass, index) re-assigns renderToScreen to whatever ends up last, so
// the outlines keep presenting.
- stackPasses.forEach((pass, offset) => (composer as any).addPass(pass, 1 + offset));
+ // P4 โ THE NORMAL PASS, on demand. A post graph that reads Scene normal (edge
+ // detect is the shipped case) needs a buffer nothing else in this app renders, and
+ // it costs a second pass over the scene โ so it is built only when an effect
+ // actually asks, and ONE of them serves every effect that does. It goes in FIRST,
+ // right after the beauty render, because a pass can only read a buffer something
+ // earlier in the chain has filled.
+ const wantNormals = stackInstances.filter((instance: any) => instance.object?.tpNeedsNormals);
+ let offsetBase = 1;
+ if (wantNormals.length) {
+ normalPass = new NormalPass(scene, camera.current);
+ (composer as any).addPass(normalPass, 1);
+ offsetBase = 2;
+ for (const instance of wantNormals) {
+ const slot = instance.object.uniforms?.get?.('normalBuffer');
+ if (slot) slot.value = normalPass.texture;
+ }
+ }
+ stackPasses.forEach((pass, offset) => (composer as any).addPass(pass, offsetBase + offset));
applyLocalPrefs();
// L4 โ TONE MAPPING, where the SCOPING is the whole point.
//
@@ -207,6 +241,8 @@
// setMainCamera does over `pass.mainCamera`. Generic, so a future effect that
// needs the camera is correct for free.
for (const instance of stackInstances) instance.def?.retarget?.(instance.object, active);
+ // the normal pass renders the scene itself, so it needs the camera swap too
+ if (normalPass) normalPass.mainCamera = active;
});
$effect(() => {
@@ -243,6 +279,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;
@@ -251,7 +289,14 @@
// 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).
@@ -259,13 +304,18 @@
void $lookOverride;
// 26-D: the quality governor's post steps โ AO first (the personal chip reads as plain
// shaded, an authored AO entry is dropped), then the whole stack. LOCAL overrides: the
- // authored document is never touched, so a peer's look is unchanged
+ // authored document is never touched, so a peer's look is unchanged.
+ // P2 + 26-D: while WATCHING a peer the authored documents and the mode come from THEIR
+ // look (`adopted`), but the governor still applies on top โ it is this machine giving
+ // up post to keep its frames, and that must not be undone by watching somebody.
const reduced = $qualityOverrides;
+ const overrides = adopted ? adopted.look : undefined;
+ const adoptedMode = adopted ? adopted.mode : $viewMode;
const entries = effectivePostStack({
- stack: resolvedDoc(POST_SCENE_KEY),
- cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null),
- mode: reduced.aoOff && $viewMode === 'shaded-ao' ? 'shaded' : $viewMode,
- localEnabled: $postEnabledLocal && !reduced.postOff,
+ stack: resolvedDoc(POST_SCENE_KEY, overrides),
+ cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera, overrides) : null),
+ mode: reduced.aoOff && adoptedMode === 'shaded-ao' ? 'shaded' : adoptedMode,
+ localEnabled: (adopted ? adopted.overrides.post !== false : $postEnabledLocal) && !reduced.postOff,
postOk,
postWarm
}).filter((entry) => !(reduced.aoOff && entry.kind === 'ao'));
@@ -374,6 +424,11 @@
// empty -> still nothing to compose. Measured as a stack that could never
// compile a pass in play mode.
if (!postWarm && ++warmupFrames > 10) postWarm = true;
+ // P4: the per-frame write a kind may declare โ the shared shader clock, which
+ // must reach a live uniform WITHOUT a chain rebuild (a rebuild per frame is
+ // what the parent plan names as the reason a param-driving node needs a seam
+ // like this one first).
+ for (const instance of stackInstances) instance.def?.tick?.(instance.object, delta);
if (renderer.xr.isPresenting || nothingToCompose) renderer.render(scene, camera.current);
else {
composer.render(delta);
@@ -486,6 +541,17 @@
outlinedSelected: outlineEffectSelected?.selection.size ?? 0,
outlinedLocked: outlineEffectLocked?.selection.size ?? 0,
stackPasses: stackPasses.length,
+ // P4: the normal pass is not one of `stackPasses` (it is the chain's, not an
+ // entry's), so the suite needs it named to prove it is added ON DEMAND
+ normals: !!normalPass,
+ graphs: stackInstances
+ .filter((instance: any) => instance.object?.tpGraphKey)
+ .map((instance: any) => ({
+ key: instance.object.tpGraphKey,
+ normals: !!instance.object.tpNeedsNormals,
+ clock: !!instance.object.tpUsesClock,
+ depth: !!(instance.object.getAttributes?.() & 1)
+ })),
plan: stackPlan,
skipped: stackSkipped.map((entry: any) => entry.kind),
kinds: stackInstances.map((instance: any) => instance.kind),
@@ -496,6 +562,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/Scene.svelte b/src/components/Scene.svelte
index f47c9396..8a36b46d 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -29,7 +29,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';
@@ -50,7 +51,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, hapticPulse } from '$lib/vrControls';
import { vrKeyboardTarget } from '$lib/vrKeyboard';
import { measureMode, measureClick } from '$lib/measure';
import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler';
@@ -278,6 +279,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();
@@ -1289,6 +1293,11 @@
// 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.
+ // 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);
@@ -1301,6 +1310,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);
element.removeEventListener('webglcontextlost', onContextLost);
diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte
index c7ec646a..9ddf8a1d 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,
@@ -58,10 +59,12 @@
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';
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';
@@ -158,6 +161,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
@@ -377,6 +383,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
@@ -710,6 +748,8 @@
@@ -796,6 +836,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/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte
index abee349a..f364ba64 100644
--- a/src/components/editors/ShaderEditor.svelte
+++ b/src/components/editors/ShaderEditor.svelte
@@ -38,7 +38,21 @@
shaderRefusalReason
} from '$lib/shaderGraph';
import { beginShaderGesture, endShaderGesture } from '$lib/shaderSync';
- import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE } from '$lib/shaderCatalog';
+ import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE, POST_OUTPUT_NODE } from '$lib/shaderCatalog';
+ // P4: the POST domain. The editor is one surface for two domains โ same cards, same
+ // palette, same pane menu โ because a post graph IS a shader graph; what differs is
+ // which nodes mean anything, which terminal node the graph ends at, and (since a post
+ // graph belongs to no object) that the post half needs a scope control while the
+ // surface half's scope is still the selection.
+ import {
+ shaderDomain,
+ activePostGraph,
+ postGraphKeys,
+ postGraphName,
+ createPostGraph,
+ deletePostGraph,
+ postPresets
+ } from '$lib/postGraphs';
import {
setDockOccupant,
dockHeight,
@@ -64,24 +78,67 @@
import { safeStorage } from '$lib/safeStorage';
const nodeTypes = Object.fromEntries(shaderNodeDefs().map((def) => [def.key, ShaderNode]));
- const catalog = shaderNodeDefs().filter((def) => def.key !== SURFACE_NODE);
- const groups = [...new Set(catalog.map((def) => def.group))];
+ const allDefs = shaderNodeDefs();
+ /**
+ * The palette for ONE domain.
+ *
+ * `stages` absent means every stage, so the arithmetic and channel nodes are in both
+ * lists; a node declaring its stages is offered only where it means something. Showing
+ * the others anyway would be the worst outcome: the compiler refuses them BY NAME, so
+ * the user would place a card, wire it, and be told it belongs somewhere else โ the
+ * palette is the right place to say so, before the wire.
+ * @param {string} which
+ */
+ const catalogFor = (which) =>
+ allDefs.filter(
+ (def) =>
+ def.key !== SURFACE_NODE &&
+ def.key !== POST_OUTPUT_NODE &&
+ (!def.stages || def.stages.includes(which === 'post' ? 'post' : 'fragment'))
+ );
+ const catalog = $derived(catalogFor($shaderDomain));
+ const groups = $derived([...new Set(catalog.map((def) => def.group))]);
const { screenToFlowPosition } = useSvelteFlow();
const LS = typeof localStorage !== 'undefined' ? localStorage : null;
- // ---- scope: purely selection-driven ----------------------------------------
+ // ---- scope: selection-driven in SURFACE, picked in POST ---------------------
const selectedUuid = $derived($selectedObjects?.length === 1 ? $selectedObjects[0] : null);
- const scope = $derived(selectedUuid ?? SCENE_GRAPH_KEY);
- const doc = $derived($shaderGraphs[scope] ?? null);
- const errors = $derived($shaderErrors[scope] ?? []);
+ const isPost = $derived($shaderDomain === 'post');
+ /** every post graph, re-derived off the store so a new one appears at once */
+ /** @param {any} _poke */
+ const graphsOf = (_poke) => postGraphKeys();
+ const postGraphs = $derived(graphsOf($shaderGraphs));
+ /** the post scope: the one asked for, else the first that exists, else none */
+ const postScope = $derived(
+ $activePostGraph && $shaderGraphs[$activePostGraph]
+ ? $activePostGraph
+ : (postGraphs[0]?.key ?? '')
+ );
+ const scope = $derived(isPost ? postScope : (selectedUuid ?? SCENE_GRAPH_KEY));
+ /** the navigator's list, split by domain (see its markup below) */
+ const treeDocuments = $derived(
+ Object.fromEntries(
+ Object.entries($shaderGraphs).filter(([key]) => key.startsWith('post:') === isPost)
+ )
+ );
+ const doc = $derived(scope ? ($shaderGraphs[scope] ?? null) : null);
+ const errors = $derived(scope ? ($shaderErrors[scope] ?? []) : []);
const ownerName = $derived(
- scope === SCENE_GRAPH_KEY
- ? 'The scene'
- : $objectsGroup?.getObjectByProperty('uuid', scope)?.name || 'This object'
+ isPost
+ ? scope
+ ? postGraphName(scope)
+ : 'No post effect'
+ : scope === SCENE_GRAPH_KEY
+ ? 'The scene'
+ : $objectsGroup?.getObjectByProperty('uuid', scope)?.name || 'This object'
);
const scopeLabel = $derived(
- scope === SCENE_GRAPH_KEY ? 'Scene default material' : ownerName + ' โ own material'
+ isPost
+ ? ownerName + ' โ post effect'
+ : scope === SCENE_GRAPH_KEY
+ ? 'Scene default material'
+ : ownerName + ' โ own material'
);
// ---- graph settings (LOCAL prefs, the node editor's set) -------------------
@@ -164,6 +221,12 @@
// ---- actions ---------------------------------------------------------------
function createGraph() {
+ if (isPost) {
+ // in the post half "create" MINTS a document (there is no object to attach one
+ // to), and the new one becomes the scope so you are looking at what you made
+ activePostGraph.set(createPostGraph({}));
+ return;
+ }
if (scope !== SCENE_GRAPH_KEY) {
const object = $objectsGroup?.getObjectByProperty('uuid', scope);
if (object && !shaderTargetSupported(object)) {
@@ -189,6 +252,11 @@
}
function removeGraph() {
+ if (isPost) {
+ if (scope) deletePostGraph(scope);
+ activePostGraph.set(null);
+ return;
+ }
if (scope !== SCENE_GRAPH_KEY) {
const object = $objectsGroup?.getObjectByProperty('uuid', scope);
if (object) detachFrom(object);
@@ -198,7 +266,7 @@
/** @param {string} key @param {{x:number,y:number}} [at] */
function addNode(key, at) {
- if (!doc) return;
+ if (!doc || !scope) return;
const id = key + '_' + Math.random().toString(36).slice(2, 7);
setShaderGraphFor(scope, {
nodes: [...doc.nodes, { id, type: key, position: at ?? { x: 140, y: 120 }, data: {} }]
@@ -506,6 +574,42 @@
+
+{#snippet domainSwitch()}
+
- {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'}
+
+ {#each graphErrorsOf(entry, $shaderErrors) as message, i (i)}
+
{message}
+ {/each}
{:else if param.type === 'asset'}
{param.label}
diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte
index 6d4d8aea..496ad0be 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';
@@ -19,6 +20,7 @@
const appVersionString = versionString();
import { vrFaceCap, VR_FACE_CAP } from '$lib/faceEdit';
import { doubleClickAction, DOUBLE_CLICK_ACTIONS } from '$lib/selectionPrefs';
+ import { shareDuplicatedMaterials } from '$lib/materialSharing';
import { lengthUnit, angleUnit, LENGTH_UNIT_KEYS } from '$lib/units';
import { vrVertexCap, VR_VERTEX_CAP } from '$lib/meshEdit';
import { syncedAnimations } from '../../stores/flowStore';
@@ -983,6 +985,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
+
Node editor
+
+
+
+
+ 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}
@@ -1135,6 +1144,16 @@
with a frozen snapshot of the compiled material and nothing to edit. An object inheriting
the scene default keeps inheriting it either way
+
+
+
+ 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
+
+
Wireframe & outline
diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte
index f82dad58..b673e0a1 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 { ingestGate, resolveIngestGate } from '$lib/commandsHandler.svelte'
import { ingestVerdict, profileFor } from '$lib/sceneBudget'
@@ -48,6 +51,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');
@@ -467,7 +476,7 @@ $effect(() => {
openSceneSection('Post-processing')
+ 'Ambient occlusion, colour grading, camera effects and shader materials (Configure Scene โธ Scene look)',
+ action: () => openSceneSection('Scene look')
},
{ label: 'Screenshot', icon: 'camera', action: screenshot }
]
diff --git a/src/components/ui/Section.svelte b/src/components/ui/Section.svelte
index c7479710..ad447aea 100644
--- a/src/components/ui/Section.svelte
+++ b/src/components/ui/Section.svelte
@@ -6,8 +6,21 @@
// rendered TEXT, and hides itself when nothing matches.
import { inspectorFilter, inspectorScrollTo } from '../../stores/appStore';
- /** @type {{label?: string, collapsible?: boolean, open?: boolean, children?: any}} */
- let { label = '', collapsible = true, open = $bindable(true), children = null } = $props();
+ /**
+ * P6: `aliases` are OLD deep-link names this section still answers to. A section's
+ * label is user-visible copy and its deep-link name is an identifier written down in
+ * menus, other components and suites โ the 21-G1 rule is that the word may change and
+ * the identifier may not, so a rename lists what it used to be called instead of
+ * hunting every caller (and silently missing one).
+ * @type {{label?: string, aliases?: string[], collapsible?: boolean, open?: boolean, children?: any}}
+ */
+ let {
+ label = '',
+ aliases = [],
+ collapsible = true,
+ open = $bindable(true),
+ children = null
+ } = $props();
const LS = typeof localStorage !== 'undefined' ? localStorage : null;
// persisted collapse, keyed by the section label (static per instance โ a
@@ -43,7 +56,7 @@
const request = $inspectorScrollTo;
// a request is either "Grid" or "Camera:Saved views" (section:sub-anchor)
const [wanted, anchor] = String(request ?? '').split(':');
- if (!request || wanted !== label) return;
+ if (!request || (wanted !== label && !aliases.includes(wanted))) return;
collapsed = false;
try {
LS?.setItem('inspector:sec:' + label, 'open');
diff --git a/src/lib/docking.js b/src/lib/docking.js
index 5450ea80..7897f97b 100644
--- a/src/lib/docking.js
+++ b/src/lib/docking.js
@@ -5,23 +5,43 @@ import { safeStorage } from './safeStorage';
// Docking lite (phase 81L). Drag a window near the left/right screen edge to
// dock it as a full-height panel (--z-drawer tier); drag its header away to
-// float it again. One window per edge (a second drop wiggles the occupant โ
-// SPLITS stay in pending/81). With the Inspector drawer open, a right-docked
-// panel offsets inward as a second column.
+// float it again. With the Inspector drawer open, a right-docked panel offsets
+// inward as a second column.
// 21-H2: the Library drawer it also used to give way to no longer exists.
+//
+// 81.4 (v1.13) โ EDGE SPLITS: an edge holds up to TWO windows, stacked. Dropping a
+// second floating window onto a docked panel (or onto that edge) splits the column
+// vertically; a draggable divider between them sets the share (persisted per side);
+// undocking either member collapses the split back to one full-height panel. The
+// quiz decision stands: splits are for DOCKED panels, tabbing (83) for floating ones
+// โ `headerTargetAt` already excludes docked windows, so the two never compete. A
+// third window on a full edge is refused with the same wiggle as before.
const EDGE = 44; // px from a screen edge that counts as a dock drop
const TOP = 64; // below the topbar, like the drawers
const DRAWER_WIDTH = 320;
+/** max windows stacked on one edge */
+const MAX_PER_SIDE = 2;
+/** half the divider's thickness, in px โ each panel gives up this much */
+const GAP = 3;
+/** the smallest share either split panel may be dragged to */
+const MIN_RATIO = 0.15;
-/** @type {{left: string|null, right: string|null}} */
-let docked = { left: null, right: null };
+/** @type {{left: string[], right: string[]}} */
+let docked = { left: [], right: [] };
/** @type {Map} */
-const registry = new Map(); // key -> {node, prevRect, handle}
+const registry = new Map(); // key -> {node, prevRect, handle, divider}
+
+/** @param {any} value a persisted side: a string (pre-81.4) or an array
+ * @returns {string[]} */
+function sideList(value) {
+ if (Array.isArray(value)) return value.filter((k) => typeof k === 'string').slice(0, MAX_PER_SIDE);
+ return typeof value === 'string' && value ? [value] : [];
+}
try {
const saved = JSON.parse(safeStorage.getItem('dockedWindows') ?? 'null');
- if (saved) docked = { left: saved.left ?? null, right: saved.right ?? null };
+ if (saved) docked = { left: sideList(saved.left), right: sideList(saved.right) };
} catch {}
function persist() {
@@ -34,6 +54,12 @@ function widthOf(key) {
return Math.min(Math.max(Number.isNaN(value) ? 300 : value, 250), Math.round(window.innerWidth * 0.4));
}
+/** the TOP panel's share of a split column @param {'left'|'right'} side */
+function ratioOf(side) {
+ const value = parseFloat(safeStorage.getItem('dockSplit:' + side) ?? '0.5');
+ return Math.min(Math.max(Number.isNaN(value) ? 0.5 : value, MIN_RATIO), 1 - MIN_RATIO);
+}
+
function drawerOpen() {
return get(inspectorClose) === false;
}
@@ -50,18 +76,31 @@ function leftInset() {
return el ? Math.round(el.getBoundingClientRect().right) + 8 : 228;
}
-/** @param {string} key */
+/** @param {string} key @returns {'left'|'right'|null} */
function sideOf(key) {
- if (docked.left === key) return 'left';
- if (docked.right === key) return 'right';
+ if (docked.left.includes(key)) return 'left';
+ if (docked.right.includes(key)) return 'right';
return null;
}
export { sideOf as dockSideOf };
+/** the keys stacked on a side, top first @param {'left'|'right'} side */
+export function dockedOn(side) {
+ return [...docked[side]];
+}
+
function applyAll() {
for (const [key] of registry) apply(key);
}
+/** re-lay out every member of one side @param {'left'|'right'} side */
+function applySide(side) {
+ for (const key of docked[side]) apply(key);
+}
+
+/** the usable column: from TOP to the bottom dock, as a CSS length */
+const COLUMN = `(100vh - ${TOP}px - var(--bottom-inset, 0px))`;
+
/** @param {string} key */
function apply(key) {
const entry = registry.get(key);
@@ -70,20 +109,40 @@ function apply(key) {
const { node } = entry;
if (!side) {
delete node.dataset.docked;
+ delete node.dataset.dockSlot;
entry.handle?.remove();
entry.handle = null;
+ entry.divider?.remove();
+ entry.divider = null;
return;
}
- const width = widthOf(key);
+ // the column's width belongs to the SIDE: every member reads the top one's
+ // (the handle below writes all of them, so either panel's handle resizes the column)
+ const width = widthOf(docked[side][0] ?? key);
+ const stack = docked[side];
+ const slot = stack.indexOf(key); // 0 = top (or the only one), 1 = bottom
+ const split = stack.length > 1;
+ const r = ratioOf(side);
node.dataset.docked = side;
node.style.position = 'fixed';
- node.style.top = TOP + 'px';
- // edge-docked windows end above a docked Flow/Explorer (105)
- node.style.height = `calc(100vh - ${TOP}px - var(--bottom-inset, 0px))`;
- node.style.width = width + 'px';
node.style.maxWidth = 'none';
node.style.maxHeight = 'none';
+ node.style.width = width + 'px';
node.style.zIndex = '30'; // --z-drawer tier
+ if (!split) {
+ delete node.dataset.dockSlot;
+ node.style.top = TOP + 'px';
+ // edge-docked windows end above a docked Flow/Explorer (105)
+ node.style.height = `calc(100vh - ${TOP}px - var(--bottom-inset, 0px))`;
+ } else if (slot === 0) {
+ node.dataset.dockSlot = 'top';
+ node.style.top = TOP + 'px';
+ node.style.height = `calc(${COLUMN} * ${r} - ${GAP}px)`;
+ } else {
+ node.dataset.dockSlot = 'bottom';
+ node.style.top = `calc(${TOP}px + ${COLUMN} * ${r} + ${GAP}px)`;
+ node.style.height = `calc(${COLUMN} * ${1 - r} - ${GAP}px)`;
+ }
const offset = side === 'right' && drawerOpen() ? DRAWER_WIDTH : 0;
node.style.left = side === 'left' ? leftInset() + 'px' : window.innerWidth - width - offset + 'px';
node.style.right = 'auto';
@@ -103,8 +162,11 @@ function apply(key) {
const move = (/** @type {any} */ ev) => {
const delta = currentSide === 'left' ? ev.clientX - startX : startX - ev.clientX;
const next = Math.min(Math.max(250, startWidth + delta), Math.round(window.innerWidth * 0.4));
- safeStorage.setItem('dockWidth:' + key, String(next));
- apply(key);
+ // the column is one width: write it for every member of the side
+ for (const k of currentSide ? docked[currentSide] : [key])
+ safeStorage.setItem('dockWidth:' + k, String(next));
+ if (currentSide) applySide(currentSide);
+ else apply(key);
};
const up = () => {
handle.removeEventListener('pointermove', move);
@@ -118,32 +180,82 @@ function apply(key) {
}
entry.handle.style.left = side === 'left' ? 'auto' : '-3px';
entry.handle.style.right = side === 'left' ? '-3px' : 'auto';
+ // 81.4: the split DIVIDER hangs off the TOP panel's bottom edge
+ if (split && slot === 0) {
+ if (!entry.divider) {
+ const divider = document.createElement('div');
+ divider.className = 'dock-split-divider resize-cue';
+ // INSIDE the panel, hugging its bottom edge: every docked window is
+ // `overflow-hidden`, so a handle hung past the edge is clipped away and
+ // takes no pointer events at all (the width handle only works because half
+ // of its 6px sits inside). 7px is the same hot zone the width grip uses.
+ divider.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:7px;cursor:ns-resize;touch-action:none;z-index:5;';
+ divider.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ divider.setPointerCapture(e.pointerId);
+ const currentSide = sideOf(key);
+ if (!currentSide) return;
+ const inset =
+ parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--bottom-inset')) || 0;
+ const column = Math.max(1, window.innerHeight - TOP - inset);
+ const move = (/** @type {any} */ ev) => {
+ const next = Math.min(Math.max((ev.clientY - TOP) / column, MIN_RATIO), 1 - MIN_RATIO);
+ safeStorage.setItem('dockSplit:' + currentSide, String(Math.round(next * 1000) / 1000));
+ applySide(currentSide);
+ };
+ const up = () => {
+ divider.removeEventListener('pointermove', move);
+ divider.removeEventListener('pointerup', up);
+ };
+ divider.addEventListener('pointermove', move);
+ divider.addEventListener('pointerup', up);
+ });
+ node.appendChild(divider);
+ entry.divider = divider;
+ }
+ } else if (entry.divider) {
+ entry.divider.remove();
+ entry.divider = null;
+ }
}
-/** @param {string} key @param {'left'|'right'} side */
-function dock(key, side) {
+/** wiggle a panel that refused a drop @param {string} occupantKey */
+function wiggle(occupantKey) {
+ const occupant = registry.get(occupantKey)?.node;
+ occupant?.animate(
+ [{ transform: 'translateX(0)' }, { transform: 'translateX(-8px)' }, { transform: 'translateX(8px)' }, { transform: 'translateX(0)' }],
+ { duration: 220 }
+ );
+}
+
+/**
+ * @param {string} key @param {'left'|'right'} side
+ * @param {'top'|'bottom'=} slot where to land when the side already holds a window
+ * (81.4): default bottom
+ */
+function dock(key, side, slot = 'bottom') {
const entry = registry.get(key);
if (!entry) return false;
- if (docked[side] && docked[side] !== key) {
- // occupied โ wiggle the occupant instead (splits live in pending/81)
- const occupant = registry.get(/** @type {string} */ (docked[side]))?.node;
- occupant?.animate(
- [{ transform: 'translateX(0)' }, { transform: 'translateX(-8px)' }, { transform: 'translateX(8px)' }, { transform: 'translateX(0)' }],
- { duration: 220 }
- );
+ const stack = docked[side].filter((k) => k !== key);
+ if (stack.length >= MAX_PER_SIDE) {
+ wiggle(stack[stack.length - 1]);
return false;
}
- if (sideOf(key)) docked[/** @type {'left'|'right'} */ (sideOf(key))] = null;
- entry.prevRect = {
- left: entry.node.style.left,
- top: entry.node.style.top,
- width: entry.node.style.width,
- height: entry.node.style.height,
- zIndex: entry.node.style.zIndex
- };
- docked[side] = key;
+ const from = sideOf(key);
+ if (from) docked[from] = docked[from].filter((k) => k !== key);
+ if (!from || from !== side || entry.prevRect == null)
+ entry.prevRect = {
+ left: entry.node.style.left,
+ top: entry.node.style.top,
+ width: entry.node.style.width,
+ height: entry.node.style.height,
+ zIndex: entry.node.style.zIndex
+ };
+ docked[side] = slot === 'top' ? [key, ...stack] : [...stack, key];
persist();
- apply(key);
+ if (from && from !== side) applySide(from);
+ applySide(side);
return true;
}
@@ -151,14 +263,17 @@ function dock(key, side) {
export function undock(key, x, y) {
const side = sideOf(key);
if (!side) return;
- docked[side] = null;
+ docked[side] = docked[side].filter((k) => k !== key);
persist();
const entry = registry.get(key);
if (entry) {
const { node, prevRect } = entry;
delete node.dataset.docked;
+ delete node.dataset.dockSlot;
entry.handle?.remove();
entry.handle = null;
+ entry.divider?.remove();
+ entry.divider = null;
node.style.height = prevRect?.height || '';
node.style.width = prevRect?.width || '';
node.style.maxWidth = '';
@@ -167,12 +282,17 @@ export function undock(key, x, y) {
node.style.left = (x != null ? Math.max(0, x - 120) : parseFloat(prevRect?.left) || 200) + 'px';
node.style.top = (y != null ? Math.max(0, y - 12) : parseFloat(prevRect?.top) || 120) + 'px';
}
+ // 81.4: the member left behind takes the whole column again
+ applySide(side);
}
/** @type {any} */ let zoneEl = null;
-/** @param {'left'|'right'|null} side */
-function showZone(side) {
- if (!side) {
+/**
+ * @param {{side: 'left'|'right', split?: {node: any, slot: 'top'|'bottom'}} | null} target
+ * `split` = the docked panel the drop would share, and which half the new window takes
+ */
+function showZone(target) {
+ if (!target) {
zoneEl?.remove();
zoneEl = null;
return;
@@ -180,11 +300,34 @@ function showZone(side) {
if (!zoneEl) {
zoneEl = document.createElement('div');
zoneEl.id = 'dock-zone';
- zoneEl.style.cssText = `position:fixed;top:${TOP}px;bottom:0;width:80px;z-index:29;pointer-events:none;background:rgb(37 99 235 / .25);border:2px dashed rgb(96 165 250 / .8);`;
+ zoneEl.style.cssText = `position:fixed;z-index:29;pointer-events:none;background:rgb(37 99 235 / .25);border:2px dashed rgb(96 165 250 / .8);`;
document.body.appendChild(zoneEl);
}
- zoneEl.style.left = side === 'left' ? '0' : 'auto';
- zoneEl.style.right = side === 'right' ? '0' : 'auto';
+ const { side, split } = target;
+ if (split) {
+ // the half of the occupant the new window would take, with a label โ the 83
+ // merge-target affordance, one drop kind over
+ const r = split.node.getBoundingClientRect();
+ zoneEl.dataset.split = split.slot;
+ zoneEl.style.top = (split.slot === 'top' ? r.top : r.top + r.height / 2) + 'px';
+ zoneEl.style.height = r.height / 2 + 'px';
+ zoneEl.style.bottom = 'auto';
+ zoneEl.style.left = r.left + 'px';
+ zoneEl.style.right = 'auto';
+ zoneEl.style.width = r.width + 'px';
+ zoneEl.textContent = 'โ Split panel';
+ zoneEl.style.cssText +=
+ 'display:flex;align-items:center;justify-content:center;color:white;font-size:11px;font-weight:600;';
+ } else {
+ delete zoneEl.dataset.split;
+ zoneEl.textContent = '';
+ zoneEl.style.top = TOP + 'px';
+ zoneEl.style.bottom = '0';
+ zoneEl.style.height = 'auto';
+ zoneEl.style.width = '80px';
+ zoneEl.style.left = side === 'left' ? '0' : 'auto';
+ zoneEl.style.right = side === 'right' ? '0' : 'auto';
+ }
}
let subscribed = false;
@@ -199,7 +342,7 @@ const isCoarse = () =>
* @param {any} node @param {{key: string}} options
*/
export function dockable(node, { key }) {
- registry.set(key, { node, prevRect: null, handle: null });
+ registry.set(key, { node, prevRect: null, handle: null, divider: null });
// On mobile, register only (so destroy() still cleans up) but wire NO edge-drag
// handlers and restore NO persisted side-dock โ the window stays a normal floating
// window. The persisted desktop preference is left untouched.
@@ -222,7 +365,8 @@ export function dockable(node, { key }) {
});
window.addEventListener('resize', applyAll);
}
- if (sideOf(key)) apply(key); // restore a persisted dock
+ const restored = sideOf(key);
+ if (restored) applySide(restored); // restore a persisted dock (and re-share a split)
let dragging = false;
/** @param {any} e */
@@ -263,18 +407,43 @@ export function dockable(node, { key }) {
if (bottomDockWouldTake(key, e.clientY)) return null;
return e.clientX < EDGE ? 'left' : e.clientX > window.innerWidth - EDGE ? 'right' : null;
};
+ /** 81.4: the docked panel under the pointer (on a side with room), and which half
+ * @param {any} e @returns {{side: 'left'|'right', node: any, slot: 'top'|'bottom'} | null} */
+ const splitAt = (e) => {
+ if (bottomDockWouldTake(key, e.clientY)) return null;
+ for (const side of /** @type {const} */ (['left', 'right'])) {
+ const stack = docked[side].filter((k) => k !== key);
+ if (stack.length !== 1) continue;
+ const other = registry.get(stack[0])?.node;
+ if (!other?.isConnected) continue;
+ const r = other.getBoundingClientRect();
+ if (r.width === 0) continue;
+ const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
+ const onEdge = edgeAt(e) === side;
+ if (!inside && !onEdge) continue;
+ return { side, node: other, slot: inside && e.clientY < r.top + r.height / 2 ? 'top' : 'bottom' };
+ }
+ return null;
+ };
+ /** @param {any} e @returns {{side: 'left'|'right', split?: {node: any, slot: 'top'|'bottom'}} | null} */
+ const targetAt = (e) => {
+ const split = splitAt(e);
+ if (split) return { side: split.side, split: { node: split.node, slot: split.slot } };
+ const side = edgeAt(e);
+ return side ? { side } : null;
+ };
/** @param {any} e */
const move = (e) => {
if (!dragging) return;
- showZone(edgeAt(e));
+ showZone(targetAt(e));
};
/** @param {any} e */
const up = (e) => {
if (!dragging) return;
dragging = false;
showZone(null);
- const side = edgeAt(e);
- if (side) dock(key, side);
+ const target = targetAt(e);
+ if (target) dock(key, target.side, target.split?.slot ?? 'bottom');
};
node.addEventListener('pointerdown', down, true); // capture: beats dragWindow while docked
window.addEventListener('pointermove', move);
@@ -286,6 +455,10 @@ export function dockable(node, { key }) {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
registry.delete(key);
+ // a member that unmounts leaves its partner the column (the persisted
+ // pair survives for its return, since the layout is re-derived on apply)
+ const side = sideOf(key);
+ if (side) applySide(side);
}
};
}
diff --git a/src/lib/dragWindow.js b/src/lib/dragWindow.js
index 33a1b346..a693a938 100644
--- a/src/lib/dragWindow.js
+++ b/src/lib/dragWindow.js
@@ -257,7 +257,10 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi
// transition โ so this rule, which re-clamps from the window's OWN stored
// rect, threw the revealed member back to wherever it last floated and left
// the tab strip standing on the group rect without it. See `applyMember`.
- if (vis && !wasVisible && !node.dataset.tabMember && typeof rect.left === 'number') {
+ // 81.4: a side-DOCKED window is in exactly the same position โ docking.js
+ // owns its rect, and a reveal (reopening it after a reload) would throw
+ // it back to wherever it last floated, half out of its column.
+ if (vis && !wasVisible && !node.dataset.tabMember && !node.dataset.docked && typeof rect.left === 'number') {
clamp(true);
apply();
}
diff --git a/src/lib/flowPrefs.js b/src/lib/flowPrefs.js
new file mode 100644
index 00000000..b79d89b2
--- /dev/null
+++ b/src/lib/flowPrefs.js
@@ -0,0 +1,37 @@
+import { writable } from 'svelte/store';
+import { safeStorage } from './safeStorage';
+
+// 114 (v1.13): the node editor's MOUSE BINDINGS, a LOCAL pref (a leaf: svelte/store
+// only plus the safeStorage leaf, so Settings and Nodes.svelte can both reach it with no
+// cycle โ and a write that cannot reach the disk still applies for this session).
+//
+// '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(safeStorage.getItem(KEY)));
+flowMouseBindings.subscribe((value) => safeStorage.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/src/lib/flowRuntime.js b/src/lib/flowRuntime.js
index a927a19a..a6ba1e78 100644
--- a/src/lib/flowRuntime.js
+++ b/src/lib/flowRuntime.js
@@ -405,6 +405,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 {
@@ -1796,6 +1817,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
@@ -1806,6 +1828,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,
@@ -1830,7 +1861,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;
}
@@ -2211,6 +2246,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
@@ -2679,6 +2726,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,
@@ -3074,12 +3174,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
@@ -3456,6 +3564,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
new file mode 100644
index 00000000..32d551a7
--- /dev/null
+++ b/src/lib/knock.js
@@ -0,0 +1,601 @@
+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 { sessionNow } from './sessionClock'; // 25-E: `at` crosses the wire, so it is SESSION time
+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;
+/** 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();
+/** @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.
+ // 25-E: `sessionNow()`, not `Date.now()` โ this number is compared on another
+ // machine (A2 folds it into the trigger log beside every other stamp, and the log
+ // is ordered), and the session clock is what makes those comparisons mean the same
+ // thing on a peer whose own clock is minutes out.
+ lastStamp = Math.max(sessionNow(), 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
+ });
+ }
+ // 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;
+}
+
+/** @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) : sessionNow(), // 25-E, as above
+ 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)[], 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;
+}
+
+export function stopKnock() {
+ if (!started) return;
+ started = false;
+ hands = null;
+ heldUuids = null;
+ haptic = 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/lookPresence.js b/src/lib/lookPresence.js
new file mode 100644
index 00000000..ec78259f
--- /dev/null
+++ b/src/lib/lookPresence.js
@@ -0,0 +1,159 @@
+// P2 (per-camera looks follow-up) โ WATCH ADOPTS THE WATCHED PEER'S LOOK.
+//
+// THE GAP THIS FILLS. Watching a peer adopts their CAMERA (spectator mode parents our
+// camera to their avatar) but not their LOOK STATE, so "watch" showed their viewpoint
+// through OUR grading rules: our view mode, our local post kill switch, the camera WE
+// were previewing (none, while watching) and our own `lookOverride` map. A peer looking
+// through a hero camera with a `replace` look, or one whose Set Look node had switched
+// the scene look off, was invisible from outside โ and that silence is the P1 lesson
+// exactly: a feature scoped to a viewpoint does nothing until that viewpoint is active,
+// and from the watcher's seat it was never active.
+//
+// SO PRESENCE GAINS THE LOOK STATE, and it is deliberately the `campreview` shape (the
+// `gamePresence` precedent, one domain over): a tiny per-peer message, a map keyed by
+// peer id, a reply riding the `getmodulestate` request, dropped at every disconnect
+// site. Ephemeral, never saved, never undone. ADDITIVE: a peer running an older build
+// never sends one, its row stays ABSENT, and the watcher falls back to its own state โ
+// which is what it did before this module existed.
+//
+// WHAT IS DELIBERATELY NOT REPLICATED: adoption is scoped to the WATCH SESSION. Nothing
+// here writes `viewMode`, `viewportOverrides` or `lookOverride` on the watcher โ those
+// are this viewer's own comfort settings โ and leaving the watch restores the watcher's
+// own resolution simply because Outline stops consulting the row. A watched peer in
+// `wireframe` is adopted for the CHAIN only (post skips, as it does for them); the
+// wireframe override material itself stays a local diagnostic.
+//
+// A LEAF as far as the history cycle is concerned: stores plus scenePost (which history's
+// own subtree does not reach) and cameraPreview. Nothing here registers a history kind.
+
+import { writable, get } from 'svelte/store';
+import { peers } from '../stores/appStore';
+import { viewMode } from '../stores/sceneStore';
+import { viewportOverrides } from './viewportOverrides';
+import { lookOverride } from './scenePost';
+import { cameraPreview } from './cameraPreview';
+
+/**
+ * @typedef {{camera: string|null, mode: string, overrides: Record, look: Record}} LookState
+ */
+
+/** REMOTE peers only, `peerId -> LookState`. Absent = unknown = use our own.
+ * @type {import('svelte/store').Writable>} */
+export const peerLooks = writable({});
+
+/** What we last put on the wire, so a store poke that changes nothing sends nothing.
+ * Declared ABOVE the module-level subscribes below (the TDZ rule). */
+let sentSignature = '';
+
+/**
+ * ONE normalizer at the boundary (the normalizeScenePost rule): a row from a newer
+ * build keeps fields we do not know, a malformed one still reads as a usable state.
+ * @param {any} raw @returns {LookState}
+ */
+export function normalizeLookState(raw) {
+ const source = raw && typeof raw === 'object' ? raw : {};
+ const overrides = source.overrides && typeof source.overrides === 'object' ? source.overrides : {};
+ const look = source.look && typeof source.look === 'object' ? source.look : {};
+ return {
+ ...source,
+ camera: typeof source.camera === 'string' && source.camera ? source.camera : null,
+ mode: typeof source.mode === 'string' && source.mode ? source.mode : 'shaded',
+ overrides: { ...overrides },
+ look: { ...look }
+ };
+}
+
+/** This peer's OWN look state โ the row a watcher would resolve from. */
+export function myLookState() {
+ const over = get(viewportOverrides);
+ return normalizeLookState({
+ camera: get(cameraPreview)?.uuid ?? null,
+ mode: get(viewMode),
+ // only the two layers a look is made of; the HUD is its own presence story
+ overrides: { post: over.post !== false, shaders: over.shaders !== false },
+ look: { ...get(lookOverride) }
+ });
+}
+
+/** @param {LookState} state */
+function broadcast(state) {
+ /** @type {any} */
+ const peer = get(peers);
+ if (!peer?.peer?.id) return;
+ peer.send({ type: 'lookstate', peerId: peer.peer.id, ...state });
+}
+
+/** Publish our state when it CHANGES. @param {boolean} [force] send even if unchanged */
+export function publishLookState(force = false) {
+ const state = myLookState();
+ const signature = JSON.stringify(state);
+ if (!force && signature === sentSignature) return false;
+ sentSignature = signature;
+ broadcast(state);
+ return true;
+}
+
+/**
+ * Tell a newly connected peer how we are looking at the scene. Rides `getmodulestate`,
+ * beside `sendCameraPreviewState`. Unconditional, unlike play mode: there is no state
+ * that "absent" already describes here โ a watcher with no row uses its OWN settings,
+ * which may differ from ours in every field โ and the message is under 200 bytes.
+ */
+export function sendLookState() {
+ broadcast(myLookState());
+}
+
+/** Remote peer's state arrived (live change or handshake reply). @param {any} data */
+export function applyRemoteLookState(data) {
+ if (!data?.peerId) return;
+ const state = normalizeLookState(data);
+ peerLooks.update((map) => ({ ...map, [data.peerId]: state }));
+}
+
+/** A peer left: drop its row, so a watcher of a departed peer is never stranded on
+ * their look. @param {string} peerId */
+export function dropPeerLook(peerId) {
+ peerLooks.update((map) => {
+ if (!(peerId in map)) return map;
+ const next = { ...map };
+ delete next[peerId];
+ return next;
+ });
+}
+
+/** The row a watcher resolves from, or null when the peer never told us (an older
+ * build, or a peer we have not met) โ in which case the caller uses its own state.
+ * @param {string} peerId @returns {LookState|null} */
+export function lookOf(peerId) {
+ return get(peerLooks)[peerId] ?? null;
+}
+
+/**
+ * One line for the watch banner, or '' when there is nothing to say. The P1 rule โ
+ * a scoped feature must say on its own surface when it cannot take effect โ applied to
+ * the two silent cases: the peer never shared a look state, or they have the scene look
+ * switched off locally, so what we see through their eyes is deliberately ungraded.
+ * @param {string} peerId
+ */
+export function watchLookNote(peerId) {
+ const row = lookOf(peerId);
+ if (!row) return 'showing your own look โ they have not shared theirs';
+ if (row.overrides.post === false) return 'they have the scene look switched off';
+ return '';
+}
+
+// ---- outbound, on change ------------------------------------------------------
+// Module-level subscribes run their callback SYNCHRONOUSLY at eval, which is why
+// `sentSignature` is declared above them. Every callback is signature-gated, so the
+// first (boot) run and any no-op poke send nothing; there is no peer at boot anyway.
+if (typeof window !== 'undefined') {
+ viewMode.subscribe(() => publishLookState());
+ viewportOverrides.subscribe(() => publishLookState());
+ lookOverride.subscribe(() => publishLookState());
+ cameraPreview.subscribe(() => publishLookState());
+}
+
+/** test/debug view */
+export function lookPresenceDebug() {
+ return { mine: myLookState(), peers: { ...get(peerLooks) } };
+}
diff --git a/src/lib/materialSharing.js b/src/lib/materialSharing.js
new file mode 100644
index 00000000..e2a35ba7
--- /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, pokeScene } from '../stores/sceneStore';
+import { safeStorage } from './safeStorage'; // 27-H: a pref write must not kill this subscriber
+
+/** 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(
+ safeStorage.getItem('shareDuplicatedMaterials') === 'true'
+);
+// 27-H: safeStorage never throws and keeps a failed write in memory for the session, so the
+// hand-rolled try/catch this used to carry (and the `typeof` guard, which SecurityError walks
+// straight through) are both its job now.
+shareDuplicatedMaterials.subscribe((value) => {
+ safeStorage.setItem('shareDuplicatedMaterials', String(value));
+});
+
+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();
+ pokeScene(); // 26-B
+ 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) pokeScene(); // 26-B
+ 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 dfd8cdfd..3dc71c01 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, pokeScene } 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,15 @@ 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;
+ }
+ // 26-B: one poke for the whole op, never `objectsGroup.update` per call site
pokeScene();
if (replicate)
broadcast({ type: 'objectParameters', parameter: 'material', uuid: uuid, material: type });
diff --git a/src/lib/meshEdit.js b/src/lib/meshEdit.js
index e292cdd8..09bd4fd7 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)
@@ -1353,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());
}
@@ -1428,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);
@@ -1448,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);
}
@@ -1523,6 +1557,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 +1642,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/src/lib/moduleSDK.js b/src/lib/moduleSDK.js
index 1fb11ddc..7ae128b5 100644
--- a/src/lib/moduleSDK.js
+++ b/src/lib/moduleSDK.js
@@ -178,7 +178,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));
@@ -728,6 +736,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/objectActions.js b/src/lib/objectActions.js
index 202e82ba..48c84686 100644
--- a/src/lib/objectActions.js
+++ b/src/lib/objectActions.js
@@ -35,6 +35,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,
@@ -428,7 +430,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)
@@ -498,8 +507,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';
@@ -523,7 +539,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`
@@ -588,8 +609,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;
@@ -599,6 +623,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 8cafb7b5..25f26037 100644
--- a/src/lib/peerHandler.svelte.js
+++ b/src/lib/peerHandler.svelte.js
@@ -38,6 +38,8 @@ import { applyRemoteCameraPreview, clearPeerPreview, sendCameraPreviewState } fr
// 21-F3: play-mode PRESENCE, the campreview shape โ a tiny per-peer message, a reply
// riding the getmodulestate request, and a drop on disconnect (golden rule 3).
import { applyRemotePlayMode, dropPeerPlayMode, sendPlayModeState } from '$lib/gamePresence';
+// P2: watch adopts the watched peer's LOOK STATE โ the campreview shape, one row per peer
+import { applyRemoteLookState, dropPeerLook, sendLookState } from '$lib/lookPresence';
// P2b: which SCENE each peer is standing in โ the gamePresence shape exactly
import { applyRemotePeerScene, dropPeerScene, sendMySceneState, peerScenes, myScene, mySceneWire, amPrivate, privacySplit, elsewhereThan, sceneOfPeer, ROOM_SCOPED, canApplyByRoom, sameRoomOrUnknown, myRoomLabel, roomLabelOf } from '$lib/peerScenes';
// 21-G2: the project manifest โ a latest-wins singleton like environment/scenephysics
@@ -50,8 +52,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, simulating, simPaused } 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';
@@ -680,6 +683,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() }
@@ -695,6 +702,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') {
@@ -899,8 +914,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') {
@@ -1025,6 +1042,7 @@ export class PeerConnection {
} else if(data.type == 'getmodulestate') {
sendModuleStates(data.sender);
sendCameraPreviewState(); // 16-P5: ride the same late-joiner request
+ sendLookState(); // P2: ...and how we are looking at the scene (view mode, look switches)
sendPlayModeState(); // 21-F3: ...and so does play-mode presence
sendMySceneState(); // P2b: ...and where we are standing
sendPeerVarsState(); // 21-G4: ...and our own per-player row, if we hold one
@@ -1068,6 +1086,10 @@ export class PeerConnection {
} else if(data.type == 'campreview') {
// 16-P5: presence only โ "X is previewing camera Y" (peers may join it)
applyRemoteCameraPreview(data);
+ } else if(data.type == 'lookstate') {
+ // P2: presence only โ how X is LOOKING at the scene, so a watcher renders
+ // X's chain rather than its own. ADDITIVE: an older build never sends it.
+ applyRemoteLookState(data);
} else if(data.type == 'annotation') {
applyAnnotation(data);
} else if(data.type == 'annotations') {
@@ -1304,6 +1326,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)
@@ -1571,6 +1601,7 @@ export class PeerConnection {
this.openedPeers.delete(peerId);
handleDisconnected(peerId);
clearPeerPreview(peerId); // 16-P5
+ dropPeerLook(peerId); // P2
dropPeerPlayMode(peerId); // 21-F3
dropPeerScene(peerId); // P2b
dropPeerVars(peerId); // 21-G4
@@ -1605,6 +1636,7 @@ export class PeerConnection {
this.openedPeers.delete(peerId);
handleDisconnected(peerId);
clearPeerPreview(peerId); // 16-P5
+ dropPeerLook(peerId); // P2
dropPeerPlayMode(peerId); // 21-F3
dropPeerScene(peerId); // P2b
dropPeerVars(peerId); // 21-G4
diff --git a/src/lib/peerScenes.js b/src/lib/peerScenes.js
index 88713320..5b3188be 100644
--- a/src/lib/peerScenes.js
+++ b/src/lib/peerScenes.js
@@ -580,7 +580,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 9ef86c34..0c278a50 100644
--- a/src/lib/physics.js
+++ b/src/lib/physics.js
@@ -24,7 +24,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
@@ -1244,6 +1245,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 001c13b6..88102534 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/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/scenePhysics.js b/src/lib/scenePhysics.js
index c9a3141e..8992b6c7 100644
--- a/src/lib/scenePhysics.js
+++ b/src/lib/scenePhysics.js
@@ -30,6 +30,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
});
@@ -88,6 +97,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),
@@ -136,6 +146,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, [
@@ -147,6 +172,7 @@ export function normalizeScenePhysics(raw) {
'ccd',
'timeScale',
'play',
+ 'knock',
'changedAt',
'type' // the wire envelope's own field, never state
]);
@@ -165,6 +191,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,
@@ -173,7 +201,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/scenePost.js b/src/lib/scenePost.js
index 0583233b..7a788757 100644
--- a/src/lib/scenePost.js
+++ b/src/lib/scenePost.js
@@ -24,7 +24,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 -----------------------------------------------------
@@ -50,7 +50,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 };
@@ -195,10 +203,12 @@ export function clearLookOverride(key) {
}
/** A document with any runtime override folded in โ what the renderer should use.
- * @param {string} [key] */
-export function resolvedDoc(key) {
+ * `overrides` defaults to OUR map; P2 hands a WATCHED peer's map in, so what we render
+ * while watching is what their Set Look nodes did to them, not what ours did to us.
+ * @param {string} [key] @param {Record} [overrides] */
+export function resolvedDoc(key, overrides) {
const doc = postStackFor(key);
- const over = get(lookOverride)[key || POST_SCENE_KEY];
+ const over = (overrides ?? get(lookOverride))[key || POST_SCENE_KEY];
return typeof over === 'boolean' && over !== doc.enabled ? { ...doc, enabled: over } : doc;
}
@@ -388,7 +398,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 714f0105..7486223b 100644
--- a/src/lib/shaderGraph.js
+++ b/src/lib/shaderGraph.js
@@ -25,6 +25,10 @@ import {
registerShaderTextureListener,
startShaderTextures
} from './shaderTextures.js';
+// P5: the LOCAL right to switch this layer off. `viewportOverrides` is a leaf (stores
+// only), and its `shaders` key has been DECLARED since B precisely so this phase adds a
+// renderer rather than a new concept โ see that module for the rule it encodes.
+import { viewportOverrides, renderLayer } from './viewportOverrides.js';
/** The reserved key for the scene default material (layer 2). */
export const SCENE_GRAPH_KEY = 'scene';
@@ -218,6 +222,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
@@ -226,6 +257,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);
@@ -294,9 +330,54 @@ export function defaultTargetsFor(key) {
return out;
}
+/**
+ * P5 โ THE LOCAL RENDER GATE.
+ *
+ * THE RULE THIS KEEPS (viewportOverrides states it, and the look plan makes it the
+ * answer all three layers must share): an authored layer is SCENE DATA and renders for
+ * everyone by DEFAULT. Nobody opts in to seeing the scene. What is local is the right to
+ * switch it off HERE โ for performance, for comfort, or to see what an object really
+ * looks like underneath.
+ *
+ * Off is a SWAP, never a detach: the documents, the compiled materials and the base
+ * materials all stay exactly as they were, so switching back costs no compile and a peer
+ * sees nothing at all. Deliberately NOT `scene.overrideMaterial` (which wireframe and the
+ * UV checker use): that replaces EVERY material in the scene, and this layer is only the
+ * ones a graph drives.
+ */
+let shadersOn = true;
+
+/** Swap every shader-driven object to (or back from) its own material. @param {boolean} on */
+function applyShaderLayer(on) {
+ if (on === shadersOn) return;
+ shadersOn = on;
+ const group = get(objectsGroup);
+ if (!group) return;
+ group.traverse((/** @type {any} */ node) => {
+ 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 (26-B: one
+ // coalesced poke, which is what every call site in the tree uses now)
+ pokeScene();
+}
+
+/** 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
@@ -449,8 +530,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
@@ -630,6 +715,29 @@ export function setShaderParam(key, nodeId, param, value) {
// injected material as if it were the object's own), and undo snapshots the tree. So
// every serializer must read the BASE material, and the GRAPH rides beside the
// snapshot โ the `animated` / `multiMaterial` shape, keyed by uuid.
+//
+// P6 โ THE SAVE-PATH AUDIT, done once across the three layers now that they meet.
+// Every one of them is a KEYED DOCUMENT plus a runtime product, and the rule that
+// falls out is the same each time: SAVE THE DOCUMENT, never the product.
+//
+// layer 3 / 2 shaderGraphs[uuid | 'scene'] product: a compiled Material
+// layer 1 shaderGraphs['post:'] product: a compiled Effect
+// layer 1 postStacks['scene' | camUuid] product: composer passes
+//
+// Carriers, checked for each: the WIRE (`shadergraph` / `scenepost`, both
+// latest-wins on a stamp), AUTOSAVE and SESSIONS/.tpscene (`shaderGraphsSnapshot` +
+// `scenePostSnapshot`, both beside the objects rather than inside them), and UNDO
+// (the `'shadergraph'` and `'look'` history kinds). The PRODUCTS are carried by
+// nobody, deliberately โ they are rebuilt from the document on the other side.
+//
+// The ritual `parkShaderMaterials` performs is only needed where a product is ATTACHED
+// TO THE SCENE TREE, which is layers 2 and 3 alone: a post Effect lives in the
+// composer, which no serializer walks, so P4 needed no fourth park. The one thing a
+// reader should not expect to find is a park for post graphs; this paragraph is why.
+//
+// KNOWN AND ACCEPTED: `shaderGraphsSnapshot()` writes `{}` into every save even when
+// nothing uses it (SH4's shape), where the post stack writes `null`. Harmless and
+// pre-existing; changing it is a save-format decision, not a rendering one.
/** parks nest (a serializer inside a serializer), so this is a DEPTH, not a flag */
let materialParkDepth = 0;
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/viewMode.js b/src/lib/viewMode.js
index a509644a..36ab069c 100644
--- a/src/lib/viewMode.js
+++ b/src/lib/viewMode.js
@@ -87,6 +87,26 @@ export function aoSupported() {
*/
export const postSupported = aoSupported;
+/**
+ * P6 โ THE CAPABILITY GATE QUESTION, DECIDED: shader-driven MATERIALS are NOT gated
+ * with fullscreen passes, and they stay separate deliberately.
+ *
+ * The measured evidence behind `postSupported` is about a fullscreen pass linking
+ * broken on an old ANGLE/D3D11 stack, and three properties of that failure do not
+ * transfer to a material:
+ * - BLAST RADIUS. A broken post pass takes the WHOLE viewport (black, or a frozen
+ * frame, with nothing in the console); a material that fails to compile affects the
+ * objects it drives, and `compileAndApply` already keeps the last good material and
+ * reports the error, so the scene is still there to look at.
+ * - WHERE THEY RUN. Post is skipped entirely in VR; materials are the only layer of
+ * the look that works in a headset. One gate would switch off the half that works.
+ * - WHO COMPILES. A material goes through three's own program path, which every other
+ * material in the app already uses โ gating it would be gating three itself.
+ * So the local switch for materials is `viewportOverrides.shaders` (a CHOICE) and the
+ * capability gate stays post-only (a REFUSAL). If a driver is ever found that breaks
+ * generated materials specifically, it wants its own gate and its own measurement.
+ */
+
let started = false;
export function startViewMode() {
if (started || typeof window === 'undefined') return;
diff --git a/src/lib/viewportOverrides.js b/src/lib/viewportOverrides.js
index 19e517cd..9e116d31 100644
--- a/src/lib/viewportOverrides.js
+++ b/src/lib/viewportOverrides.js
@@ -26,8 +26,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 = [
@@ -39,7 +41,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/src/lib/vrControls.js b/src/lib/vrControls.js
index d826b3c3..eef68025 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/src/lib/wireValidate.js b/src/lib/wireValidate.js
index 9f5276de..134cacc6 100644
--- a/src/lib/wireValidate.js
+++ b/src/lib/wireValidate.js
@@ -96,6 +96,12 @@ export const VALIDATORS = {
name: (d) => isUuid(d.uuid) && typeof d.name === 'string',
move: (d) => isUuid(d.uuid) && isVec3(d.pos) && isQuatOrEuler(d.rot) && isVec3(d.scale),
throw: (d) => isUuid(d.uuid),
+ // 24-A: a knock. The velocities are applied to a body the moment this lands, so the
+ // triples are checked here rather than trusted by `applyHit`.
+ hit: (d) => isUuid(d.uuid) && isArray(d.linvel) && isArray(d.angvel) && typeof d.speed === 'number',
+ // P2: a peer's LOOK presence row. `overrides` and `look` are read as objects the moment
+ // this lands, so a malformed row is dropped here rather than breaking the watch chain.
+ lookstate: (d) => typeof d.peerId === 'string' && (d.overrides === undefined || typeof d.overrides === 'object'),
simulate: (d) => typeof d.running === 'boolean' || typeof d.paused === 'boolean',
loading: (d) => isArray(d.uuids),
object: (d) => d.element !== undefined,
diff --git a/tests/e2e/dock-splits.test.cjs b/tests/e2e/dock-splits.test.cjs
new file mode 100644
index 00000000..4835f011
--- /dev/null
+++ b/tests/e2e/dock-splits.test.cjs
@@ -0,0 +1,136 @@
+// 81.4 (v1.13): DOCKED EDGE SPLITS โ dropping a second floating window onto a docked
+// panel splits that edge into two stacked panels with a draggable divider (max two per
+// edge, persisted); undocking either member collapses the split. Tabbing (83) stays a
+// floating-window affair: the two never compete because `headerTargetAt` excludes
+// docked windows. Counterfactual (proven at commit time): with the split branch removed
+// the drop wiggles the occupant and one panel remains โ the two-panel check goes red.
+const h = require('./helpers.cjs');
+
+const box = (page, sel) => page.locator(sel).boundingBox();
+const attrs = (page, sel) =>
+ page.evaluate((sel) => {
+ const el = document.querySelector(sel);
+ return el ? { docked: el.dataset.docked ?? null, slot: el.dataset.dockSlot ?? null } : null;
+ }, sel);
+const dragHeader = async (page, sel, dx, dy, to) => {
+ const b = await box(page, sel);
+ await page.mouse.move(b.x + 100, b.y + 10);
+ await page.mouse.down();
+ await page.mouse.move(to ? to[0] : b.x + 100 + dx, to ? to[1] : b.y + 10 + dy, { steps: 12 });
+ await page.mouse.up();
+ await page.waitForTimeout(300);
+};
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const vw = await A.page.evaluate(() => window.innerWidth);
+ const vh = await A.page.evaluate(() => window.innerHeight);
+
+ // dock the object list on the right (81L)
+ await A.page.locator('p[title="Object list (O)"]').click();
+ await A.page.waitForTimeout(400);
+ await dragHeader(A.page, '#object-list', 0, 0, [vw - 15, 400]);
+ let list = await box(A.page, '#object-list');
+ h.check(Math.abs(list.x + list.width - vw) < 4 && list.height > vh * 0.8, `object list docked right, full height (${Math.round(list.height)})`);
+ const column = list.height;
+
+ // a second window dropped ONTO it splits the edge
+ await A.page.locator('p[title="Node editor (N)"]').click();
+ await A.page.waitForTimeout(500);
+ await A.page.locator('#flow-undock').click();
+ await A.page.waitForTimeout(400);
+ const flow0 = await box(A.page, '#flow-window');
+ await A.page.mouse.move(flow0.x + 120, flow0.y + 12);
+ await A.page.mouse.down();
+ await A.page.mouse.move(list.x + list.width / 2, list.y + list.height * 0.75, { steps: 12 });
+ const zone = await A.page.evaluate(() => {
+ const z = document.querySelector('#dock-zone');
+ return z ? { split: z.dataset.split ?? null, text: z.textContent } : null;
+ });
+ h.check(zone?.split === 'bottom', `hovering the lower half shows the SPLIT affordance for the bottom slot (${JSON.stringify(zone)})`);
+ await A.page.mouse.up();
+ await A.page.waitForTimeout(300);
+ list = await box(A.page, '#object-list');
+ let flow = await box(A.page, '#flow-window');
+ const a1 = await attrs(A.page, '#object-list');
+ const a2 = await attrs(A.page, '#flow-window');
+ h.check(a1?.docked === 'right' && a2?.docked === 'right', `both windows are docked right (${JSON.stringify([a1, a2])})`);
+ h.check(a1?.slot === 'top' && a2?.slot === 'bottom', 'the occupant kept the top slot, the dropped window took the bottom');
+ h.check(Math.abs(flow.y - (list.y + list.height + 6)) < 3, `they stack with the divider between them (gap ${Math.round(flow.y - list.y - list.height)})`);
+ h.check(Math.abs(list.height + flow.height + 6 - column) < 4, `the two heights fill the column (${Math.round(list.height)} + ${Math.round(flow.height)} vs ${Math.round(column)})`);
+ h.check(Math.abs(list.width - flow.width) < 2 && Math.abs(flow.x + flow.width - vw) < 4, 'both share the column width on the edge');
+
+ // the divider drags and the share persists
+ const divider = await box(A.page, '#object-list .dock-split-divider');
+ h.check(!!divider, 'the top panel carries the split divider (premise)');
+ await A.page.mouse.move(divider.x + divider.width / 2, divider.y + divider.height / 2);
+ await A.page.mouse.down();
+ await A.page.mouse.move(divider.x + divider.width / 2, divider.y - 150, { steps: 10 });
+ await A.page.mouse.up();
+ await A.page.waitForTimeout(200);
+ const listUp = await box(A.page, '#object-list');
+ const flowUp = await box(A.page, '#flow-window');
+ h.check(list.height - listUp.height > 120, `dragging the divider up shrinks the top panel (${Math.round(list.height)} โ ${Math.round(listUp.height)})`);
+ h.check(flowUp.height - flow.height > 120, `...and grows the bottom one (${Math.round(flow.height)} โ ${Math.round(flowUp.height)})`);
+ const ratio = await A.page.evaluate(() => parseFloat(localStorage.getItem('dockSplit:right') ?? 'NaN'));
+ h.check(ratio > 0.15 && ratio < 0.5, `the share persisted (${ratio})`);
+ const savedDock = await A.page.evaluate(() => JSON.parse(localStorage.getItem('dockedWindows') ?? 'null'));
+ h.check(Array.isArray(savedDock?.right) && savedDock.right.join() === 'objects,flow', `the stack persisted in order (${JSON.stringify(savedDock)})`);
+
+ // a THIRD window is refused (max two per edge): the Explorer, undocked
+ // (the wiggle is an animation; the assertion is that nothing docked)
+ const third = await A.page.evaluate(() => {
+ const s = window.__stores;
+ return typeof s.explorerClose?.set === 'function';
+ });
+ if (third) {
+ await A.page.evaluate(() => window.__stores.explorerClose.set(false));
+ await A.page.waitForTimeout(600);
+ const undockBtn = A.page.locator('#explorer-undock');
+ if (await undockBtn.count()) {
+ await undockBtn.first().click();
+ await A.page.waitForTimeout(400);
+ const ex = await box(A.page, '#explorer-window');
+ if (ex) {
+ await A.page.mouse.move(ex.x + 100, ex.y + 10);
+ await A.page.mouse.down();
+ await A.page.mouse.move(vw - 15, 300, { steps: 10 });
+ await A.page.mouse.up();
+ await A.page.waitForTimeout(300);
+ const exAttrs = await attrs(A.page, '#explorer-window');
+ h.check(exAttrs?.docked !== 'right', 'a third window on a full edge is refused');
+ await A.page.evaluate(() => window.__stores.explorerClose.set(true));
+ }
+ }
+ }
+
+ // reload: both come back split at the same share. Each window's side-dock is
+ // restored as its NODE mounts, so both have to be open again first โ driven
+ // through the stores rather than the toggles, which would close whatever the
+ // reload happened to restore as open.
+ await h.freshReload(A);
+ await A.page.waitForTimeout(1200);
+ await A.page.evaluate(() => {
+ window.__stores.objectListClose.set(false);
+ window.__stores.flowGraphClose.set(false);
+ });
+ await A.page.waitForTimeout(1200);
+ const listBack = await box(A.page, '#object-list');
+ const flowBack = await box(A.page, '#flow-window');
+ const back1 = await attrs(A.page, '#object-list');
+ const back2 = await attrs(A.page, '#flow-window');
+ h.check(back1?.slot === 'top' && back2?.slot === 'bottom', `the split survives a reload (${JSON.stringify([back1, back2])})`);
+ h.check(listBack && Math.abs(listBack.height - listUp.height) < 6, `...at the dragged share (${Math.round(listUp.height)} โ ${Math.round(listBack?.height)})`);
+
+ // undocking one member collapses the split
+ await dragHeader(A.page, '#flow-window', 400, 200);
+ const flowFloat = await attrs(A.page, '#flow-window');
+ const listFull = await box(A.page, '#object-list');
+ const listAttrs = await attrs(A.page, '#object-list');
+ h.check(flowFloat?.docked === null, 'drag-away undocks the bottom member');
+ h.check(listAttrs?.docked === 'right' && listAttrs?.slot === null && listFull.height > vh * 0.8, `the remaining panel takes the whole column again (${Math.round(listFull.height)})`);
+ h.check(!(await A.page.locator('#object-list .dock-split-divider').count()), 'the divider is gone with the split');
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/docking.test.cjs b/tests/e2e/docking.test.cjs
index b2925118..aebb295a 100644
--- a/tests/e2e/docking.test.cjs
+++ b/tests/e2e/docking.test.cjs
@@ -48,7 +48,8 @@ h.run(async () => {
const savedWidth = await A.page.evaluate(() => localStorage.getItem('dockWidth:objects'));
h.check(Math.abs(parseInt(savedWidth) - wider.width) < 4, 'dock width persisted');
- // a second window cannot take the same edge
+ // 81.4: a second window on the same edge SPLITS it (two stacked panels) โ the
+ // refusal is for a THIRD; the split's own coverage is dock-splits
await A.page.locator('p[title="Node editor (N)"]').click();
await A.page.waitForTimeout(500);
await A.page.locator('#flow-undock').click();
@@ -60,11 +61,29 @@ h.run(async () => {
await A.page.mouse.up();
await A.page.waitForTimeout(300);
const flowBox = await A.page.locator('#flow-window').boundingBox();
- h.check(flowBox.height < vh * 0.8, 'occupied edge refuses a second dock');
+ const split = await A.page.evaluate(() => ({
+ flow: document.querySelector('#flow-window').dataset.docked,
+ slot: document.querySelector('#flow-window').dataset.dockSlot,
+ list: document.querySelector('#object-list').dataset.dockSlot
+ }));
+ h.check(split.flow === 'right' && flowBox.height < vh * 0.8, `occupied edge splits for a second window (${JSON.stringify(split)})`);
+ // the drop landed on the UPPER half of the occupant (y 300 of a 64..720 panel), and
+ // the half you release over is the slot you take โ so the newcomer is on top here
+ h.check(split.slot === 'top' && split.list === 'bottom', `the half you drop on is the slot you get (${JSON.stringify(split)})`);
- // ...but the other edge works
+ // dragging the split member's header away undocks it (the split collapses)...
await A.page.mouse.move(flowBox.x + 120, flowBox.y + 12);
await A.page.mouse.down();
+ await A.page.mouse.move(flowBox.x - 300, flowBox.y + 200, { steps: 10 });
+ await A.page.mouse.up();
+ await A.page.waitForTimeout(300);
+ const listAgain = await A.page.locator('#object-list').boundingBox();
+ h.check(listAgain.height > vh * 0.8, 'the panel left behind takes the whole column again');
+
+ // ...and the other edge works, on the next gesture
+ const flowFree = await A.page.locator('#flow-window').boundingBox();
+ await A.page.mouse.move(flowFree.x + 120, flowFree.y + 12);
+ await A.page.mouse.down();
await A.page.mouse.move(10, 300, { steps: 10 });
await A.page.mouse.up();
await A.page.waitForTimeout(300);
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);
+});
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/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);
+});
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/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);
+});
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);
+});
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);
+});
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
// =====================================================================
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);
+});
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 ===');
diff --git a/tests/e2e/scene-post-effects.test.cjs b/tests/e2e/scene-post-effects.test.cjs
index ffccceb1..fc0c4f70 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');
@@ -219,6 +223,9 @@ h.run(async () => {
window.__stores.viewMode.set('custom');
window.__stores.inspectorKind.set('scene');
window.__stores.inspectorClose.set(false);
+ // P6 renamed the section; the collapse pref is keyed by the LABEL, so this seeds
+ // both names rather than silently opening nothing
+ localStorage.setItem('inspector:sec:Scene look', 'open');
localStorage.setItem('inspector:sec:Post-processing', 'open');
await new Promise((r) => setTimeout(r, 900));
});
diff --git a/tests/e2e/scene-post-ui.test.cjs b/tests/e2e/scene-post-ui.test.cjs
index 0716f66c..dcb26b24 100644
--- a/tests/e2e/scene-post-ui.test.cjs
+++ b/tests/e2e/scene-post-ui.test.cjs
@@ -1,4 +1,4 @@
-// L3 โ Configure Scene โธ Post-processing, driven through the REAL UI.
+// L3 โ Configure Scene โธ Scene look (the post stack's half), driven through the REAL UI.
//
// Kept separate from `scene-post` (the pixel/replication suite) so both stay
// readable and each runs standalone. Everything here is a DOM assertion or a real
@@ -90,7 +90,7 @@ h.run(async () => {
await page.evaluate(() => {
window.__stores.inspectorKind.set('scene');
window.__stores.inspectorClose.set(false);
- for (const label of ['File', 'Actions', 'Environment', 'Music', 'View', 'Camera', 'Grid', 'Snapping', 'Physics', 'Background', 'Fog', 'Post-processing'])
+ for (const label of ['File', 'Actions', 'Environment', 'Music', 'View', 'Camera', 'Grid', 'Snapping', 'Physics', 'Background', 'Fog', 'Scene look'])
localStorage.setItem('inspector:sec:' + label, 'open');
});
await h.freshReload(A);
@@ -106,7 +106,7 @@ h.run(async () => {
// find the panel scroller the same way Section.svelte does โ by real
// scrollability, not by class name
const anchor = [...document.querySelectorAll('.ui-section-label')].find((el) =>
- (el.textContent ?? '').startsWith('Post-processing')
+ (el.textContent ?? '').startsWith('Scene look')
);
if (!anchor) return { found: false };
let scroller = anchor.parentElement;
@@ -119,14 +119,17 @@ h.run(async () => {
scroller.scrollTop = scroller.scrollHeight; // start at the far end
return { found: true, scrollable: true, before: scroller.scrollTop, max: scroller.scrollHeight };
});
- h.check(scrolled.found === true, '1.1 the Post-processing section renders in the scene inspector');
+ h.check(scrolled.found === true, '1.1 the Scene look section renders in the scene inspector');
h.check(scrolled.scrollable === true, '1.2 premise: the panel is genuinely scrollable, so a scroll can be measured');
+ // P6 renamed the section and kept its deep-link NAME working (Section `aliases`), so
+ // this drives the OLD name on purpose โ every menu, component and suite that wrote it
+ // down must keep landing
await page.evaluate(() => window.__stores.openSceneSection('Post-processing'));
await page.waitForTimeout(1200);
const landed = await page.evaluate(() => {
const anchor = [...document.querySelectorAll('.ui-section-label')].find((el) =>
- (el.textContent ?? '').startsWith('Post-processing')
+ (el.textContent ?? '').startsWith('Scene look')
);
let scroller = anchor?.parentElement;
while (scroller) {
@@ -521,5 +524,88 @@ h.run(async () => {
'7.4 the panel never threw: ' + JSON.stringify(h.pageErrors(A).slice(0, 2))
);
+ // ---------------------------------------------------------------- section 8
+ // P6 โ ONE STORY. The three layers of the authored look are one section now, so the
+ // section has to SAY what the look is and account for the layer whose editing surface
+ // lives elsewhere (materials are a dock tab). The cost line is the point: an author
+ // learns what a look costs in exactly one place, and the materials half now speaks in
+ // the same voice as "Effects: N, passes: M".
+ console.log('\n=== 8. one Scene look story (P6) ===');
+ await page.evaluate(() => window.__stores.openSceneSection('Scene look'));
+ await page.waitForTimeout(700);
+ const story = await page.evaluate(() => ({
+ summary: document.querySelector('#scene-look-shaders')?.textContent?.trim() ?? '',
+ opener: !!document.querySelector('#scene-look-open-shader'),
+ counts: document.querySelector('#post-counts')?.textContent?.trim() ?? ''
+ }));
+ h.check(
+ /no shader materials/i.test(story.summary),
+ '8.1 with no graphs the materials line says so: "' + story.summary + '"'
+ );
+ h.check(story.opener, '8.2 ...and the way in is right there');
+ h.check(/Effects:/.test(story.counts), '8.3 the post cost line is still in the same section');
+
+ // a scene default, and the cost line reads it
+ await page.evaluate(() => {
+ window.__stores.commandsHandler.sceneCommand('/create box');
+ });
+ await page.waitForTimeout(900);
+ await page.evaluate(() =>
+ window.__stores.shaderGraph.setShaderGraphFor('scene', {
+ nodes: [
+ { id: 'surface', type: 'surface', position: { x: 360, y: 120 }, data: {} },
+ { id: 'col', type: 'color', position: { x: 90, y: 130 }, data: { value: '#44ff88' } }
+ ],
+ edges: [
+ { id: 'e-col.out-surface.albedo', source: 'col', sourceHandle: 'out', target: 'surface', targetHandle: 'albedo' }
+ ]
+ })
+ );
+ await page.waitForTimeout(1800);
+ const withScene = await page.evaluate(
+ () => document.querySelector('#scene-look-shaders')?.textContent?.trim() ?? ''
+ );
+ h.check(
+ /scene default/i.test(withScene) && /driving \d+ object/.test(withScene) && /program/.test(withScene),
+ '8.4 a scene default is reported WITH its cost, in the post line\'s voice: "' + withScene + '"'
+ );
+
+ // The opener reaches the editor โ whose SCOPE follows the selection, so the button
+ // lands on the scene default with nothing selected and on an object's own material
+ // when one is. Both halves are asserted, because the first draft of this check
+ // pressed the button straight after `/create box` (which SELECTS) and read the
+ // object scope as a failure when it was the rule working.
+ await page.evaluate(() => window.__stores.objectActions.deselectObject());
+ await page.waitForTimeout(500);
+ await page.evaluate(() => document.querySelector('#scene-look-open-shader').click());
+ await page.waitForTimeout(1200);
+ const shaderTab = await page.evaluate(() => ({
+ tab: !!document.querySelector('#shader-editor'),
+ scope: document.querySelector('#shader-scope')?.textContent?.trim() ?? ''
+ }));
+ h.check(
+ shaderTab.tab && /scene default/i.test(shaderTab.scope),
+ '8.5 the button opens the shader editor, scoped to the scene default with nothing selected: ' +
+ JSON.stringify(shaderTab)
+ );
+ const boxUuid = await page.evaluate(() => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ let found = '';
+ group.traverse((n) => {
+ if (n.isMesh && !found) found = n.uuid;
+ });
+ return found;
+ });
+ await page.evaluate((u) => window.__stores.objectActions.selectObject(u), boxUuid);
+ await page.waitForTimeout(900);
+ const objectScope = await page.evaluate(
+ () => document.querySelector('#shader-scope')?.textContent?.trim() ?? ''
+ );
+ h.check(
+ /own material/i.test(objectScope),
+ '8.6 ...and follows the selection to that object: "' + objectScope + '"'
+ );
+
await h.finish(browser);
});
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);
+});
diff --git a/tests/e2e/watch-look.test.cjs b/tests/e2e/watch-look.test.cjs
new file mode 100644
index 00000000..978b12e5
--- /dev/null
+++ b/tests/e2e/watch-look.test.cjs
@@ -0,0 +1,276 @@
+// P2 โ WATCH ADOPTS THE WATCHED PEER'S LOOK.
+//
+// Watching a peer adopts their camera; this makes it adopt their LOOK STATE too: the
+// camera they look through (and its own look), their view mode, their local post
+// switch and their Set Look overrides. Presence, the `campreview` shape โ never data,
+// never saved, dropped on disconnect, and scoped to the watch: nothing of theirs is
+// ever written into the watcher's own settings.
+//
+// The compiled chain (`__postDebug().kinds`) is the thing measured, because it is
+// what the composer will draw; `adoptedFrom` says WHOSE state it came from.
+
+const h = require('./helpers.cjs');
+
+/** register two visible test effects through the public registry seam */
+async function registerFills(page) {
+ return page.evaluate(() => {
+ const { Effect, BlendFunction } = window.__stores.postprocessing;
+ const fill = (name, rgb) =>
+ new Effect(
+ name,
+ 'void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { outputColor = vec4(' +
+ rgb +
+ ', 1.0); }',
+ { blendFunction: BlendFunction.SET }
+ );
+ window.__stores.scenePost.registerPostEffect('fill-red', {
+ label: 'Fill red',
+ group: 'test',
+ make: () => fill('FillRed', '1.0, 0.0, 0.0')
+ });
+ window.__stores.scenePost.registerPostEffect('fill-blue', {
+ label: 'Fill blue',
+ group: 'test',
+ make: () => fill('FillBlue', '0.0, 0.0, 1.0')
+ });
+ return window.__stores.scenePost.postEffectKinds().map((d) => d.kind);
+ });
+}
+
+const chainOf = (page) => page.evaluate(() => window.__postDebug().kinds.join(','));
+const adoptedFrom = (page) => page.evaluate(() => window.__postDebug().adoptedFrom);
+const rowsOn = (page) =>
+ page.evaluate(() => {
+ let map = null;
+ window.__stores.lookPresence.peerLooks.subscribe((m) => (map = m))();
+ return map;
+ });
+const bannerNote = (page) =>
+ page.evaluate(() => document.querySelector('.spectator-note')?.textContent?.trim() ?? '');
+const watching = (page) =>
+ page.evaluate(() => {
+ let v = null;
+ window.__stores.specatorMode.subscribe((x) => (v = x))();
+ return v;
+ });
+const settle = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Press the real Watch button for the ONE remote peer in the popover.
+ *
+ * `.peer-watch` is worn by TWO buttons: the Watch button, and the join-a-peer's-camera
+ * button beside it (`.peer-watch.peer-preview`), which only renders while that peer is
+ * previewing a camera โ which is exactly the fixture this suite builds. A bare
+ * `.peer-watch` selector therefore picked the JOIN button and B previewed A's camera
+ * instead of watching A: every reading came out "fill-blue" (the right answer for the
+ * wrong reason) and nothing followed A afterwards. Select by exclusion, and assert the
+ * count so a third button in that row can never quietly take the click.
+ */
+async function pressWatch(peer) {
+ await peer.page.evaluate(() => document.querySelector('#peers-trigger').click());
+ await settle(400);
+ return peer.page.evaluate(() => {
+ const box = document.querySelector('#peers-popover');
+ const all = box ? [...box.querySelectorAll('.peer-watch')] : [];
+ const watch = all.filter((b) => !b.classList.contains('peer-preview') && !b.disabled);
+ if (watch.length !== 1) return { ok: false, buttons: all.map((b) => b.className) };
+ watch[0].click();
+ return { ok: true, buttons: all.map((b) => b.className) };
+ });
+}
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ await registerFills(A.page);
+ await registerFills(B.page);
+
+ // ---------------------------------------------------------------- section 0
+ console.log('\n=== 0. the fixture: a scene look, a camera with a replace look ===');
+ const camUuid = await A.page.evaluate(async () => {
+ window.__stores.commandsHandler.sceneCommand('/create sphere');
+ window.__stores.commandsHandler.sceneCommand('/create camera');
+ await new Promise((r) => setTimeout(r, 1200));
+ return window.__stores.cameraObjects.listCameraObjects()[0]?.uuid ?? '';
+ });
+ h.check(!!camUuid, '0.1 premise: a camera object exists');
+ await A.page.evaluate(async (cam) => {
+ const post = window.__stores.scenePost;
+ post.postStacks.set({});
+ post.addPostEffect('fill-red');
+ post.addPostEffect('fill-blue', undefined, cam);
+ post.setCameraLookMode(cam, 'replace');
+ window.__stores.objectActions.deselectObject();
+ window.__stores.viewMode.set('shaded');
+ }, camUuid);
+ await B.page.evaluate(() => {
+ window.__stores.objectActions.deselectObject();
+ window.__stores.viewMode.set('shaded');
+ });
+ await settle(1200);
+ h.check((await chainOf(A.page)) === 'fill-red', '0.2 premise: A renders the scene look (' + (await chainOf(A.page)) + ')');
+
+ await h.connect(B, A);
+ await h.eventually(
+ () => chainOf(B.page),
+ (c) => c === 'fill-red',
+ '0.3 the scene look reaches B over the handshake and B renders it',
+ 25000
+ );
+
+ // ---------------------------------------------------------------- section 1
+ console.log('\n=== 1. the row: handshake reply and live change ===');
+ await h.eventually(
+ () => rowsOn(B.page),
+ (m) => !!m[A.id],
+ '1.1 B holds a look-state row for A after the handshake (rides getmodulestate)',
+ 15000
+ );
+ const rowA = (await rowsOn(B.page))[A.id];
+ h.check(
+ rowA.mode === 'shaded' && rowA.camera === null && rowA.overrides.post === true,
+ '1.2 ...carrying A\'s view mode, no camera, post on: ' + JSON.stringify(rowA)
+ );
+ h.check(!!(await rowsOn(A.page))[B.id], '1.3 ...and A holds one for B (both directions)');
+
+ await A.page.evaluate((cam) => window.__stores.cameraPreview.startCameraPreview(cam), camUuid);
+ await h.eventually(
+ () => chainOf(A.page),
+ (c) => c === 'fill-blue',
+ '1.4 premise: A looks through the camera and renders its replace look',
+ 8000
+ );
+ await h.eventually(
+ () => rowsOn(B.page),
+ (m) => m[A.id]?.camera === camUuid,
+ '1.5 the camera change reaches B\'s row (sent on change, no timer)',
+ 8000
+ );
+ h.check((await chainOf(B.page)) === 'fill-red', '1.6 ...and B, not watching, still renders ITS OWN chain');
+ h.check((await adoptedFrom(B.page)) === '', '1.7 ...adopted from nobody');
+
+ // ---------------------------------------------------------------- section 2
+ console.log('\n=== 2. B watches A: the chain follows A\'s state ===');
+ // the REAL opener: the peers popover's Watch button
+ const pressed = await pressWatch(B);
+ h.check(
+ pressed.ok,
+ '2.1 premise: exactly one Watch button (not the join-camera one) was there and clicked: ' +
+ JSON.stringify(pressed.buttons)
+ );
+ await h.eventually(
+ () => watching(B.page),
+ (v) => v === A.id,
+ '2.2 B is watching A (not previewing its camera)',
+ 5000
+ );
+ await h.eventually(
+ () => chainOf(B.page),
+ (c) => c === 'fill-blue',
+ '2.3 B renders A\'s camera look (the camera A looks through, replace mode)',
+ 8000
+ );
+ h.check((await adoptedFrom(B.page)) === A.id, '2.4 ...and the chain says it came from A');
+ h.check((await bannerNote(B.page)) === '', '2.5 the banner has nothing to warn about');
+
+ // a Set Look override on A (the setlook node's write) is A's runtime state, not ours
+ await A.page.evaluate((cam) => window.__stores.scenePost.setLookOverride(cam, false), camUuid);
+ await h.eventually(() => chainOf(A.page), (c) => c === '', '2.6 premise: A switched its camera look off', 5000);
+ await h.eventually(() => chainOf(B.page), (c) => c === '', '2.7 B follows A\'s Set Look override', 8000);
+ const bOwn = await B.page.evaluate(() => {
+ let over = null;
+ window.__stores.scenePost.lookOverride.subscribe((m) => (over = m))();
+ return Object.keys(over).length;
+ });
+ h.check(bOwn === 0, '2.8 ...without writing anything into B\'s own override map');
+ await A.page.evaluate((cam) => window.__stores.scenePost.clearLookOverride(cam), camUuid);
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-blue', '2.9 ...and back when A clears it', 8000);
+
+ // A's LOCAL post switch
+ await A.page.evaluate(() => window.__stores.viewportOverrides.setRenderLayer('post', false));
+ await h.eventually(() => chainOf(B.page), (c) => c === '', '2.10 B follows A\'s local "scene look off"', 8000);
+ h.check(
+ /switched off/.test(await bannerNote(B.page)),
+ '2.11 ...and the banner SAYS so ("' + (await bannerNote(B.page)) + '")'
+ );
+ const bLayer = await B.page.evaluate(() => window.__stores.viewportOverrides.renderLayer('post'));
+ h.check(bLayer === true, '2.12 ...B\'s own post switch untouched');
+ await A.page.evaluate(() => window.__stores.viewportOverrides.setRenderLayer('post', true));
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-blue', '2.13 ...and back', 8000);
+
+ // A's VIEW MODE (wireframe skips post for them, so for us while watching)
+ await A.page.evaluate(() => window.__stores.viewMode.set('wireframe'));
+ await h.eventually(() => chainOf(B.page), (c) => c === '', '2.14 B follows A\'s wireframe (no post)', 8000);
+ const bMode = await B.page.evaluate(() => {
+ let v = null;
+ window.__stores.viewMode.subscribe((x) => (v = x))();
+ return v;
+ });
+ h.check(bMode === 'shaded', '2.15 ...B\'s own view mode untouched (' + bMode + ')');
+ await A.page.evaluate(() => window.__stores.viewMode.set('shaded'));
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-blue', '2.16 ...and back', 8000);
+
+ // A leaves the camera: the scene look again
+ await A.page.evaluate(() => window.__stores.cameraPreview.stopCameraPreview());
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-red', '2.17 A stops previewing: B follows to the scene look', 8000);
+
+ // ---------------------------------------------------------------- section 3
+ console.log('\n=== 3. B stops watching: its own state again ===');
+ // B's own comfort setting differs from A's โ while watching, A's wins
+ await B.page.evaluate(() => window.__stores.viewportOverrides.setRenderLayer('post', false));
+ await settle(800);
+ h.check((await chainOf(B.page)) === 'fill-red', '3.1 while watching, B\'s own post switch does not apply (A\'s state wins)');
+ // the WATCH banner's Exit โ `.spectator-exit` is also the camera-preview banner's, and
+ // that one is `.spectator-banner.preview-banner` (the same trap as `.peer-watch` above)
+ const exited = await B.page.evaluate(() => {
+ const btn = document.querySelector('.spectator-banner:not(.preview-banner) .spectator-exit');
+ if (!btn) return false;
+ btn.click();
+ return true;
+ });
+ h.check(exited, '3.2 premise: the WATCH banner\'s Exit was there and clicked');
+ await h.eventually(() => watching(B.page), (v) => !v, '3.3 B stopped watching', 5000);
+ await h.eventually(() => chainOf(B.page), (c) => c === '', '3.4 B reverts to its OWN state (post off)', 8000);
+ h.check((await adoptedFrom(B.page)) === '', '3.5 ...adopted from nobody');
+ await B.page.evaluate(() => window.__stores.viewportOverrides.setRenderLayer('post', true));
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-red', '3.6 ...and its own look is back', 8000);
+
+ // ---------------------------------------------------------------- section 4
+ console.log('\n=== 4. an absent row (an older build) falls back to our own ===');
+ await A.page.evaluate((cam) => window.__stores.cameraPreview.startCameraPreview(cam), camUuid);
+ await h.eventually(() => rowsOn(B.page), (m) => m[A.id]?.camera === camUuid, '4.1 premise: A is on the camera again', 8000);
+ const pressed2 = await pressWatch(B);
+ h.check(pressed2.ok, '4.2a premise: the Watch button was there again');
+ await h.eventually(
+ () => watching(B.page),
+ (v) => v === A.id,
+ '4.2b premise: B is watching A again',
+ 5000
+ );
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-blue', '4.2 premise: B adopts fill-blue', 8000);
+ // simulate a peer that never sent a row: drop it locally
+ await B.page.evaluate((id) => window.__stores.lookPresence.dropPeerLook(id), A.id);
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-red', '4.3 no row: B renders its OWN chain', 8000);
+ h.check((await adoptedFrom(B.page)) === '', '4.4 ...adopted from nobody');
+ h.check(
+ /your own look/.test(await bannerNote(B.page)),
+ '4.5 ...and the banner says so ("' + (await bannerNote(B.page)) + '")'
+ );
+ await A.page.evaluate(() => window.__stores.lookPresence.publishLookState(true));
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-blue', '4.6 the row returning re-adopts', 8000);
+
+ // ---------------------------------------------------------------- section 5
+ console.log('\n=== 5. A disconnects mid-watch: nothing stranded ===');
+ await A.page.evaluate(() => {
+ let p = null;
+ window.__stores.peers.subscribe((v) => (p = v))();
+ p.leaveSession();
+ });
+ await h.eventually(() => rowsOn(B.page), (m) => !m[A.id], '5.1 B drops A\'s row on disconnect', 20000);
+ await h.eventually(() => chainOf(B.page), (c) => c === 'fill-red', '5.2 ...and renders its own chain again', 8000);
+ h.check((await adoptedFrom(B.page)) === '', '5.3 ...adopted from nobody (no stranded state)');
+ h.check(h.pageErrors(B).length === 0, '5.4 no page errors on B (' + h.pageErrors(B).length + ')');
+
+ await h.finish(browser);
+});