editor: gate Load Build behind a verification dialog
Loading JSON previously called setScene blindly and crashed when the file held schema-invalid nodes (e.g. items missing `asset`). The new dialog parses the file, runs validateBuildJson in core, and surfaces structure counts (site/building/levels/walls/doors/ windows/items/slabs/ceilings/zones/scans), floor area, and a per-node Schema Details list grouped by type. Import is blocked when any hard error exists. Two schema bugs the validator surfaced are fixed here too: - SiteNode.children is now an id array like every other node (was a discriminatedUnion of full objects; three readers carried a string-or-object ternary that's now dropped). migrateNodes flattens legacy nested-object children on load. Default-scene seed and photo-to-scene MCP builder updated to pass `building.id`. - LevelNode.children now includes shelf — the editor allowed it but the schema didn't. The schema-vs-registry-as-source-of-truth discussion is captured in plans/editor-node-registry.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
42bf025cad
commit
ffc497c67a
@@ -171,3 +171,12 @@ export {
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
export {
|
||||
type BuildStats,
|
||||
type ParsedBuildJson,
|
||||
type SchemaIssue,
|
||||
type ValidateBuildJsonResult,
|
||||
type ValidationIssue,
|
||||
type ValidationSeverity,
|
||||
validateBuildJson,
|
||||
} from './validation/validate-build-json'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GuideNode } from './guide'
|
||||
import { ItemNode } from './item'
|
||||
import { RoofNode } from './roof'
|
||||
import { ScanNode } from './scan'
|
||||
import { ShelfNode } from './shelf'
|
||||
import { SlabNode } from './slab'
|
||||
import { SpawnNode } from './spawn'
|
||||
import { StairNode } from './stair'
|
||||
@@ -32,6 +33,7 @@ export const LevelNode = BaseNode.extend({
|
||||
ScanNode.shape.id,
|
||||
GuideNode.shape.id,
|
||||
SpawnNode.shape.id,
|
||||
ShelfNode.shape.id,
|
||||
]),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { BuildingNode } from './building'
|
||||
import { ItemNode } from './item'
|
||||
|
||||
// 2D Polygon
|
||||
const PropertyLineData = z.object({
|
||||
@@ -33,14 +31,12 @@ export const SiteNode = BaseNode.extend({
|
||||
],
|
||||
}),
|
||||
// terrain: TerrainData,
|
||||
children: z
|
||||
.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))
|
||||
.default([BuildingNode.parse({})]),
|
||||
children: z.array(z.string()).default([]),
|
||||
}).describe(
|
||||
dedent`
|
||||
Site node - used to represent a site
|
||||
- polygon: polygon data
|
||||
- children: array of building and item nodes
|
||||
- children: array of child node ids (buildings, items)
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -355,6 +355,30 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
if (node.type === 'roof') {
|
||||
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
// Legacy: site.children used to hold nested BuildingNode / ItemNode
|
||||
// objects (see the SiteNode schema before the children-as-ids fix).
|
||||
// Flatten any leftover nested children into ids, and absorb the
|
||||
// embedded nodes into the flat map so the rest of the loader can
|
||||
// treat the site like every other parent.
|
||||
if (node.type === 'site' && Array.isArray(node.children)) {
|
||||
let needsFlatten = false
|
||||
const flattened: string[] = []
|
||||
for (const child of node.children) {
|
||||
if (typeof child === 'string') {
|
||||
flattened.push(child)
|
||||
} else if (child && typeof child === 'object' && typeof child.id === 'string') {
|
||||
needsFlatten = true
|
||||
flattened.push(child.id)
|
||||
if (!patchedNodes[child.id]) {
|
||||
patchedNodes[child.id] = { ...child, parentId: id }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (needsFlatten) {
|
||||
patchedNodes[id] = { ...node, children: flattened }
|
||||
}
|
||||
}
|
||||
}
|
||||
return patchedNodes as Record<string, AnyNode>
|
||||
}
|
||||
@@ -575,7 +599,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
})
|
||||
|
||||
const site = SiteNode.parse({
|
||||
children: [building],
|
||||
children: [building.id],
|
||||
})
|
||||
|
||||
// Define all nodes flat
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { AnyNode, type AnyNodeType } from '../schema/types'
|
||||
|
||||
export type ValidationSeverity = 'error' | 'warning'
|
||||
|
||||
export type ValidationIssue = {
|
||||
severity: ValidationSeverity
|
||||
code: string
|
||||
message: string
|
||||
nodeId?: string
|
||||
}
|
||||
|
||||
export type BuildStats = {
|
||||
total: number
|
||||
byType: Partial<Record<AnyNodeType, number>>
|
||||
unknownTypes: Record<string, number>
|
||||
floorAreaM2: number
|
||||
}
|
||||
|
||||
export type ParsedBuildJson = {
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
}
|
||||
|
||||
export type SchemaIssue = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ValidateBuildJsonResult = {
|
||||
ok: boolean
|
||||
parsed: ParsedBuildJson | null
|
||||
stats: BuildStats
|
||||
errors: ValidationIssue[]
|
||||
warnings: ValidationIssue[]
|
||||
schemaIssues: SchemaIssue[]
|
||||
schemaIssueCount: number
|
||||
}
|
||||
|
||||
const KNOWN_TYPES = new Set<string>(
|
||||
AnyNode.options.map((o) => o.shape.type.parse(undefined) as string),
|
||||
)
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function polygonAreaM2(points: ReadonlyArray<readonly [number, number]>): number {
|
||||
if (points.length < 3) return 0
|
||||
let area = 0
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i]
|
||||
const b = points[(i + 1) % points.length]
|
||||
if (!a || !b) return 0
|
||||
area += a[0] * b[1] - b[0] * a[1]
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
function isPointArray(value: unknown): value is ReadonlyArray<readonly [number, number]> {
|
||||
if (!Array.isArray(value)) return false
|
||||
return value.every(
|
||||
(p) =>
|
||||
Array.isArray(p) && p.length === 2 && typeof p[0] === 'number' && typeof p[1] === 'number',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight validator for `{ nodes, rootNodeIds }` build JSON loaded via
|
||||
* Load Build (drag-drop, IFC converter output, hand-edited files).
|
||||
*
|
||||
* Reports issues without mutating; the scene store still owns migration
|
||||
* and orphan cleanup at import time. Hard errors mean the file is
|
||||
* structurally unusable and import should be blocked.
|
||||
*/
|
||||
export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
const errors: ValidationIssue[] = []
|
||||
const warnings: ValidationIssue[] = []
|
||||
const schemaIssues: SchemaIssue[] = []
|
||||
const stats: BuildStats = { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 }
|
||||
|
||||
if (!isPlainObject(input)) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
code: 'not_an_object',
|
||||
message: 'File is not a JSON object.',
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
parsed: null,
|
||||
stats,
|
||||
errors,
|
||||
warnings,
|
||||
schemaIssues,
|
||||
schemaIssueCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const nodesRaw = input.nodes
|
||||
const rootNodeIdsRaw = input.rootNodeIds
|
||||
|
||||
if (!isPlainObject(nodesRaw)) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
code: 'missing_nodes',
|
||||
message: 'Missing or invalid "nodes" — expected an object of id → node.',
|
||||
})
|
||||
}
|
||||
if (!Array.isArray(rootNodeIdsRaw) || !rootNodeIdsRaw.every((id) => typeof id === 'string')) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
code: 'missing_root_node_ids',
|
||||
message: 'Missing or invalid "rootNodeIds" — expected an array of node IDs.',
|
||||
})
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
parsed: null,
|
||||
stats,
|
||||
errors,
|
||||
warnings,
|
||||
schemaIssues,
|
||||
schemaIssueCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = nodesRaw as Record<string, unknown>
|
||||
const rootNodeIds = rootNodeIdsRaw as string[]
|
||||
|
||||
if (rootNodeIds.length === 0) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
code: 'empty_root_node_ids',
|
||||
message: '"rootNodeIds" is empty — no entry point into the scene.',
|
||||
})
|
||||
}
|
||||
|
||||
let validRootCount = 0
|
||||
let mismatchedKeyCount = 0
|
||||
let schemaFailureCount = 0
|
||||
|
||||
for (const [key, value] of Object.entries(nodes)) {
|
||||
if (!isPlainObject(value)) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'node_not_object',
|
||||
message: `Node "${key}" is not an object.`,
|
||||
nodeId: key,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
stats.total += 1
|
||||
|
||||
const id = typeof value.id === 'string' ? value.id : null
|
||||
const type = typeof value.type === 'string' ? value.type : null
|
||||
const parentId = typeof value.parentId === 'string' ? value.parentId : null
|
||||
|
||||
if (id && id !== key) {
|
||||
mismatchedKeyCount += 1
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'missing_type',
|
||||
message: `Node "${key}" has no "type" field.`,
|
||||
nodeId: key,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (KNOWN_TYPES.has(type)) {
|
||||
const t = type as AnyNodeType
|
||||
stats.byType[t] = (stats.byType[t] ?? 0) + 1
|
||||
|
||||
const parseResult = AnyNode.safeParse(value)
|
||||
if (!parseResult.success) {
|
||||
schemaFailureCount += 1
|
||||
const issue = parseResult.error.issues[0]
|
||||
schemaIssues.push({
|
||||
nodeId: key,
|
||||
nodeType: type,
|
||||
path: issue ? issue.path.join('.') : '',
|
||||
message: issue ? issue.message : 'schema mismatch',
|
||||
})
|
||||
}
|
||||
|
||||
if (type === 'slab') {
|
||||
const polygon = (value as { polygon?: unknown }).polygon
|
||||
if (isPointArray(polygon)) {
|
||||
let area = polygonAreaM2(polygon)
|
||||
const holes = (value as { holes?: unknown }).holes
|
||||
if (Array.isArray(holes)) {
|
||||
for (const hole of holes) {
|
||||
if (isPointArray(hole)) area -= polygonAreaM2(hole)
|
||||
}
|
||||
}
|
||||
stats.floorAreaM2 += Math.max(0, area)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stats.unknownTypes[type] = (stats.unknownTypes[type] ?? 0) + 1
|
||||
}
|
||||
|
||||
if (parentId && !(parentId in nodes)) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'orphan_parent',
|
||||
message: `Node "${key}" has parentId "${parentId}" which is not in the file (will be dropped on import).`,
|
||||
nodeId: key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatchedKeyCount > 0) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'key_id_mismatch',
|
||||
message: `${mismatchedKeyCount} node${mismatchedKeyCount === 1 ? '' : 's'} have a key that does not match their "id" field.`,
|
||||
})
|
||||
}
|
||||
|
||||
const unknownTypeNames = Object.keys(stats.unknownTypes)
|
||||
if (unknownTypeNames.length > 0) {
|
||||
const totalUnknown = unknownTypeNames.reduce((n, t) => n + stats.unknownTypes[t]!, 0)
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'unknown_types',
|
||||
message: `${totalUnknown} node${totalUnknown === 1 ? '' : 's'} use unknown type${unknownTypeNames.length === 1 ? '' : 's'}: ${unknownTypeNames.join(', ')}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (schemaFailureCount > 0) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
code: 'schema_failure',
|
||||
message: `${schemaFailureCount} node${schemaFailureCount === 1 ? '' : 's'} did not match the expected schema. See details below — these would cause the editor to crash on load.`,
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of rootNodeIds) {
|
||||
if (id in nodes) {
|
||||
validRootCount += 1
|
||||
} else {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'orphan_root',
|
||||
message: `Root node "${id}" is not in the file (will be ignored on import).`,
|
||||
nodeId: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (rootNodeIds.length > 0 && validRootCount === 0) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
code: 'no_valid_roots',
|
||||
message: 'None of the rootNodeIds point to a node in the file.',
|
||||
})
|
||||
}
|
||||
|
||||
const hasBuildingOrSite = (stats.byType.building ?? 0) > 0 || (stats.byType.site ?? 0) > 0
|
||||
if (!hasBuildingOrSite) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'no_building',
|
||||
message: 'No site or building node found.',
|
||||
})
|
||||
}
|
||||
if ((stats.byType.level ?? 0) === 0) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'no_levels',
|
||||
message: 'No level nodes found.',
|
||||
})
|
||||
}
|
||||
|
||||
const ok = errors.length === 0
|
||||
return {
|
||||
ok,
|
||||
parsed: ok ? { nodes, rootNodeIds } : null,
|
||||
stats,
|
||||
errors,
|
||||
warnings,
|
||||
schemaIssues,
|
||||
schemaIssueCount: schemaFailureCount,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user