diff --git a/packages/core/src/validation/validate-build-json.test.ts b/packages/core/src/validation/validate-build-json.test.ts index 64e3d0972e..e7d20ebb25 100644 --- a/packages/core/src/validation/validate-build-json.test.ts +++ b/packages/core/src/validation/validate-build-json.test.ts @@ -126,3 +126,48 @@ describe('validateBuildJson with registered plugin kinds', () => { expect(result.schemaIssues[0]?.nodeType).toBe('trees:tree') }) }) + +describe('scene materials', () => { + const minimalGraph = () => ({ + nodes: { + building_1: { id: 'building_1', type: 'building', children: ['level_1'] }, + level_1: { id: 'level_1', type: 'level', children: [] }, + }, + rootNodeIds: ['building_1'], + }) + + test('carries valid materials through to parsed', () => { + const result = validateBuildJson({ + ...minimalGraph(), + materials: { + mat_a: { + id: 'mat_a', + name: 'Measured cabinet', + material: { properties: { color: '#595c5a' } }, + }, + }, + }) + expect(result.ok).toBe(true) + expect(result.parsed?.materials?.mat_a?.name).toBe('Measured cabinet') + }) + + test('skips invalid material entries with a warning, keeps the rest', () => { + const result = validateBuildJson({ + ...minimalGraph(), + materials: { + mat_ok: { id: 'mat_ok', name: 'Fine', material: {} }, + mat_bad: { name: 42 }, + }, + }) + expect(result.ok).toBe(true) + expect(Object.keys(result.parsed?.materials ?? {})).toEqual(['mat_ok']) + expect(result.warnings.some((w) => w.code === 'invalid_materials')).toBe(true) + }) + + test('warns when materials is not an object', () => { + const result = validateBuildJson({ ...minimalGraph(), materials: 'nope' }) + expect(result.ok).toBe(true) + expect(result.parsed?.materials).toBeUndefined() + expect(result.warnings.some((w) => w.code === 'invalid_materials')).toBe(true) + }) +}) diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index 1257a43ef2..19615386c8 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,4 +1,5 @@ import { nodeRegistry } from '../registry' +import { SceneMaterial } from '../schema/scene-material' import { AnyNode, type AnyNodeType } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' @@ -24,6 +25,8 @@ export type ParsedBuildJson = { nodes: Record rootNodeIds: string[] installedPlugins?: string[] + /** Scene materials referenced by node `slots` (`scene:`). */ + materials?: Record } export type SchemaIssue = { @@ -111,6 +114,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const nodesRaw = input.nodes const rootNodeIdsRaw = input.rootNodeIds const installedPluginsRaw = input.installedPlugins + const materialsRaw = input.materials if (!isPlainObject(nodesRaw)) { errors.push({ @@ -160,6 +164,46 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { }) } + // Scene materials ride along with the graph: nodes reference them by + // `scene:` slot refs, so dropping the table here silently strips + // every custom finish from the imported scene. Invalid entries are + // skipped one by one — a bad material must not take the import down. + // + // DELIBERATE: `safeParse().data` NORMALIZES — defaults are injected + // and unknown keys dropped. That is the opposite of the API boundary + // (`apiGraphSchema` preserves unknown fields on purpose), and it is + // chosen here because import feeds the live scene store, which only + // understands schema-shaped materials; a hand-edited file with a + // half-formed material should land as something the renderer can + // draw, not round-trip garbage. + let materials: Record | undefined + if (isPlainObject(materialsRaw)) { + let skipped = 0 + const kept: Record = {} + for (const [id, value] of Object.entries(materialsRaw)) { + const result = SceneMaterial.safeParse(value) + if (result.success) { + kept[id] = result.data + } else { + skipped += 1 + } + } + if (Object.keys(kept).length > 0) materials = kept + if (skipped > 0) { + warnings.push({ + severity: 'warning', + code: 'invalid_materials', + message: `Ignored ${skipped} invalid scene material${skipped === 1 ? '' : 's'}.`, + }) + } + } else if (materialsRaw !== undefined) { + warnings.push({ + severity: 'warning', + code: 'invalid_materials', + message: 'Ignored invalid "materials" — expected an object of id → material.', + }) + } + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { warnings.push({ severity: 'warning', @@ -373,6 +417,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { nodes, rootNodeIds, ...(installedPlugins ? { installedPlugins } : {}), + ...(materials ? { materials } : {}), } : null, stats, diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index fecc75978c..4b466466fa 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -2,6 +2,7 @@ import { clearSceneHistory, emitter, useScene, + type ParsedBuildJson, validateBuildJson, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -287,16 +288,16 @@ export function SettingsPanel({ e.target.value = '' } - const handleConfirmImport = (parsed: { - nodes: Record - rootNodeIds: string[] - installedPlugins?: string[] - }) => { + const handleConfirmImport = (parsed: ParsedBuildJson) => { const currentScene = useScene.getState() setScene( parsed.nodes as Parameters[0], parsed.rootNodeIds as Parameters[1], { + // Without this, every `scene:` slot ref in the imported file + // pointed at a material that no longer existed — custom finishes + // silently reverted to defaults on import. + materials: parsed.materials, installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins, hasExplicitPluginInstallState: parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,