fix: preserve scene materials across every persistence boundary - #597
Merged
Conversation
A custom scene material survived nothing. `materials` was absent from the
`SceneGraph` type, so every layer that rebuilds a graph field-by-field
silently omitted it: `cloneSceneGraph`/`forkSceneGraph`, the MCP bridge's
`exportJSON`/`loadJSON`, `exportSceneGraph` (which `save_scene`,
`publishLiveSceneSnapshot` and variant generation all persist through), the
SQLite read schema, and the editor's API graph schema. Reopen a scene and it
came back with default surfaces.
Nodes point at materials through `slots` values shaped `scene:mat_…`. Those
are opaque strings to the clone remapping, so material ids are carried over
unchanged — minting new ones would orphan every reference.
Two things fell out of fixing the round trip:
`loadJSON` dropped collections too, for a different reason: it applied plugin
state in a second call after `setScene`, and `setScene` resets `collections`
and `materials` to `{}` whenever they aren't in its `extra` bag. Everything
now goes in one call, which also fixes dirty-tracking for plugin-owned nodes
— `setScene` marks nodes dirty at the end, and `markDirty` skips nodes whose
plugin isn't installed yet.
The editor's echo-suppression signature omitted `materials`, so a local edit
that touched only the palette signed identically to the last remote payload
and the save was skipped — the edit was lost. The signature now defaults the
fields `setScene` always writes, so a payload that omits them (MCP live sync
sends exactly that) still matches the store that defaulted them.
Materials are validated where they enter from the network, not where they are
read back from disk. The API schema holds them to `SceneMaterial` in
`superRefine` — they carry texture URLs, and that schema is where the
`AssetUrl` allowlist is enforced — but keeps the parsed value untransformed,
since the routes persist this schema's output and `SceneMaterial` injects
defaults and strips unknown keys. The SQLite read path stays permissive:
nothing validates on write and `parseGraph` throws, so a strict read shape
would turn one odd stored value into a permanently unloadable scene.
Co-authored-by: ShiroKSH <kushidashiro@gmail.com>
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased and reworked version of #560, co-authored with @ShiroKSH, whose diagnosis and layering this follows. Closes #560.
The bug
A custom scene material survived nothing.
materialswas missing from theSceneGraphtype, so every layer that rebuilds a graph field-by-field silently omitted it. Reopen a scene and it came back with default surfaces.Probed on
mainbefore touching anything — all eight of these dropped the palette:maincloneSceneGraphcollections, installedPlugins, nodes, rootNodeIdsforkSceneGraphexportJSONloadJSONmaterials→{}loadJSONcollections→{}(see below)exportSceneGraphmaterialsabsent — this is the MCP persist pathsave→loadz.object()strips itgraph-schemaz.object()strips itNodes reference materials through
slotsvalues shapedscene:mat_…. Those are opaque strings to the clone remapping, so material ids are carried over unchanged — minting fresh ones would orphan every reference and the clone would render with defaults anyway.Two bugs found while fixing the round trip
1.
loadJSONdropped collections too, for a different reason than materials. It applied plugin state in a second call aftersetScene, andsetSceneresetscollectionsandmaterialsto{}whenever they aren't in itsextrabag — so anything applied afterwards is discarded. Everything now goes in one call.That also fixes dirty-tracking for plugin-owned nodes:
setScenemarks every node dirty at the end, andmarkDirtyreturns early for a node whose plugin isn't ininstalledPlugins. Installing plugins afterwards meant those nodes were never marked, so they never got validated.2. The echo-suppression signature omitted
materials, which lost user edits.scene-loader.tsxskips a save when the graph signature matches the last remote payload. Since the signature ignoredmaterials, a local edit that touched only the palette signed identically to that payload — so it read as an echo and the save was skipped. Verified against main's signature function: a material-only change produced a byte-identical signature.The signature now also defaults the three fields
setSceneunconditionally writes. Without that, a payload that omits them — which is exactly what MCP live sync sends — can never match the store that defaulted them, so every remote update looks like a local edit and gets saved back, bumping the version.scene-signature.tsexists so this is testable; the two tests there both fail against main's version.Where materials get validated
At the network boundary, not on read-back:
graph-schema.ts) holds materials toSceneMaterialinsuperRefine. They carry texture URLs, andMaterialSchemaroutes every one throughAssetUrl— this schema is where that allowlist is enforced, so passing materials through asunknownwould have reopened the Phase 3 risk on a field the editor loads. Confirmedftp://andjavascript:are rejected.SceneMaterialinjectsMaterialPropertiesdefaults and strips unknown keys — so a validating shape would make every save silently rewrite the caller's palette. Measured: a sparse material grew from 1 property to 6.z.record(z.string(), z.unknown())). Nothing validates on write andparseGraphthrows, so a strict read shape would turn one odd stored value into a permanently unloadable scene. There's a test that saves a material with anftp://texture and asserts it still loads — a stored row must never become unreadable. This is the one place I diverged from fix: preserve scene material document state #560, which putSceneMaterialon the read path.Verification
End-to-end probe over the real store, all eight boundaries — every one failed on
main, all pass here:Full CI gate green locally:
bun run check1603 files clean,check-types9/9,bun run test12/12 tasks,bun run build7/7.11 tests added across the five layers, each pinning a boundary that silently dropped data rather than erroring — that's the failure mode here, so a type alone wouldn't hold the line.
Credit
The diagnosis is @ShiroKSH's, and so is the call to fix it in
packages/coreandpackages/mcprather than only inapps/editor— that matters, because the hosted app forks scenes through@pascal-app/core/clone-scene-graph, so an app-only fix would have left both npm consumers and production broken. What I changed from #560: kept the echo-suppression timer instead of removing it, moved material validation from the SQLite read path to the API write path, dropped the one-lineisRemoteSceneEchowrapper, and rebased onto the 1.0-beta CHANGELOG.Note
Medium Risk
Touches scene persistence, live-sync save suppression, and API graph validation (material texture URLs); behavior is well covered by new boundary tests but mistakes could still lose data or reject saves.
Overview
Fixes custom scene materials being stripped whenever a graph crossed a persistence boundary (save/load, clone/fork, MCP export/import, SQLite round-trip, API validation).
materialsis now part of the coreSceneGraphand is deep-copied on clone/fork with stable material ids (nodescene:mat_…refs are not remapped).MCP bridge:
exportJSON/exportSceneGraphinclude materials;loadJSONpasses collections, materials, andinstalledPluginsin a singlesetSceneextrabag so they are not wiped whensetScenedefaults missing fields.Editor: Shared
sceneGraphSignatureincludesmaterials,collections, andinstalledPlugins(with defaults matchingsetScene) so live-sync echo suppression does not skip saves for palette-only changes or treat every remote update as local.API
apiGraphSchema: Accepts and validates materials viaSceneMaterial.safeParse(texture URLs /AssetUrl) without normalizing stored palette entries. SQLiteGraphSchemalistsmaterials(andinstalledPlugins) for round-trip but stays permissive on read.Reviewed by Cursor Bugbot for commit 15a80fa. Bugbot is set up for automated code reviews on this repo. Configure here.