From ffc497c67a7d2209bb106f309024d3e71e852dab Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 20 May 2026 10:31:02 -0400 Subject: [PATCH] editor: gate Load Build behind a verification dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/index.ts | 9 + packages/core/src/schema/nodes/level.ts | 2 + packages/core/src/schema/nodes/site.ts | 8 +- packages/core/src/store/use-scene.ts | 26 +- .../src/validation/validate-build-json.ts | 292 ++++++++++++++++++ .../sidebar/panels/settings-panel/index.tsx | 57 +++- .../settings-panel/load-build-dialog.tsx | 259 ++++++++++++++++ .../ui/sidebar/panels/site-panel/index.tsx | 5 +- packages/editor/src/store/use-editor.tsx | 2 +- .../tools/photo-to-scene/photo-to-scene.ts | 2 +- packages/nodes/src/site/renderer.tsx | 7 +- 11 files changed, 642 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/validation/validate-build-json.ts create mode 100644 packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d254f643..c3cad9ae8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 0b24763a0..d0bd589de 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -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([]), diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index d52410e9d..6cb33d681 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -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) `, ) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 39f28c00e..9301dad82 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -355,6 +355,30 @@ function migrateNodes(nodes: Record): Record { 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 } @@ -575,7 +599,7 @@ const useScene: UseSceneStore = create()( }) const site = SiteNode.parse({ - children: [building], + children: [building.id], }) // Define all nodes flat diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts new file mode 100644 index 000000000..23873c3ed --- /dev/null +++ b/packages/core/src/validation/validate-build-json.ts @@ -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> + unknownTypes: Record + floorAreaM2: number +} + +export type ParsedBuildJson = { + nodes: Record + 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( + AnyNode.options.map((o) => o.shape.type.parse(undefined) as string), +) + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function polygonAreaM2(points: ReadonlyArray): 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 { + 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 + 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, + } +} 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 be5bddc59..968f8d3f7 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 @@ -1,4 +1,4 @@ -import { emitter, useScene } from '@pascal-app/core' +import { emitter, useScene, validateBuildJson } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' import { Camera, Download, Save, Trash2, Upload } from 'lucide-react' @@ -21,6 +21,7 @@ import { Switch } from './../../../../../components/ui/primitives/switch' import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor' import { AudioSettingsDialog } from './audio-settings-dialog' import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog' +import { LoadBuildDialog, type PendingImport } from './load-build-dialog' type SceneNode = Record & { id?: unknown @@ -185,6 +186,7 @@ export function SettingsPanel({ const showGrid = useViewer((state) => state.showGrid) const setPhase = useEditor((state) => state.setPhase) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) + const [pendingImport, setPendingImport] = useState(null) const sceneGraphValue = useMemo( () => buildSceneGraphValue(nodes as Record, rootNodeIds), [nodes, rootNodeIds], @@ -221,16 +223,37 @@ export function SettingsPanel({ const reader = new FileReader() reader.onload = (event) => { + const text = event.target?.result as string + let parsed: unknown try { - const data = JSON.parse(event.target?.result as string) - if (data.nodes && data.rootNodeIds) { - setScene(data.nodes, data.rootNodeIds) - resetSelection() - setPhase('site') - } - } catch (err) { - console.error('Failed to load build:', err) + parsed = JSON.parse(text) + } catch { + setPendingImport({ + fileName: file.name, + fileSizeBytes: file.size, + result: { + ok: false, + parsed: null, + stats: { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 }, + errors: [ + { + severity: 'error', + code: 'invalid_json', + message: 'File could not be parsed as JSON.', + }, + ], + warnings: [], + schemaIssues: [], + schemaIssueCount: 0, + }, + }) + return } + setPendingImport({ + fileName: file.name, + fileSizeBytes: file.size, + result: validateBuildJson(parsed), + }) } reader.readAsText(file) @@ -238,6 +261,16 @@ export function SettingsPanel({ e.target.value = '' } + const handleConfirmImport = (parsed: { nodes: Record; rootNodeIds: string[] }) => { + setScene( + parsed.nodes as Parameters[0], + parsed.rootNodeIds as Parameters[1], + ) + resetSelection() + setPhase('site') + setPendingImport(null) + } + const handleResetToDefault = () => { clearScene() resetSelection() @@ -380,6 +413,12 @@ export function SettingsPanel({ ref={fileInputRef} type="file" /> + + setPendingImport(null)} + onConfirm={handleConfirmImport} + pending={pendingImport} + /> {/* Audio Section */} diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx new file mode 100644 index 000000000..28424179e --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx @@ -0,0 +1,259 @@ +import type { BuildStats, SchemaIssue, ValidateBuildJsonResult } from '@pascal-app/core' +import { + AlertTriangle, + AppWindow, + Box, + Building2, + CheckCircle2, + DoorOpen, + Layers, + MapPin, + Scan, + Square, + XCircle, +} from 'lucide-react' +import { useState } from 'react' +import { Button } from '../../../../../components/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../../../../components/ui/primitives/dialog' + +export type PendingImport = { + fileName: string + fileSizeBytes: number + result: ValidateBuildJsonResult +} + +type Props = { + pending: PendingImport | null + onCancel: () => void + onConfirm: (parsed: NonNullable) => void +} + +type StatRow = { + icon: typeof Building2 + label: string + count: number +} + +function statsRows(stats: BuildStats): StatRow[] { + return ( + [ + { icon: MapPin, label: 'Sites', count: stats.byType.site ?? 0 }, + { icon: Building2, label: 'Buildings', count: stats.byType.building ?? 0 }, + { icon: Layers, label: 'Levels', count: stats.byType.level ?? 0 }, + { icon: Square, label: 'Walls', count: stats.byType.wall ?? 0 }, + { icon: DoorOpen, label: 'Doors', count: stats.byType.door ?? 0 }, + { icon: AppWindow, label: 'Windows', count: stats.byType.window ?? 0 }, + { icon: Box, label: 'Items', count: stats.byType.item ?? 0 }, + { icon: Square, label: 'Slabs', count: stats.byType.slab ?? 0 }, + { icon: Square, label: 'Ceilings', count: stats.byType.ceiling ?? 0 }, + { icon: Square, label: 'Zones', count: stats.byType.zone ?? 0 }, + { icon: Scan, label: 'Scans', count: stats.byType.scan ?? 0 }, + ] satisfies StatRow[] + ).filter((row) => row.count > 0) +} + +function groupSchemaIssuesByType( + issues: SchemaIssue[], +): { type: string; issues: SchemaIssue[] }[] { + const groups = new Map() + for (const issue of issues) { + const existing = groups.get(issue.nodeType) + if (existing) { + existing.push(issue) + } else { + groups.set(issue.nodeType, [issue]) + } + } + return Array.from(groups, ([type, list]) => ({ type, issues: list })) +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function formatFloorArea(m2: number): string { + if (m2 === 0) return '—' + if (m2 < 10) return `${m2.toFixed(2)} m²` + return `${m2.toFixed(1)} m²` +} + +export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) { + const [showAllWarnings, setShowAllWarnings] = useState(false) + const [showSchemaIssues, setShowSchemaIssues] = useState(false) + + if (!pending) return null + + const { fileName, fileSizeBytes, result } = pending + const { ok, parsed, stats, errors, warnings, schemaIssues, schemaIssueCount } = result + const rows = statsRows(stats) + const visibleWarnings = showAllWarnings ? warnings : warnings.slice(0, 3) + const hiddenWarningCount = warnings.length - visibleWarnings.length + const schemaIssuesByType = groupSchemaIssuesByType(schemaIssues) + + return ( + { + if (!open) onCancel() + }} + > + + + + {ok ? ( + + ) : ( + + )} + {ok ? 'Ready to import' : 'Cannot import this file'} + + + {fileName} · {formatFileSize(fileSizeBytes)} · {stats.total} node + {stats.total === 1 ? '' : 's'} + + + +
+ {errors.length > 0 && ( +
+
+ + {errors.length} error{errors.length === 1 ? '' : 's'} +
+
    + {errors.map((e) => ( +
  • · {e.message}
  • + ))} +
+
+ )} + + {stats.total > 0 && ( +
+
+ Structure +
+ {rows.length > 0 ? ( +
+ {rows.map((row, i) => { + const Icon = row.icon + return ( +
+
+ + {row.label} +
+ {row.count} +
+ ) + })} + {stats.floorAreaM2 > 0 && ( +
+ Floor area + + {formatFloorArea(stats.floorAreaM2)} + +
+ )} +
+ ) : ( +
+ The file contains no recognised nodes. +
+ )} +
+ )} + + {warnings.length > 0 && ( +
+
+ + {warnings.length} warning{warnings.length === 1 ? '' : 's'} +
+
    + {visibleWarnings.map((w, i) => ( +
  • · {w.message}
  • + ))} +
+ {hiddenWarningCount > 0 && ( + + )} +
+ )} + + {schemaIssues.length > 0 && ( +
+ + {showSchemaIssues && ( +
+ {schemaIssuesByType.map(({ type, issues }) => ( +
+
+ {type} · {issues.length} +
+
    + {issues.map((issue) => ( +
  • + {issue.nodeId} + {issue.path && · {issue.path}} + — {issue.message} +
  • + ))} +
+
+ ))} +
+ )} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 0d5ea42b5..5c291d323 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -1541,10 +1541,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel useShallow((s) => { if (!siteNode) return [] return siteNode.children - .map((child) => { - const id = typeof child === 'string' ? child : child.id - return s.nodes[id] as BuildingNode | undefined - }) + .map((childId) => s.nodes[childId as AnyNodeId] as BuildingNode | undefined) .filter((node): node is BuildingNode => node?.type === 'building') }), ) diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 057cbf665..5600381dd 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -439,7 +439,7 @@ export function selectDefaultBuildingAndLevel() { const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null if (siteNode?.type === 'site') { const firstBuilding = siteNode.children - .map((child) => (typeof child === 'string' ? scene.nodes[child] : child)) + .map((childId) => scene.nodes[childId as AnyNodeId]) .find((node) => node?.type === 'building') if (firstBuilding) { buildingId = firstBuilding.id as BuildingNode['id'] diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index 16f775ce2..ad183be59 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -229,7 +229,7 @@ function buildSceneGraphFromVision( // Build the skeleton: site → building → level. const building = BuildingNode.parse({}) const level = LevelNode.parse({ level: 0 }) - const site = SiteNode.parse({ children: [building] }) + const site = SiteNode.parse({ children: [building.id] }) // Link parent ids so downstream traversal works. const siteId = site.id as AnyNodeId diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 92a96d69e..89ff65cd1 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -116,11 +116,8 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return ( {/* Render children (buildings and items) */} - {node.children.map((child) => ( - + {node.children.map((childId) => ( + ))} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}