feat(paint-slots): persist scene materials + collections via autosave

The editor autosave/load is the single source of truth for what travels
with a scene. Include document-level state (collections, materials) so it
survives reload in every embedder (community cloud save + standalone
localStorage):

- SceneGraph carries optional collections + materials.
- useAutoSave triggers on collections/materials reference changes (not just
  nodes) and writes them into the saved graph + the unload flush.
- applySceneGraphToEditor restores them via setScene's extras arg on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-15 10:43:09 -04:00
co-authored by Claude Fable 5
parent b3d840a39b
commit 2ac9f6762d
2 changed files with 30 additions and 7 deletions
+21 -5
View File
@@ -61,6 +61,12 @@ export function useAutoSave({
useEffect(() => {
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
let lastNodeCount = Object.keys(useScene.getState().nodes).length
// Collections + scene materials are document-level state that persists with
// the graph but lives outside `nodes`. Track them by reference (zustand
// hands out a new object on every mutation) so a material edit or a
// collection change still triggers a save.
let lastCollectionsRef = useScene.getState().collections
let lastMaterialsRef = useScene.getState().materials
async function executeSave() {
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
@@ -69,8 +75,8 @@ export function useAutoSave({
return
}
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
// Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted.
@@ -118,19 +124,29 @@ export function useAutoSave({
const unsubscribe = useScene.subscribe((state) => {
if (isLoadingSceneRef.current) {
lastNodesSnapshot = JSON.stringify(state.nodes)
lastCollectionsRef = state.collections
lastMaterialsRef = state.materials
return
}
if (isVersionPreviewModeRef.current) {
setSaveStatus('paused')
lastNodesSnapshot = JSON.stringify(state.nodes)
lastCollectionsRef = state.collections
lastMaterialsRef = state.materials
return
}
const currentNodesSnapshot = JSON.stringify(state.nodes)
if (currentNodesSnapshot === lastNodesSnapshot) return
const changed =
currentNodesSnapshot !== lastNodesSnapshot ||
state.collections !== lastCollectionsRef ||
state.materials !== lastMaterialsRef
if (!changed) return
lastNodesSnapshot = currentNodesSnapshot
lastCollectionsRef = state.collections
lastMaterialsRef = state.materials
hasDirtyChangesRef.current = true
onDirtyRef.current?.()
setSaveStatus('pending')
@@ -156,8 +172,8 @@ export function useAutoSave({
function flushOnExit() {
if (!hasDirtyChangesRef.current) return
hasDirtyChangesRef.current = false
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
if (onSaveRef.current) {
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
} else {
+9 -2
View File
@@ -11,6 +11,10 @@ import useEditor, {
export type SceneGraph = {
nodes: Record<string, unknown>
rootNodeIds: string[]
// Document-level scene state that travels with the graph. Optional so older
// payloads (and callers that only build nodes) stay valid.
collections?: Record<string, unknown>
materials?: Record<string, unknown>
}
type PersistedSelectionPath = {
@@ -374,8 +378,11 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
if (hasUsableSceneGraph(sceneGraph)) {
const { nodes, rootNodeIds } = sceneGraph
useScene.getState().setScene(nodes as any, rootNodeIds as any)
const { nodes, rootNodeIds, collections, materials } = sceneGraph
useScene.getState().setScene(nodes as any, rootNodeIds as any, {
collections: collections as any,
materials: materials as any,
})
} else {
useScene.getState().clearScene()
}