Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixes

- Preserve custom scene materials across save, load, clone, fork, and live sync. Materials were dropped at every persistence boundary, so a scene reopened with default surfaces. Collections were dropped on MCP import for the same reason.

## 1.0.0-beta.1 (2026-07-30)

The first Pascal Editor 1.0 beta. Relative to
Expand Down
16 changes: 2 additions & 14 deletions apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Image from 'next/image'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature'
import { cn } from '@/lib/utils'
import { BuildTab } from './build-tab'
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
Expand Down Expand Up @@ -71,26 +72,13 @@ interface SceneLoaderProps {
meta: SceneMeta
}

type SceneGraphWithCollections = SceneGraph & {
collections?: Record<string, unknown>
}

interface LiveSceneEvent {
eventId: number
sceneId: string
version: number
kind: string
createdAt: string
graph: SceneGraphWithCollections
}

function sceneGraphSignature(graph: SceneGraphWithCollections): string {
return JSON.stringify({
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections,
installedPlugins: graph.installedPlugins,
})
graph: PersistedSceneGraph
}

/**
Expand Down
49 changes: 49 additions & 0 deletions apps/editor/lib/graph-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,52 @@ test('treats an unnamespaced unknown type as a foreign node', () => {
.success,
).toBe(false)
})

const MATERIAL_ID = 'mat_a1b2c3d4e5f6g7h8'
const material = (overrides: Record<string, unknown> = {}) => ({
id: MATERIAL_ID,
name: 'Oak',
material: { preset: 'wood', ...overrides },
})

test('keeps materials in the parsed output', () => {
const graph = { ...buildGraph({}), materials: { [MATERIAL_ID]: material() } }
const res = apiGraphSchema.safeParse(graph)

expect(res.success).toBe(true)
expect(res.data?.materials).toEqual(graph.materials)
})

// A material's texture is a URL the editor loads, so it is held to the same
// `AssetUrl` allowlist as every other URL-shaped field in the graph.
test('rejects a material texture URL outside the allowlist', () => {
for (const url of ['ftp://host/a.png', 'javascript:alert(1)']) {
const graph = {
...buildGraph({}),
materials: { [MATERIAL_ID]: material({ texture: { url } }) },
}
expect(apiGraphSchema.safeParse(graph).success).toBe(false)
}
})

test('accepts a material texture URL inside the allowlist', () => {
const graph = {
...buildGraph({}),
materials: {
[MATERIAL_ID]: material({ texture: { url: 'https://cdn.example.com/oak.png' } }),
},
}

expect(apiGraphSchema.safeParse(graph).success).toBe(true)
})

// The routes persist this schema's output, so validation must not double as
// normalization: a save has to store the palette it was handed.
test('does not rewrite materials it accepts', () => {
const sparse = { id: MATERIAL_ID, name: 'Oak', material: { properties: { color: '#886644' } } }
const graph = { ...buildGraph({}), materials: { [MATERIAL_ID]: sparse } }
const res = apiGraphSchema.safeParse(graph)

expect(res.success).toBe(true)
expect(res.data?.materials?.[MATERIAL_ID]).toEqual(sparse)
})
23 changes: 22 additions & 1 deletion apps/editor/lib/graph-schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AnyNode, AssetUrl, BaseNode } from '@pascal-app/core/schema'
import { AnyNode, AssetUrl, BaseNode, SceneMaterial } from '@pascal-app/core/schema'
import { z } from 'zod'

/**
Expand Down Expand Up @@ -100,6 +100,15 @@ export const apiGraphSchema = z
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.unknown().optional(),
// `unknown` here, validated in `superRefine` below — the same split the
// nodes get, and for the same reason: the routes persist this schema's
// *output*, so a validating shape would also rewrite what gets stored.
// `SceneMaterial` injects `MaterialProperties` defaults and drops unknown
// keys, which would make every save silently normalize the caller's
// palette. Materials still have to be checked, because they carry texture
// URLs that `MaterialSchema` routes through `AssetUrl` — this schema is
// where that allowlist is enforced.
materials: z.record(z.string(), z.unknown()).optional(),
installedPlugins: z.array(z.string().min(1)).optional(),
})
.superRefine((value, ctx) => {
Expand All @@ -113,6 +122,18 @@ export const apiGraphSchema = z
}
}

for (const [materialId, material] of Object.entries(value.materials ?? {})) {
const res = SceneMaterial.safeParse(material)
if (res.success) continue
for (const issue of res.error.issues) {
ctx.addIssue({
code: 'custom',
path: ['materials', materialId, ...issue.path],
message: issue.message,
})
}
}

// Ids of foreign nodes in this graph. Builtin container schemas name the
// child kinds they accept (`BuildingNode.children`, `RoofNode.children`),
// so a container holding a plugin child fails against `AnyNode` even
Expand Down
41 changes: 41 additions & 0 deletions apps/editor/lib/scene-signature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from 'bun:test'
import { type PersistedSceneGraph, sceneGraphSignature } from './scene-signature'

const NODE_ID = 'level_a1b2c3d4e5f6g7h8'
const MATERIAL_ID = 'mat_a1b2c3d4e5f6g7h8'

const graph = (overrides: Partial<PersistedSceneGraph> = {}) =>
({
nodes: { [NODE_ID]: { object: 'node', id: NODE_ID, type: 'level', level: 0 } },
rootNodeIds: [NODE_ID],
...overrides,
}) as PersistedSceneGraph

// The echo check compares a raw SSE payload against the store after
// `setScene` ran, and `setScene` always writes these three keys. A payload
// that omits them — which is exactly what MCP live sync sends — must still
// match, or every remote update looks like a local edit and gets saved back.
test('an omitted field signs the same as its applied default', () => {
expect(sceneGraphSignature(graph())).toBe(
sceneGraphSignature(graph({ collections: {}, materials: {}, installedPlugins: [] })),
)
})

// Conversely, every field the save body carries has to be signed. An unsigned
// field makes a local edit that touches only that field read as an echo, and
// the save is skipped — the change is silently lost.
test('changing any signed field changes the signature', () => {
const base = sceneGraphSignature(graph())

expect(
sceneGraphSignature(
graph({ materials: { [MATERIAL_ID]: { id: MATERIAL_ID, name: 'Oak', material: {} } } }),
),
).not.toBe(base)
expect(
sceneGraphSignature(graph({ collections: { col_1: { id: 'col_1', nodeIds: [] } } })),
).not.toBe(base)
expect(sceneGraphSignature(graph({ installedPlugins: ['@pascal-app/plugin-trees'] }))).not.toBe(
base,
)
})
27 changes: 27 additions & 0 deletions apps/editor/lib/scene-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SceneGraph } from '@pascal-app/editor'

export type PersistedSceneGraph = SceneGraph & {
collections?: Record<string, unknown>
}

/**
* Identity of a graph for echo detection, compared across a boundary that
* normalizes: one side is a raw SSE payload, the other is the editor's state
* after `applySceneGraphToEditor` ran. `setScene` always writes `collections`,
* `materials` and `installedPlugins`, so a payload that omits them (MCP live
* sync emits exactly that) has to serialize the same as the store that
* defaulted them, or the echo reads as a local edit and gets saved back.
*
* Every field the PUT body carries has to appear here. A field that is
* persisted but unsigned makes a local change to *only* that field
* indistinguishable from an echo, and the save is skipped.
*/
export function sceneGraphSignature(graph: PersistedSceneGraph): string {
return JSON.stringify({
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections ?? {},
materials: graph.materials ?? {},
installedPlugins: graph.installedPlugins ?? [],
})
}
40 changes: 40 additions & 0 deletions packages/core/src/utils/clone-scene-graph.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import type { CollectionId } from '../schema/collections'
import type { SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
cloneLevelSubtree,
Expand Down Expand Up @@ -46,10 +47,49 @@ function makeSceneGraph(): SceneGraph {
nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[],
},
},
materials: {
['mat_1' as SceneMaterialId]: {
id: 'mat_1',
name: 'Oak',
material: { preset: 'wood' },
},
},
installedPlugins: ['pascal:trees'],
}
}

describe('scene material palette', () => {
// Nodes reference materials through `slots` values shaped `scene:mat_…`.
// Those are opaque strings to the node remapping, so the ids they point at
// have to survive a clone unchanged or every reference dangles.
test('cloneSceneGraph carries materials over with their ids intact', () => {
const source = makeSceneGraph()
const cloned = cloneSceneGraph(source)

expect(cloned.materials).toEqual(source.materials)
})

test('cloneSceneGraph deep-copies materials', () => {
const source = makeSceneGraph()
const cloned = cloneSceneGraph(source)
const material = cloned.materials?.['mat_1' as SceneMaterialId]
expect(material).toBeDefined()
if (!material) return

material.name = 'Mutated'
expect(source.materials?.['mat_1' as SceneMaterialId]?.name).toBe('Oak')
})

// A palette entry is authored content in its own right. Stripping the scan
// node that happened to use it must not take the material with it.
test('forkSceneGraph keeps materials when stripping scans', () => {
const source = makeSceneGraph()
const forked = forkSceneGraph(source)

expect(forked.materials).toEqual(source.materials)
})
})

describe('forkSceneGraph', () => {
test('strips scan and guide nodes by default', () => {
const forked = forkSceneGraph(makeSceneGraph())
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/utils/clone-scene-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'

export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
collections?: Record<CollectionId, Collection>
materials?: Record<SceneMaterialId, SceneMaterial>
installedPlugins?: string[]
}

Expand All @@ -32,7 +34,7 @@ function extractIdPrefix(id: string): string {
* - Multi-scene in-memory scenarios
*/
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph

// Build ID mapping: old ID -> new ID
const idMap = new Map<string, string>()
Expand Down Expand Up @@ -164,6 +166,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
nodes: clonedNodes,
rootNodeIds: clonedRootNodeIds,
...(clonedCollections && { collections: clonedCollections }),
// Material ids are deliberately *not* remapped. Nodes point at these
// through `slots` values shaped `scene:mat_…` — opaque strings that the
// node remapping above copies verbatim, since `idMap` only covers node
// ids. Minting fresh material ids here would orphan every one of those
// refs and the clone would render with default materials.
...(materials && { materials: structuredClone(materials) }),
...(installedPlugins && { installedPlugins: [...installedPlugins] }),
}
}
Expand Down Expand Up @@ -304,7 +312,7 @@ export function forkSceneGraph(
return cloneSceneGraph(sceneGraph)
}

const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph

// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
const excludedNodeIds = new Set<string>()
Expand Down Expand Up @@ -366,6 +374,11 @@ export function forkSceneGraph(
nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds,
...(filteredCollections && { collections: filteredCollections }),
// Kept whole rather than filtered to the surviving nodes: a palette entry
// is authored content in its own right, and dropping the scan node that
// happened to be its only user would silently delete a material the fork's
// owner can still pick from the palette.
...(materials && { materials }),
...(installedPlugins && { installedPlugins }),
})
}
23 changes: 23 additions & 0 deletions packages/mcp/src/bridge/scene-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,29 @@ describe('SceneBridge', () => {
expect(bridge.exportJSON().installedPlugins).toEqual(['pascal:trees'])
})

// `setScene` resets `collections` and `materials` to `{}` unless they are
// in its `extra` bag, so anything applied after it is silently discarded.
// These two round trips are what catch a regression back to that.
test('loadJSON round-trips the material palette', () => {
const materials = {
mat_1: { id: 'mat_1', name: 'Oak', material: { preset: 'wood' } },
}
bridge.loadJSON({ ...bridge.exportJSON(), materials } as never)

expect(bridge.exportJSON().materials).toEqual(materials)
})

test('loadJSON round-trips collections', () => {
const snap = bridge.exportJSON()
const nodeId = Object.keys(snap.nodes)[0]!
const collections = {
collection_1: { id: 'collection_1', name: 'Refs', nodeIds: [nodeId] },
}
bridge.loadJSON({ ...snap, collections } as never)

expect(bridge.exportJSON().collections).toEqual(collections)
})

test('legacy graphs do not become explicitly uninstalled on export', () => {
const snap = bridge.exportJSON()
const { installedPlugins: _installedPlugins, ...legacy } = snap
Expand Down
Loading
Loading