v1.13 feature lane: looks-materials - #214
Conversation
Watching a peer already adopts their CAMERA; it now adopts the state that decides
what that camera LOOKS like, which is the only way a camera-scoped or locally
overridden look is observable from outside. Presence, never data.
- `lookPresence.js` (a LEAF, the `campreview` shape): a per-peer `lookstate` row
{camera, mode, overrides:{post,shaders}, look} published on CHANGE (signature
gated, never on a timer), replied to `getmodulestate` beside
sendCameraPreviewState, and dropped at both finalizeDisconnect sites. ADDITIVE:
an older build never sends one, its row stays ABSENT, and the watcher falls back
to its own state — which is exactly what it did before this existed.
- Outline.svelte resolves the chain from the WATCHED peer's row while
`specatorMode` names them: the camera they look through and its own look, their
view mode, their local post switch and their Set Look overrides.
`resolvedDoc(key, overrides?)` takes the override map as an argument so the
watcher never writes anything of theirs into its own stores — leaving the watch
reverts by construction. `__postDebug().adoptedFrom` names whose state it is.
- The watch banner SAYS when adoption cannot take effect (they shared no row, or
they have the scene look switched off) — the P1 lesson that a viewpoint-scoped
feature must speak on its own surface, since silence there is indistinguishable
from a dead wire.
- Suite `watch-look`, 45 checks on two peers: the handshake row both ways, live
changes (camera, Set Look, the local switch, wireframe), watch adopts, stop
reverts, an absent row falls back, and A disconnecting mid-watch strands nothing.
Counterfactuals, each proven by breaking the code and watching it go red:
- drop the `adopted` branch in Outline's chain effect -> 10 red (2.3/2.7/2.9/2.10/
2.13/2.14/2.16/3.1/4.2/4.6): B renders its own chain while watching.
- remove `dropPeerLook` from the two disconnect sites -> 5.1/5.2/5.3 red: B stays
stranded on a departed peer's look.
- remove the module-level send-on-change subscribes -> 1.5 red (the camera change
never reaches B's row) and §2 follows it down.
Also fixed on the way, both found by those runs:
- a duplicated, mis-indented `dropPeerLook` in leaveSession's loop (idempotent, so
harmless, but it read as a mistake).
- the suite selected `.peer-watch`, which is ALSO worn by the join-a-peer's-camera
button rendered beside Watch whenever that peer is previewing — the exact fixture
this suite builds. B joined A's camera instead of watching it, so every reading
was "fill-blue": the right answer for the wrong reason. It selects by exclusion
now and asserts the button count. The same trap sits on `.spectator-exit`, which
the camera-preview banner shares with the watch banner.
Gates: watch-look 45/45 · duplicate-parity 28/0 · scene-post-effects 41/1 and
shader-editor 64/2 (both pre-existing, reproduced at the base commit) ·
svelte-check 361 errors / 47 warnings with the message set identical to base ·
`npm run build` green with the dev server down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
Layer 1 of the look 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 one without writing a module.
A post graph compiles to a fragment 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.
- `shaderCatalog`: a third stage, `post` (absent `stages` still means every stage,
so the arithmetic and channel nodes serve both domains). New **Post** group, all
post-only: Scene colour, Scene sample, Scene depth (linear + raw), Scene normal,
Resolution, Bayer pattern, Edge detect, Ambient occlusion — plus `postOutput`,
the domain's own terminal, and `outputNodeFor(domain)`. `normal` is now declared
fragment+vertex: a screen pixel has no surface of its own, and the post domain
reads the normal BUFFER instead.
- `shaderCompile`: the evaluator core extracted into `createCompiler(graph,
outputType)` and shared, with a new `compilePostGraphToIR` walking the Post
output's colour/alpha taps. `STAGE_EQUIVALENT` generalises the vertex-stage
default translation to post (`vUv` -> the screen `uv`; a surface normal has NO
equivalent, so a socket defaulting to one is refused BY NAME rather than reading
a varying that does not exist in an EffectPass). `tpDepthAt` is emitted once
whenever any node wants depth, so Edge detect and the AO node work in a graph
that has no Scene depth node in it.
- `postGraphs.js`: the bridge — a post effect KIND `graph` whose `params.graph`
names a `post:`-keyed document (the prefix SH1 reserved). `scenePost` never
learns what a shader graph is and this module never touches the composer.
- `postGraphPresets.js` (pure data): posterise, ordered dither, depth+normal edge
detect, graph AO. They PROVE THE SEAM rather than being hardcoded — each is an
ordinary graph you can take apart, and between them they touch every input the
domain has. Every number in them is a Float NODE: the arithmetic nodes take
operands from sockets and have no params, so an authored `{b: 0.5}` would be
silently ignored and the socket's 0.0 used.
- `scenePost`: two OPTIONAL registry members, both absent on every built-in so
their signatures stay byte-identical — `signature(params)` for a kind whose
output depends on state its params only POINT at, and `tick` for a live uniform.
A post graph's signature is its compiled FRAGMENT, so a value scrub writes the
uniform and only a STRUCTURAL edit rebuilds the chain.
- `Outline`: ONE NormalPass, built only while something in the stack reads normals
(a second scene render is not an ambient cost), and the per-frame `tick` that
feeds the shared shader clock without a rebuild.
- UI: a `graph` group in the Post-processing add menu offering the presets and the
scene's existing graphs (its bare leaf would have added a row pointing at
nothing), a graph picker + Edit on the row, and a Surface|Post switch in the
shader editor — same cards, same palette filtered by domain, with the post half
getting a scope picker because a post graph belongs to no object.
Counterfactuals, each proven by breaking the code:
- drop the `signature` fold in postStackSignature -> 5.4 red: a structural edit
leaves the stale pass compiled into the chain.
- remove the NormalPass block in rebuildStack -> 4.2 and 7.3 red: edge detect
renders with an empty normal buffer.
- remove POST_EQUIVALENT from STAGE_EQUIVALENT -> 1.8 red: a shared node's `vUv`
default is emitted into a post shader, where it compiles and reads nothing.
Two suites carried an assertion this invalidates, updated here: the post library
is 14 kinds now (`graph:graph`), and `postOutput` joins `surface` as a terminal
that emits nothing.
Gates: shader-post-domain 48/48 NEW · the 19-suite shader/post battery at or above
base (camera-looks 43 · scene-post 93 · scene-post-ui 44 · shader-graph 67 ·
shader-sync 20 · shader-persist 19 · shader-editor 64/2 and scene-post-effects
42/1, both pre-existing · watch-look 45) · svelte-check 361/47, message set
identical to base · `npm run build` green with the dev server down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
…ms measured
Most of layer 2 shipped with SH6b — `graphKeyFor` already resolves own graph ->
scene default -> the object's real material, `defaultTargetsFor('scene')` already
drives every mesh without one, and each object already keeps its own base colour
because the compile clones per object. What was missing was the LOCAL half, and
proof for two things the plan said were free.
- `viewportOverrides.shaders` finally renders something. It was DECLARED in B
ahead of this phase precisely so layer 2 would add a renderer and not a new
concept, and until now nothing read it — the Inspector even filtered the
checkbox out of the Overrides list because it would have done nothing.
`applyShaderLayer` in shaderGraph swaps every shader-driven object to its own
material and back; the checkbox is in the list now, with a hint that says what
it does.
- OFF IS A SWAP, NEVER A DETACH: the documents, the compiled materials and the
base materials all stay, so switching back costs no compile and a peer sees
nothing at all. A compile that lands WHILE it is off is remembered and not
installed, which is not hypothetical — a peer editing the scene graph
recompiles on my machine through that same path.
- Deliberately not `scene.overrideMaterial`: that replaces EVERY material in the
scene, and this layer is only the ones a graph drives. Which is also why
wireframe and the UV checker suppress layers 2 and 3 for free — they own that
slot — and the plan asked for it to be asserted rather than assumed.
Suite `scene-default-material` (33 checks, two peers, pixels): the resolution
order and own-before-scene as two DIFFERENT material instances; one graph over
three objects each keeping its own colour; wireframe and the UV checker taking
overrideMaterial while the graphs stay attached underneath; the local override
including a recompile while it is off; a late joiner inheriting the scene default
through the scene key; and a scene using none of it carrying no documents.
Counterfactuals:
- remove the viewportOverrides subscribe -> 4.2/4.3 red: the switch does nothing.
- remove the install guard in applyMaterial -> 4.6 red: a recompile while the
layer is off puts the material back on.
A trap worth recording, because the first version of that second counterfactual
PASSED against the bug: the injected material is a CLONE of the base, so both
read `MeshStandardMaterial` and a type check cannot tell "the layer is off" from
"the layer just installed something". The metric is material IDENTITY now
(`isBase`), which is what makes the guard provable.
Gates: scene-default-material 33/33 NEW · shader-graph 67 · shader-scene-default
16 · shader-persist 19 · shader-inspector 24 · shader-sync 20 · shader-post-domain
48 · shader-window 40 · shader-editor 64/2 (pre-existing) · svelte-check 361/47
with the message set identical to base · `npm run build` green, server down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
… decisions The four questions the plan says are only answerable once the three layers meet. ONE STORY. Configure Scene read as unrelated sections — "Post-processing" for layer 1, nothing at all for layers 2 and 3 — which is how "must my peers switch this on?" became a separate question three times over. There is one **Scene look** section now: what the look IS (three layers, all scene data, only the right to switch one off is local), then the stack, then the materials layer as a summary plus the way in, because its editing surface is a dock tab and belongs there. The label moved and the DEEP-LINK NAME did not. `Section` takes `aliases` now, so a rename lists what the section used to be called instead of hunting every menu, component and suite that wrote the old name down — and silently missing one. The 21-G1 rule, generalised into the component that enforces it. THE COST LINE, EXTENDED. L3's "Effects: N, passes: M" was the only place a look's cost was visible. The materials layer speaks in the same voice now: "A scene default and 2 objects with their own — driving 9 objects, 3 programs." PROGRAMS rather than objects is the honest number — `customProgramCacheKey` hashes the injected source, so N objects on one graph compile one program (measured 22 -> 23 for 24 objects when SH6b's compile-once optimisation was declined on evidence). THE SAVE-PATH AUDIT, done once and written where the carriers are (shaderGraph.js). All three layers are a KEYED DOCUMENT plus a runtime product, and the rule is the same each time: save the document, never the product. Wire, autosave, sessions and undo each carry all three; the products are carried by nobody and rebuilt on the other side. The conclusion worth recording is a NEGATIVE one: `parkShaderMaterials` exists only because layers 2 and 3 attach their product to the scene TREE, and a post Effect lives in the composer, which no serializer walks — so P4 needed no fourth park, and a reader looking for one now finds the paragraph saying why. THE CAPABILITY GATE, DECIDED: they stay separate, reason in viewMode.js beside `postSupported`. Three properties of the measured ANGLE/D3D11 failure do not transfer to a material — blast radius (a broken pass takes the whole viewport, a broken material keeps its last good one and reports), where they run (post is skipped in VR, materials are the only layer that works there, so one gate would switch off the half that works), and who compiles (a material goes through three's own program path, so gating it is gating three). Materials get a CHOICE (`viewportOverrides.shaders`, P5); post keeps its REFUSAL. Counterfactual: remove `aliases` from Section's deep-link match -> scene-post-ui 1.3 red (gap -3090px: the old name lands nowhere). Gates: scene-post-ui 50/50 (44 base + 6 new) · scene-post 93 · panel-deeplinks 23 · shader-inspector 24 · camera-looks 43 · watch-look 45 · shader-post-domain 48 · scene-default-material 33 · scene-post-effects 42/1 and shader-editor 64/2, both pre-existing · svelte-check 361/47 message set identical to base · build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
…der lane proved The user's ask from 2026-08-17 — "by default copy, add into app settings an option to share" — held since then because sharing is one line LOCALLY and everything hard about it is replication: every material change is broadcast PER OBJECT, so two objects sharing an instance would diverge the moment a peer applied one. The plan said to wait for the shader lane and reuse whatever identity it established. THE IDENTITY IS `userData.materialId` — the carrier `userData.physics`, `userData.origin`, `userData.camera` and `__uuid` already ride, which is the four-carrier problem (wire, autosave, sessions, undo) solved once rather than designed twice. `materialSharing.js` is a leaf holding it and three rules: 1. THE ID IS THE TRUTH; THE INSTANCE IS AN OPTIMISATION. Objects sharing an id should share one THREE.Material so a local edit is instant on both, but every carrier splits instances somewhere — GLTF rebuilds one per mesh, a peer receives objects one message at a time, undo re-parses a subtree. So nothing depends on the instance: `reconcileSharedMaterials` re-unifies by id whenever the scene changes (the shaderGraph reconcile shape). 2. THE SENDER FANS. Rather than mint a material-addressed message — a new type, a new applier, a capability-gate entry and a story for every older peer — the sender repeats the per-object messages a receiver ALREADY understands, once per sharer. One choke point (`materialsHandler.broadcast`) covers colour, params, maps, the slot array and the type switch; the wire is byte-unchanged. 3. COPY REMAINS THE DEFAULT (`shareDuplicatedMaterials`, LOCAL, off). A duplicate is a working copy of everything that belongs to the object — D1's DCC rule — and Blender's linked duplicate is its own command, not its default. Geometry is always copied: two questions, and only one of them has a setting. The one op that REPLACES a material rather than writing into it — the type switch — relinks the sharers locally, or the next reconcile would put the old material back on the object that just changed. UI: the Settings toggle under the other Duplicate options, and a shared-material notice in the Inspector's Material section with **Unlink** beside it (the `shader-driven` notice's shape, one concern over), because an edit that quietly changes another object needs to say so where the edit happens. Suite `material-sharing` (42 checks, three peers): copy still the default and PROVEN by editing, sharing on, unlink, the reconcile, the id in a save, two peers both ways, a first share made while connected, a type switch, and a late joiner. Counterfactuals — and the first two are the reason this suite is bigger than it looks, because BOTH passed against the first version of it: - remove the fan -> 6d.2 red. It took a receiver whose instances are still SPLIT to isolate: with them already unified, one message reaches both objects for free, so the check was passing for the wrong reason. §6d stops that peer's reconcile and separates its materials by hand, which is the real window (just after objects arrive, just after a duplicate, and always for an older peer). - remove the applier's link -> 6b.3 red. Likewise: the id rides the full-state sync, so a peer that connected AFTER the sharing needs no applier at all. The case that needs one is a FIRST share made while connected, where the id is minted on a source whose userData never gets re-sent — §6b creates a fresh object for exactly that. - make the reconcile a no-op -> 4.3 red. Gates: material-sharing 42/42 NEW · duplicate-parity 28 · material-types 11 · uv-materials 25 · mesh-edit-materials 34 · shader-inspector 24 · object-sync 12 · animation-autokey-material 11, all at base · svelte-check 361/47 with the message set identical to base · `npm run build` green with the dev server down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
…rials The lane forked at 4d7dce2 (1.11.0); release/next has since taken roadmaps 25-27 and shipped as 1.12.0. Three textual conflicts, each resolved to BOTH sides' intent, plus three of the batch's invariants applied to this lane's new code: - **`Outline.svelte`'s post chain** is where P2 and 26-D met on the same call. The authored documents and the view mode now come from the WATCHED peer when watching (`adopted`), and the quality governor still applies on top of that: the governor is this machine giving up post to hold its frames, and watching somebody must not undo it. So `mode` degrades `shaded-ao` → `shaded` on the ADOPTED mode, and `localEnabled` is the adopted switch AND `!reduced.postOff`. - **`materialsHandler.js`** — D2's fan (the material-TYPE swap is the one op that REPLACES the instance, so the sharers must be handed the new one) now ends in 26-B's `pokeScene()` instead of the direct `objectsGroup.update`. Both imports kept. - **App.svelte's three debug tails** are the union: the lane's lookPresence / postGraphs / postGraphPresets / materialSharing beside the batch's seven. All three lists count 208, same order. - **`materialSharing.js` moves to `safeStorage`** (27-H): the share pref was a bare `localStorage` pair with its own try/catch, which `scripts/check-storage.cjs` — a gate CI runs — was red on. safeStorage keeps a failed write in memory for the session, and the `typeof localStorage` guard it carried is exactly the one a SecurityError walks through. - **`materialSharing.js` ×2 and `shaderGraph.js` ×1 call `pokeScene()`** instead of `objectsGroup.update((value) => value)`. Release/next has exactly ONE of those left, in `flushScenePoke` itself; every other call site in the tree is the coalesced poke. - `postGraphs`/`materialSharing` id minting keeps `Date.now()` on purpose: an id built from a timestamp is never compared as a time, and 25-E's contract is about numbers another machine compares. - `lookstate` gets a shape in 27-A's `wireValidate` table — `overrides` and `look` are read as objects the moment the row lands. Gates on the merged tree: - `node scripts/check-ratchet.cjs` → **341 errors / 47 warnings**, exactly the floor. - `npm run build` exit 0 with the dev server stopped; `npx vitest run` **133/133**. - e2e, one batch on 5178 under `/tmp/tp-e2e.lock`: **591 PASS** — watch-look 45 · shader-post-domain 48 · scene-default-material 33 · material-sharing 42 · scene-post-ui 50 · scene-post 93 · shader-compile 85 · shader-graph 67 · scene-post-effects 42 · **perf-governor 39** · **scene-poke 32** · **wire-hardening 15**. The last three are the batch's suites for the invariants above. - `scene-post-effects` §4.5 ("assigning a LUT PUSHES its bytes to the mesh") fails. **A/B'd and PRE-EXISTING**: the same check fails on a pristine `origin/main` @3f71477 worktree (41 pass there, 42 here — this lane legitimately grew the suite by one when the post library became 14 kinds). The lane had already reproduced it at its own base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
|
Merged the current Three textual conflicts, each resolved to both sides:
Three of the batch's invariants applied to this lane's own new code (none of these conflicted — the gates and a grep found them):
Suites: watch-look 45 · shader-post-domain 48 · scene-default-material 33 · material-sharing 42 · scene-post-ui 50 · scene-post 93 · shader-compile 85 · shader-graph 67 · scene-post-effects 42 · perf-governor 39 · scene-poke 32 · wire-hardening 15.
🤖 Generated with Claude Code |
One of the four v1.13 feature lanes, complete with a done handover at
~/.code/handovers/done/STATUS-looks-materials.md, which carries the full suite tables, deviations, owed-on-device checks and integration notes.Forked from
4d7dce2(1.11.0), so it does NOT contain the 1.12.0 hardening batch. Read the conflict state before merging —release/nexthas since taken roadmaps 25, 26 and 27.Raised so nothing is stranded; the v1.13 integration itself is still owed by the orchestrator, which has to stack the four lanes, write the CHANGELOG and CLAUDE.md entries and the docs notes.
🤖 Generated with Claude Code