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
57 changes: 57 additions & 0 deletions packages/core/src/validation/validate-build-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,60 @@ describe('scene materials', () => {
expect(result.warnings.some((w) => w.code === 'invalid_materials')).toBe(true)
})
})

describe('collections', () => {
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 collections through to parsed', () => {
const result = validateBuildJson({
...minimalGraph(),
collections: {
collection_a: {
id: 'collection_a',
name: 'Kitchen set',
color: '#ff0000',
nodeIds: ['item_1', 'item_2'],
},
},
})
expect(result.ok).toBe(true)
expect(result.parsed?.collections?.collection_a?.name).toBe('Kitchen set')
expect(result.parsed?.collections?.collection_a?.nodeIds).toEqual(['item_1', 'item_2'])
})

test('skips invalid collection entries with a warning, keeps the rest', () => {
const result = validateBuildJson({
...minimalGraph(),
collections: {
collection_ok: { id: 'collection_ok', name: 'Fine', nodeIds: [] },
collection_bad: { id: 'collection_bad', name: 'Broken', nodeIds: [42] },
collection_worse: 'nope',
},
})
expect(result.ok).toBe(true)
expect(Object.keys(result.parsed?.collections ?? {})).toEqual(['collection_ok'])
const warning = result.warnings.find((w) => w.code === 'invalid_collections')
expect(warning).toBeDefined()
expect(warning?.message).toContain('collection_bad')
expect(warning?.message).toContain('collection_worse')
})

test('warns when collections is not an object', () => {
const result = validateBuildJson({ ...minimalGraph(), collections: [] })
expect(result.ok).toBe(true)
expect(result.parsed?.collections).toBeUndefined()
expect(result.warnings.some((w) => w.code === 'invalid_collections')).toBe(true)
})

test('omits collections from parsed when absent', () => {
const result = validateBuildJson(minimalGraph())
expect(result.ok).toBe(true)
expect('collections' in (result.parsed ?? {})).toBe(false)
})
})
46 changes: 46 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 type { Collection } from '../schema/collections'
import { SceneMaterial } from '../schema/scene-material'
import { AnyNode, type AnyNodeType, nodeKindOf } from '../schema/types'
import { healSceneNodes } from '../utils/heal-scene-graph'
Expand Down Expand Up @@ -27,6 +28,8 @@ export type ParsedBuildJson = {
installedPlugins?: string[]
/** Scene materials referenced by node `slots` (`scene:<id>`). */
materials?: Record<string, SceneMaterial>
/** Item collections; member nodes carry the matching `collectionIds`. */
collections?: Record<string, Collection>
}

export type SchemaIssue = {
Expand All @@ -52,6 +55,18 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function isCollection(value: unknown): value is Collection {
if (!isPlainObject(value)) return false
return (
typeof value.id === 'string' &&
typeof value.name === 'string' &&
Array.isArray(value.nodeIds) &&
value.nodeIds.every((nodeId) => typeof nodeId === 'string') &&
(value.color === undefined || typeof value.color === 'string') &&
(value.controlNodeId === undefined || typeof value.controlNodeId === 'string')
)
}

function polygonAreaM2(points: ReadonlyArray<readonly [number, number]>): number {
if (points.length < 3) return 0
let area = 0
Expand Down Expand Up @@ -113,6 +128,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
const rootNodeIdsRaw = input.rootNodeIds
const installedPluginsRaw = input.installedPlugins
const materialsRaw = input.materials
const collectionsRaw = input.collections

if (!isPlainObject(nodesRaw)) {
errors.push({
Expand Down Expand Up @@ -206,6 +222,35 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
})
}

let collections: Record<string, Collection> | undefined
if (isPlainObject(collectionsRaw)) {
const skippedIds: string[] = []
const kept: Record<string, Collection> = {}
for (const [id, value] of Object.entries(collectionsRaw)) {
if (isCollection(value)) {
kept[id] = value
} else {
skippedIds.push(id)
}
}
if (Object.keys(kept).length > 0) collections = kept
if (skippedIds.length > 0) {
warnings.push({
severity: 'warning',
code: 'invalid_collections',
message: `Ignored ${skippedIds.length} invalid collection${
skippedIds.length === 1 ? '' : 's'
}: ${skippedIds.join(', ')}.`,
})
}
} else if (collectionsRaw !== undefined) {
warnings.push({
severity: 'warning',
code: 'invalid_collections',
message: 'Ignored invalid "collections" — expected an object of id → collection.',
})
}

if (strippedChildRefs > 0 || droppedWallIds.length > 0) {
warnings.push({
severity: 'warning',
Expand Down Expand Up @@ -420,6 +465,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
rootNodeIds,
...(installedPlugins ? { installedPlugins } : {}),
...(materials ? { materials } : {}),
...(collections ? { collections } : {}),
}
: null,
stats,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export function SettingsPanel({
const rootNodeIds = useScene((state) => state.rootNodeIds)
const installedPlugins = useScene((state) => state.installedPlugins)
const materials = useScene((state) => state.materials)
const collections = useScene((state) => state.collections)
const setScene = useScene((state) => state.setScene)
const clearScene = useScene((state) => state.clearScene)
const resetSelection = useViewer((state) => state.resetSelection)
Expand Down Expand Up @@ -236,7 +237,7 @@ export function SettingsPanel({
// Materials ride along: nodes reference them by `scene:<id>` slot
// refs, so a save without the table produces a file whose custom
// finishes revert to defaults on the very Load Build path below.
const sceneData = { nodes, rootNodeIds, installedPlugins, materials }
const sceneData = { nodes, rootNodeIds, installedPlugins, materials, collections }
const json = JSON.stringify(sceneData, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
Expand Down Expand Up @@ -302,6 +303,7 @@ export function SettingsPanel({
// pointed at a material that no longer existed — custom finishes
// silently reverted to defaults on import.
materials: parsed.materials,
collections: parsed.collections,
installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
hasExplicitPluginInstallState:
parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
Expand Down