Skip to content

v1.13 feature lane: looks-materials - #214

Merged
AlexZ005 merged 6 commits into
release/nextfrom
feat/looks-materials
Sep 18, 2026
Merged

AlexZ005 merged 6 commits into
release/nextfrom
feat/looks-materials

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

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/next has 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

AlexZ005 and others added 6 commits September 16, 2026 08:32
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
@AlexZ005

Copy link
Copy Markdown
Collaborator Author

Merged the current release/next (1.12.0 + the whole hardening batch) in, as a merge commit so the P2→D2 history stays readable.

Three textual conflicts, each resolved to both sides:

  1. Outline.svelte — P2's adopted look and 26-D's quality governor landed on the same effectivePostStack call. The authored documents and the view mode come from the watched peer while watching; the governor still applies on top, because it is this machine giving up post to hold its frames and watching somebody must not undo that. So the AO degrade reads the adopted mode, and localEnabled is the adopted switch AND !reduced.postOff.
  2. materialsHandler.js — D2's fan (the material-type swap is the one op that replaces the instance, so sharers must be handed the new one) now ends in 26-B's pokeScene() rather than the direct store update.
  3. App.svelte's three debug tails — the union of this lane's four new modules and the batch's seven; all three lists count 208 in the same order.

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):

  • materialSharing.js's share pref moves to safeStorage. scripts/check-storage.cjs, which CI runs, was red on it.
  • materialSharing.js ×2 and shaderGraph.js ×1 now call pokeScene(); release/next has exactly one direct objectsGroup.update left, inside flushScenePoke itself.
  • lookstate gets a shape in wireValidate. The id minting in postGraphs/materialSharing deliberately keeps Date.now(): an id built from a timestamp is never compared as a time, which is what 25-E's contract is about.
gate result
scripts/check-ratchet.cjs 341 / 47 — exactly the floor
npm run build exit 0
npx vitest run 133/133
e2e, one batch on 5178 under the lock 591 PASS

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.

scene-post-effects §4.5 (the LUT byte push) fails — A/B'd as pre-existing: the same check fails on a pristine origin/main @3f71477 worktree. The lane had already reproduced it at its own base; this re-confirms it on the new one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw

@AlexZ005
AlexZ005 merged commit d1fab11 into release/next Sep 18, 2026
4 checks passed
@AlexZ005
AlexZ005 deleted the feat/looks-materials branch September 18, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant