Merge pull request #320 from pascalorg/feat/load-build-verification-dialog
editor: gate Load Build behind a verification dialog
This commit is contained in:
@@ -171,3 +171,12 @@ export {
|
|||||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||||
export { isObject } from './utils/types'
|
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 { ItemNode } from './item'
|
||||||
import { RoofNode } from './roof'
|
import { RoofNode } from './roof'
|
||||||
import { ScanNode } from './scan'
|
import { ScanNode } from './scan'
|
||||||
|
import { ShelfNode } from './shelf'
|
||||||
import { SlabNode } from './slab'
|
import { SlabNode } from './slab'
|
||||||
import { SpawnNode } from './spawn'
|
import { SpawnNode } from './spawn'
|
||||||
import { StairNode } from './stair'
|
import { StairNode } from './stair'
|
||||||
@@ -32,6 +33,7 @@ export const LevelNode = BaseNode.extend({
|
|||||||
ScanNode.shape.id,
|
ScanNode.shape.id,
|
||||||
GuideNode.shape.id,
|
GuideNode.shape.id,
|
||||||
SpawnNode.shape.id,
|
SpawnNode.shape.id,
|
||||||
|
ShelfNode.shape.id,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { BuildingNode } from './building'
|
|
||||||
import { ItemNode } from './item'
|
|
||||||
|
|
||||||
// 2D Polygon
|
// 2D Polygon
|
||||||
const PropertyLineData = z.object({
|
const PropertyLineData = z.object({
|
||||||
@@ -33,14 +31,12 @@ export const SiteNode = BaseNode.extend({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
// terrain: TerrainData,
|
// terrain: TerrainData,
|
||||||
children: z
|
children: z.array(z.string()).default([]),
|
||||||
.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))
|
|
||||||
.default([BuildingNode.parse({})]),
|
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Site node - used to represent a site
|
Site node - used to represent a site
|
||||||
- polygon: polygon data
|
- 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') {
|
if (node.type === 'roof') {
|
||||||
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
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>
|
return patchedNodes as Record<string, AnyNode>
|
||||||
}
|
}
|
||||||
@@ -575,7 +599,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const site = SiteNode.parse({
|
const site = SiteNode.parse({
|
||||||
children: [building],
|
children: [building.id],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Define all nodes flat
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { TreeView, VisualJson } from '@visual-json/react'
|
import { TreeView, VisualJson } from '@visual-json/react'
|
||||||
import { Camera, Download, Save, Trash2, Upload } from 'lucide-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 useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
|
||||||
import { AudioSettingsDialog } from './audio-settings-dialog'
|
import { AudioSettingsDialog } from './audio-settings-dialog'
|
||||||
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
|
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
|
||||||
|
import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
|
||||||
|
|
||||||
type SceneNode = Record<string, unknown> & {
|
type SceneNode = Record<string, unknown> & {
|
||||||
id?: unknown
|
id?: unknown
|
||||||
@@ -185,6 +186,7 @@ export function SettingsPanel({
|
|||||||
const showGrid = useViewer((state) => state.showGrid)
|
const showGrid = useViewer((state) => state.showGrid)
|
||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
||||||
|
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
|
||||||
const sceneGraphValue = useMemo(
|
const sceneGraphValue = useMemo(
|
||||||
() => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
|
() => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
|
||||||
[nodes, rootNodeIds],
|
[nodes, rootNodeIds],
|
||||||
@@ -221,16 +223,37 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string
|
||||||
|
let parsed: unknown
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.target?.result as string)
|
parsed = JSON.parse(text)
|
||||||
if (data.nodes && data.rootNodeIds) {
|
} catch {
|
||||||
setScene(data.nodes, data.rootNodeIds)
|
setPendingImport({
|
||||||
resetSelection()
|
fileName: file.name,
|
||||||
setPhase('site')
|
fileSizeBytes: file.size,
|
||||||
}
|
result: {
|
||||||
} catch (err) {
|
ok: false,
|
||||||
console.error('Failed to load build:', err)
|
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)
|
reader.readAsText(file)
|
||||||
|
|
||||||
@@ -238,6 +261,16 @@ export function SettingsPanel({
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConfirmImport = (parsed: { nodes: Record<string, unknown>; rootNodeIds: string[] }) => {
|
||||||
|
setScene(
|
||||||
|
parsed.nodes as Parameters<typeof setScene>[0],
|
||||||
|
parsed.rootNodeIds as Parameters<typeof setScene>[1],
|
||||||
|
)
|
||||||
|
resetSelection()
|
||||||
|
setPhase('site')
|
||||||
|
setPendingImport(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleResetToDefault = () => {
|
const handleResetToDefault = () => {
|
||||||
clearScene()
|
clearScene()
|
||||||
resetSelection()
|
resetSelection()
|
||||||
@@ -380,6 +413,12 @@ export function SettingsPanel({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LoadBuildDialog
|
||||||
|
onCancel={() => setPendingImport(null)}
|
||||||
|
onConfirm={handleConfirmImport}
|
||||||
|
pending={pendingImport}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio Section */}
|
{/* Audio Section */}
|
||||||
|
|||||||
+259
@@ -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<ValidateBuildJsonResult['parsed']>) => 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<string, SchemaIssue[]>()
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onCancel()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle2 className="size-5 text-emerald-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-5 text-red-600" />
|
||||||
|
)}
|
||||||
|
{ok ? 'Ready to import' : 'Cannot import this file'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{fileName} · {formatFileSize(fileSizeBytes)} · {stats.total} node
|
||||||
|
{stats.total === 1 ? '' : 's'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] space-y-4 overflow-y-auto py-2">
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-md border border-red-200 bg-red-50 p-3">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-red-800 text-sm">
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
{errors.length} error{errors.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 text-red-700 text-xs">
|
||||||
|
{errors.map((e) => (
|
||||||
|
<li key={`${e.code}-${e.nodeId ?? ''}`}>· {e.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats.total > 0 && (
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<div className="border-b px-3 py-2 font-medium text-muted-foreground text-xs uppercase">
|
||||||
|
Structure
|
||||||
|
</div>
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const Icon = row.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-3 py-2 ${
|
||||||
|
i === rows.length - 1 ? '' : 'border-b'
|
||||||
|
}`}
|
||||||
|
key={row.label}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{row.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm">{row.count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{stats.floorAreaM2 > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2">
|
||||||
|
<span className="text-muted-foreground text-sm">Floor area</span>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{formatFloorArea(stats.floorAreaM2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-4 text-center text-muted-foreground text-xs">
|
||||||
|
The file contains no recognised nodes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-amber-800 text-sm">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
{warnings.length} warning{warnings.length === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 text-amber-700 text-xs">
|
||||||
|
{visibleWarnings.map((w, i) => (
|
||||||
|
<li key={`${w.code}-${w.nodeId ?? ''}-${i}`}>· {w.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{hiddenWarningCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="text-amber-800 text-xs underline hover:no-underline"
|
||||||
|
onClick={() => setShowAllWarnings(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Show {hiddenWarningCount} more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schemaIssues.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-md border bg-card p-3">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-between text-left"
|
||||||
|
onClick={() => setShowSchemaIssues((v) => !v)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-muted-foreground text-xs uppercase">
|
||||||
|
Schema details ({schemaIssueCount} node
|
||||||
|
{schemaIssueCount === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{showSchemaIssues ? 'Hide' : 'Show'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showSchemaIssues && (
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
{schemaIssuesByType.map(({ type, issues }) => (
|
||||||
|
<div className="space-y-1" key={type}>
|
||||||
|
<div className="font-medium text-xs">
|
||||||
|
{type} · {issues.length}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-muted-foreground text-xs">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<li className="font-mono" key={issue.nodeId}>
|
||||||
|
<span className="text-foreground">{issue.nodeId}</span>
|
||||||
|
{issue.path && <span> · {issue.path}</span>}
|
||||||
|
<span> — {issue.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onCancel} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!ok || !parsed}
|
||||||
|
onClick={() => {
|
||||||
|
if (parsed) onConfirm(parsed)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Replace current scene
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1541,10 +1541,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
|||||||
useShallow((s) => {
|
useShallow((s) => {
|
||||||
if (!siteNode) return []
|
if (!siteNode) return []
|
||||||
return siteNode.children
|
return siteNode.children
|
||||||
.map((child) => {
|
.map((childId) => s.nodes[childId as AnyNodeId] as BuildingNode | undefined)
|
||||||
const id = typeof child === 'string' ? child : child.id
|
|
||||||
return s.nodes[id] as BuildingNode | undefined
|
|
||||||
})
|
|
||||||
.filter((node): node is BuildingNode => node?.type === 'building')
|
.filter((node): node is BuildingNode => node?.type === 'building')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ export function selectDefaultBuildingAndLevel() {
|
|||||||
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
|
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
|
||||||
if (siteNode?.type === 'site') {
|
if (siteNode?.type === 'site') {
|
||||||
const firstBuilding = siteNode.children
|
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')
|
.find((node) => node?.type === 'building')
|
||||||
if (firstBuilding) {
|
if (firstBuilding) {
|
||||||
buildingId = firstBuilding.id as BuildingNode['id']
|
buildingId = firstBuilding.id as BuildingNode['id']
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function buildSceneGraphFromVision(
|
|||||||
// Build the skeleton: site → building → level.
|
// Build the skeleton: site → building → level.
|
||||||
const building = BuildingNode.parse({})
|
const building = BuildingNode.parse({})
|
||||||
const level = LevelNode.parse({ level: 0 })
|
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.
|
// Link parent ids so downstream traversal works.
|
||||||
const siteId = site.id as AnyNodeId
|
const siteId = site.id as AnyNodeId
|
||||||
|
|||||||
@@ -116,11 +116,8 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
return (
|
return (
|
||||||
<group ref={ref} {...handlers}>
|
<group ref={ref} {...handlers}>
|
||||||
{/* Render children (buildings and items) */}
|
{/* Render children (buildings and items) */}
|
||||||
{node.children.map((child) => (
|
{node.children.map((childId) => (
|
||||||
<NodeRenderer
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
key={typeof child === 'string' ? child : child.id}
|
|
||||||
nodeId={typeof child === 'string' ? child : child.id}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||||
|
|||||||
Reference in New Issue
Block a user