diff --git a/packages/core/src/validation/validate-build-json.test.ts b/packages/core/src/validation/validate-build-json.test.ts index 0c7836fc9d..3f0488dce5 100644 --- a/packages/core/src/validation/validate-build-json.test.ts +++ b/packages/core/src/validation/validate-build-json.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index f5bdbb8a20..0f9390c87f 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 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' @@ -27,6 +28,8 @@ export type ParsedBuildJson = { installedPlugins?: string[] /** Scene materials referenced by node `slots` (`scene:`). */ materials?: Record + /** Item collections; member nodes carry the matching `collectionIds`. */ + collections?: Record } export type SchemaIssue = { @@ -52,6 +55,18 @@ function isPlainObject(value: unknown): value is Record { 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): number { if (points.length < 3) return 0 let area = 0 @@ -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({ @@ -206,6 +222,35 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { }) } + let collections: Record | undefined + if (isPlainObject(collectionsRaw)) { + const skippedIds: string[] = [] + const kept: Record = {} + 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', @@ -420,6 +465,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { rootNodeIds, ...(installedPlugins ? { installedPlugins } : {}), ...(materials ? { materials } : {}), + ...(collections ? { collections } : {}), } : 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 3baefe5a6f..0fff65ad93 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 @@ -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) @@ -236,7 +237,7 @@ export function SettingsPanel({ // Materials ride along: nodes reference them by `scene:` 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) @@ -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,