Skip to content
Open
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
45 changes: 45 additions & 0 deletions packages/core/src/validation/validate-build-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
45 changes: 45 additions & 0 deletions packages/core/src/validation/validate-build-json.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -24,6 +25,8 @@ export type ParsedBuildJson = {
nodes: Record<string, unknown>
rootNodeIds: string[]
installedPlugins?: string[]
/** Scene materials referenced by node `slots` (`scene:<id>`). */
materials?: Record<string, SceneMaterial>
}

export type SchemaIssue = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -160,6 +164,46 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
})
}

// Scene materials ride along with the graph: nodes reference them by
// `scene:<id>` 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<string, SceneMaterial> | undefined
if (isPlainObject(materialsRaw)) {
let skipped = 0
const kept: Record<string, SceneMaterial> = {}
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',
Expand Down Expand Up @@ -373,6 +417,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
nodes,
rootNodeIds,
...(installedPlugins ? { installedPlugins } : {}),
...(materials ? { materials } : {}),
}
: null,
stats,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
clearSceneHistory,
emitter,
useScene,
type ParsedBuildJson,
validateBuildJson,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
Expand Down Expand Up @@ -287,16 +288,16 @@ export function SettingsPanel({
e.target.value = ''
}

const handleConfirmImport = (parsed: {
nodes: Record<string, unknown>
rootNodeIds: string[]
installedPlugins?: string[]
}) => {
const handleConfirmImport = (parsed: ParsedBuildJson) => {
const currentScene = useScene.getState()
setScene(
parsed.nodes as Parameters<typeof setScene>[0],
parsed.rootNodeIds as Parameters<typeof setScene>[1],
{
// Without this, every `scene:<id>` 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,
Expand Down