From 3f9e9666cb1c57bc48ee0185d88dfdb3678d99d6 Mon Sep 17 00:00:00 2001 From: ALX Date: Mon, 21 Sep 2026 18:24:51 -0400 Subject: [PATCH] fix(core): fill slab/ceiling hole fields on scene load Slabs and ceilings written by third-party exporters (scanning apps, IFC hand-offs) can omit `holes`, `holeMetadata` and `autoFromWalls`: the schema declares defaults for them. The opening sync and the public viewer read those fields on the raw node, so a scene whose slab has no `holes` throws "Cannot read properties of undefined (reading 'map')" and never renders, while the editor (which fills defaults) opens it fine. Fill the three fields in setScene, the way doors and windows are already normalized on load. Only the hole fields are touched: a full zod parse would also default `thickness`, which the vertical-model migration must still see as absent on legacy solids. Repro: a valid scene (69/69 nodes pass AnyNode.safeParse) exported by A3 Atlas Scanner; black viewer before, renders after adding `holes: []`. --- .../use-scene-surface-holes-defaults.test.ts | 147 ++++++++++++++++++ packages/core/src/store/use-scene.ts | 20 +++ 2 files changed, 167 insertions(+) create mode 100644 packages/core/src/store/use-scene-surface-holes-defaults.test.ts diff --git a/packages/core/src/store/use-scene-surface-holes-defaults.test.ts b/packages/core/src/store/use-scene-surface-holes-defaults.test.ts new file mode 100644 index 0000000000..d320cedecc --- /dev/null +++ b/packages/core/src/store/use-scene-surface-holes-defaults.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +// Third-party exporters (scanning apps, IFC hand-offs) can legitimately write a +// slab or ceiling without `holes` / `holeMetadata` / `autoFromWalls`: the schema +// declares defaults for them. The opening sync and the public viewer read those +// fields on the raw node, so a missing `holes` threw +// "Cannot read properties of undefined (reading 'map')" and the scene never +// rendered. Doors and windows are already zod-parsed on load; this covers +// slabs and ceilings the same way. +describe('scene load applies surface hole defaults', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + const base = { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + level: 0, + children: ['slab_bare', 'ceiling_bare'], + }, + } + + test('a slab and a ceiling written without holes load with empty hole arrays', () => { + useScene.getState().setScene( + { + ...base, + slab_bare: { + object: 'node', + id: 'slab_bare', + type: 'slab', + parentId: 'level_test', + visible: true, + metadata: {}, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + elevation: 0.05, + }, + ceiling_bare: { + object: 'node', + id: 'ceiling_bare', + type: 'ceiling', + parentId: 'level_test', + visible: true, + metadata: {}, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }, + } as unknown as Record, + ['site_test' as AnyNode['id']], + ) + + const slab = useScene.getState().nodes['slab_bare' as AnyNode['id']] + expect(slab?.type).toBe('slab') + if (slab?.type !== 'slab') return + expect(slab.holes).toEqual([]) + expect(slab.holeMetadata).toEqual([]) + expect(slab.autoFromWalls).toBe(false) + expect(slab.polygon).toEqual([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + const ceiling = useScene.getState().nodes['ceiling_bare' as AnyNode['id']] + expect(ceiling?.type).toBe('ceiling') + if (ceiling?.type !== 'ceiling') return + expect(ceiling.holes).toEqual([]) + expect(ceiling.holeMetadata).toEqual([]) + }) + + test('existing holes and metadata are kept as written', () => { + useScene.getState().setScene( + { + ...base, + level_test: { ...base.level_test, children: ['slab_holed'] }, + slab_holed: { + object: 'node', + id: 'slab_holed', + type: 'slab', + parentId: 'level_test', + visible: true, + metadata: {}, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + holeMetadata: [{ source: 'manual' }], + }, + } as unknown as Record, + ['site_test' as AnyNode['id']], + ) + const slab = useScene.getState().nodes['slab_holed' as AnyNode['id']] + if (slab?.type !== 'slab') throw new Error('slab expected') + expect(slab.holes).toHaveLength(1) + expect(slab.holeMetadata).toEqual([{ source: 'manual' }]) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 5e45cf7402..18e30a8f1a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -178,6 +178,22 @@ function normalizeWindowNode(node: Record) { return parsed.success ? { ...node, ...parsed.data } : null } +// Slabs and ceilings written by third-party exporters (scanning apps, IFC +// hand-offs) can legitimately omit `holes` / `holeMetadata` / `autoFromWalls`: +// the schema gives them defaults. The opening sync and the public viewer read +// those fields on the raw node, so a missing `holes` throws +// "Cannot read properties of undefined (reading 'map')" and the scene never +// renders. Only the hole fields are filled here — a full zod parse would also +// default `thickness`, which the vertical-model migration below must still see +// as absent on legacy solids. +function normalizeSurfaceHoleFields(node: Record) { + const holes = Array.isArray(node.holes) ? node.holes : [] + const holeMetadata = Array.isArray(node.holeMetadata) ? node.holeMetadata : [] + const autoFromWalls = + node.type === 'slab' && node.autoFromWalls === undefined ? { autoFromWalls: false } : {} + return { ...node, holes, holeMetadata, ...autoFromWalls } +} + function normalizeShelfNode(node: Record) { const sanitized = { ...node, @@ -826,6 +842,10 @@ function migrateNodes(nodes: Record): { } } + if (node.type === 'slab' || node.type === 'ceiling') { + patchedNodes[id] = normalizeSurfaceHoleFields(patchedNodes[id] ?? node) as AnyNode + } + // Dormers originally rendered one inline parametric window. Promote that // default to a real hosted WindowNode so additional windows can use the // regular window tool and inspector without changing the old appearance.