Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle and a `children: ItemNode[]` field for item hosting. Schema-level defaults preserve the v1 wall-shelf visual so existing scenes load unchanged; the placement tool spreads `shelfDefinition.defaults()` for fresh shelves (cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on). Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per row, plus the bottom-board top when `withBottom` is on for cubby / bookshelf. Material is a single paintable surface (same shape walls / slabs / stairs use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so unpainted shelves read as the canonical off-white. Preview clones each cached material before mutating `transparent / opacity` on the ghost — without the clone the mutation leaked into the cached `getShelfMaterial` instance every committed shelf was using, rendering them all see-through after the first placement preview rendered. Store hardening: `migrateNodes` patches missing `children: []` on v1 shelves, and `updateNodesAction` reparenting tolerates a missing children array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint mode picks up the kind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
794 lines
26 KiB
TypeScript
794 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import type { TemporalState } from 'zundo'
|
|
import { temporal } from 'zundo'
|
|
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
|
import { BuildingNode } from '../schema'
|
|
import type { Collection, CollectionId } from '../schema/collections'
|
|
import { generateCollectionId } from '../schema/collections'
|
|
import { LevelNode } from '../schema/nodes/level'
|
|
import { SiteNode } from '../schema/nodes/site'
|
|
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
|
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
|
import * as nodeActions from './actions/node-actions'
|
|
import { resetSceneHistoryPauseDepth } from './history-control'
|
|
|
|
function getFiniteNumber(value: unknown, fallback: number) {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
}
|
|
|
|
function getBoolean(value: unknown, fallback: boolean) {
|
|
return typeof value === 'boolean' ? value : fallback
|
|
}
|
|
|
|
function getEnumValue<T extends readonly string[]>(
|
|
value: unknown,
|
|
allowed: T,
|
|
fallback: T[number],
|
|
): T[number] {
|
|
return typeof value === 'string' && allowed.includes(value) ? value : fallback
|
|
}
|
|
|
|
function getNullableString(value: unknown) {
|
|
return typeof value === 'string' ? value : null
|
|
}
|
|
|
|
function getStringArray(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
: []
|
|
}
|
|
|
|
function getVector3(value: unknown, fallback: [number, number, number]): [number, number, number] {
|
|
if (!Array.isArray(value) || value.length < 3) {
|
|
return fallback
|
|
}
|
|
|
|
return [
|
|
getFiniteNumber(value[0], fallback[0]),
|
|
getFiniteNumber(value[1], fallback[1]),
|
|
getFiniteNumber(value[2], fallback[2]),
|
|
]
|
|
}
|
|
|
|
function normalizeStairNode(node: Record<string, unknown>) {
|
|
const sanitized = {
|
|
...node,
|
|
position: getVector3(node.position, [0, 0, 0]),
|
|
rotation: getFiniteNumber(node.rotation, 0),
|
|
stairType: getEnumValue(node.stairType, ['straight', 'curved', 'spiral'] as const, 'straight'),
|
|
fromLevelId: getNullableString(node.fromLevelId),
|
|
toLevelId: getNullableString(node.toLevelId),
|
|
slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'),
|
|
openingOffset: getFiniteNumber(node.openingOffset, 0),
|
|
width: getFiniteNumber(node.width, 1),
|
|
totalRise: getFiniteNumber(node.totalRise, 2.5),
|
|
stepCount: getFiniteNumber(node.stepCount, 10),
|
|
thickness: getFiniteNumber(node.thickness, 0.25),
|
|
fillToFloor: getBoolean(node.fillToFloor, true),
|
|
innerRadius: getFiniteNumber(node.innerRadius, 0.9),
|
|
sweepAngle: getFiniteNumber(node.sweepAngle, Math.PI / 2),
|
|
topLandingMode: getEnumValue(node.topLandingMode, ['none', 'integrated'] as const, 'none'),
|
|
topLandingDepth: getFiniteNumber(node.topLandingDepth, 0.9),
|
|
showCenterColumn: getBoolean(node.showCenterColumn, true),
|
|
showStepSupports: getBoolean(node.showStepSupports, true),
|
|
railingMode: getEnumValue(node.railingMode, ['none', 'left', 'right', 'both'] as const, 'none'),
|
|
railingHeight: getFiniteNumber(node.railingHeight, 0.92),
|
|
children: getStringArray(node.children),
|
|
}
|
|
|
|
const parsed = StairNodeSchema.safeParse(sanitized)
|
|
return parsed.success ? parsed.data : null
|
|
}
|
|
|
|
function normalizeStairSegmentNode(node: Record<string, unknown>) {
|
|
const sanitized = {
|
|
...node,
|
|
position: getVector3(node.position, [0, 0, 0]),
|
|
rotation: getFiniteNumber(node.rotation, 0),
|
|
segmentType: getEnumValue(node.segmentType, ['stair', 'landing'] as const, 'stair'),
|
|
width: getFiniteNumber(node.width, 1),
|
|
length: getFiniteNumber(node.length, 3),
|
|
height: getFiniteNumber(node.height, 2.5),
|
|
stepCount: getFiniteNumber(node.stepCount, 10),
|
|
attachmentSide: getEnumValue(node.attachmentSide, ['front', 'left', 'right'] as const, 'front'),
|
|
fillToFloor: getBoolean(node.fillToFloor, true),
|
|
thickness: getFiniteNumber(node.thickness, 0.25),
|
|
}
|
|
|
|
const parsed = StairSegmentNodeSchema.safeParse(sanitized)
|
|
return parsed.success ? parsed.data : null
|
|
}
|
|
|
|
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
|
const hasInterior =
|
|
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
|
|
const hasExterior =
|
|
node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string'
|
|
const legacyFinish = {
|
|
material: node.material,
|
|
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
|
}
|
|
|
|
if (!(hasInterior || hasExterior)) {
|
|
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
|
return node
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
interiorMaterial: legacyFinish.material,
|
|
interiorMaterialPreset: legacyFinish.materialPreset,
|
|
exteriorMaterial: legacyFinish.material,
|
|
exteriorMaterialPreset: legacyFinish.materialPreset,
|
|
}
|
|
}
|
|
|
|
if (!hasInterior) {
|
|
return {
|
|
...node,
|
|
interiorMaterial: node.exteriorMaterial,
|
|
interiorMaterialPreset: node.exteriorMaterialPreset,
|
|
}
|
|
}
|
|
|
|
if (!hasExterior) {
|
|
return {
|
|
...node,
|
|
exteriorMaterial: node.interiorMaterial,
|
|
exteriorMaterialPreset: node.interiorMaterialPreset,
|
|
}
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
|
const hasRailing =
|
|
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
|
|
const hasTread = node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
|
|
const hasSide = node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
|
|
const legacyFinish = {
|
|
material: node.material,
|
|
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
|
}
|
|
|
|
const resolveBodyFallback = () => {
|
|
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
|
return {
|
|
material: node.treadMaterial,
|
|
materialPreset:
|
|
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
|
}
|
|
}
|
|
|
|
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
|
return {
|
|
material: node.sideMaterial,
|
|
materialPreset:
|
|
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
|
}
|
|
}
|
|
|
|
return legacyFinish
|
|
}
|
|
|
|
if (!(hasRailing || hasTread || hasSide)) {
|
|
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
|
return node
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
railingMaterial: legacyFinish.material,
|
|
railingMaterialPreset: legacyFinish.materialPreset,
|
|
treadMaterial: legacyFinish.material,
|
|
treadMaterialPreset: legacyFinish.materialPreset,
|
|
sideMaterial: legacyFinish.material,
|
|
sideMaterialPreset: legacyFinish.materialPreset,
|
|
}
|
|
}
|
|
|
|
const next = { ...node }
|
|
|
|
if (!hasTread) {
|
|
const fallback =
|
|
node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
|
|
? {
|
|
material: node.sideMaterial,
|
|
materialPreset:
|
|
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
|
}
|
|
: resolveBodyFallback()
|
|
next.treadMaterial = fallback.material
|
|
next.treadMaterialPreset = fallback.materialPreset
|
|
}
|
|
|
|
if (!hasSide) {
|
|
const fallback =
|
|
node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
|
|
? {
|
|
material: node.treadMaterial,
|
|
materialPreset:
|
|
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
|
}
|
|
: resolveBodyFallback()
|
|
next.sideMaterial = fallback.material
|
|
next.sideMaterialPreset = fallback.materialPreset
|
|
}
|
|
|
|
if (!hasRailing) {
|
|
const fallback = resolveBodyFallback()
|
|
next.railingMaterial = fallback.material
|
|
next.railingMaterialPreset = fallback.materialPreset
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
|
const hasTop = node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string'
|
|
const hasEdge = node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string'
|
|
const hasWall = node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string'
|
|
const legacyFinish = {
|
|
material: node.material,
|
|
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
|
}
|
|
|
|
if (!(hasTop || hasEdge || hasWall)) {
|
|
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
|
return node
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
topMaterial: legacyFinish.material,
|
|
topMaterialPreset: legacyFinish.materialPreset,
|
|
edgeMaterial: legacyFinish.material,
|
|
edgeMaterialPreset: legacyFinish.materialPreset,
|
|
wallMaterial: legacyFinish.material,
|
|
wallMaterialPreset: legacyFinish.materialPreset,
|
|
}
|
|
}
|
|
|
|
const next = { ...node }
|
|
|
|
if (!hasTop) {
|
|
next.topMaterial = legacyFinish.material
|
|
next.topMaterialPreset = legacyFinish.materialPreset
|
|
}
|
|
|
|
if (!hasEdge) {
|
|
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
|
|
next.edgeMaterial = node.wallMaterial
|
|
next.edgeMaterialPreset =
|
|
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined
|
|
} else {
|
|
next.edgeMaterial = legacyFinish.material
|
|
next.edgeMaterialPreset = legacyFinish.materialPreset
|
|
}
|
|
}
|
|
|
|
if (!hasWall) {
|
|
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
|
|
next.wallMaterial = node.edgeMaterial
|
|
next.wallMaterialPreset =
|
|
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined
|
|
} else {
|
|
next.wallMaterial = legacyFinish.material
|
|
next.wallMaterialPreset = legacyFinish.materialPreset
|
|
}
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|
const patchedNodes = { ...nodes }
|
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
// 1. Item scale migration
|
|
if (node.type === 'item' && !('scale' in node)) {
|
|
patchedNodes[id] = { ...node, scale: [1, 1, 1] }
|
|
}
|
|
// 2. Old roof to new roof + segment migration
|
|
if (node.type === 'roof' && !('children' in node)) {
|
|
const oldRoof = node
|
|
const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2)
|
|
const segmentId = `rseg_${suffix}`
|
|
|
|
const segment = {
|
|
object: 'node',
|
|
id: segmentId,
|
|
type: 'roof-segment',
|
|
parentId: id,
|
|
visible: oldRoof.visible ?? true,
|
|
metadata: {},
|
|
position: [0, 0, 0],
|
|
rotation: 0,
|
|
roofType: 'gable',
|
|
width: oldRoof.length ?? 8,
|
|
depth: (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2),
|
|
wallHeight: 0,
|
|
roofHeight: oldRoof.height ?? 2.5,
|
|
wallThickness: 0.1,
|
|
deckThickness: 0.1,
|
|
overhang: 0.3,
|
|
shingleThickness: 0.05,
|
|
}
|
|
|
|
patchedNodes[segmentId] = segment
|
|
patchedNodes[id] = {
|
|
...oldRoof,
|
|
children: [segmentId],
|
|
}
|
|
}
|
|
|
|
if (node.type === 'stair') {
|
|
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
}
|
|
|
|
if (node.type === 'stair-segment') {
|
|
const normalized = normalizeStairSegmentNode(node)
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
}
|
|
|
|
if (node.type === 'wall') {
|
|
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
|
}
|
|
|
|
// Shelf v2: hosting was added in this migration cycle. Older shelves
|
|
// (saved before the schema gained `children`) need the field
|
|
// initialised so `createNode(item, shelfId)` finds an array to
|
|
// append the child id to — without this the host item ends up
|
|
// orphaned (parented in scene state but not in the shelf's
|
|
// children list, so the renderer doesn't mount it).
|
|
if (node.type === 'shelf' && !Array.isArray(node.children)) {
|
|
patchedNodes[id] = { ...node, children: [] }
|
|
}
|
|
|
|
if (node.type === 'roof') {
|
|
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
|
}
|
|
}
|
|
return patchedNodes as Record<string, AnyNode>
|
|
}
|
|
|
|
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
|
|
if (!('children' in node && Array.isArray(node.children))) {
|
|
return []
|
|
}
|
|
|
|
return (node.children as unknown[])
|
|
.map((child) => {
|
|
if (typeof child === 'string') return child
|
|
if (child && typeof child === 'object' && 'id' in child && typeof child.id === 'string') {
|
|
return child.id
|
|
}
|
|
return null
|
|
})
|
|
.filter((id): id is AnyNodeId => typeof id === 'string')
|
|
}
|
|
|
|
function normalizeRootNodeIds(
|
|
nodes: Record<AnyNodeId, AnyNode>,
|
|
rootNodeIds: AnyNodeId[],
|
|
): AnyNodeId[] {
|
|
const existingRootIds = rootNodeIds.filter((id) => Boolean(nodes[id]))
|
|
const siteRootIds = existingRootIds.filter((id) => nodes[id]?.type === 'site')
|
|
|
|
if (siteRootIds.length > 0) {
|
|
return siteRootIds
|
|
}
|
|
|
|
return existingRootIds.filter((id) => nodes[id]?.parentId === null)
|
|
}
|
|
|
|
function collectReachableNodeIds(
|
|
nodes: Record<AnyNodeId, AnyNode>,
|
|
rootNodeIds: AnyNodeId[],
|
|
): Set<AnyNodeId> {
|
|
const reachable = new Set<AnyNodeId>()
|
|
const stack = [...rootNodeIds]
|
|
const childIdsByParentId = new Map<AnyNodeId, AnyNodeId[]>()
|
|
|
|
for (const node of Object.values(nodes)) {
|
|
if (!node.parentId) continue
|
|
const parentId = node.parentId as AnyNodeId
|
|
const children = childIdsByParentId.get(parentId) ?? []
|
|
children.push(node.id as AnyNodeId)
|
|
childIdsByParentId.set(parentId, children)
|
|
}
|
|
|
|
while (stack.length > 0) {
|
|
const id = stack.pop()
|
|
if (!id || reachable.has(id)) continue
|
|
|
|
const node = nodes[id]
|
|
if (!node) continue
|
|
|
|
reachable.add(id)
|
|
stack.push(...getNodeChildIds(node))
|
|
stack.push(...(childIdsByParentId.get(id) ?? []))
|
|
}
|
|
|
|
return reachable
|
|
}
|
|
|
|
export type SceneState = {
|
|
// 1. The Data: A flat dictionary of all nodes
|
|
nodes: Record<AnyNodeId, AnyNode>
|
|
|
|
// 2. The Root: Which nodes are at the top level?
|
|
rootNodeIds: AnyNodeId[]
|
|
|
|
// 3. The "Dirty" Set: For the Wall/Physics systems
|
|
dirtyNodes: Set<AnyNodeId>
|
|
|
|
// 4. Relational metadata — not nodes
|
|
collections: Record<CollectionId, Collection>
|
|
|
|
// 5. Read-only lock — when true all create/update/delete operations are no-ops
|
|
readOnly: boolean
|
|
setReadOnly: (readOnly: boolean) => void
|
|
|
|
// Actions
|
|
loadScene: () => void
|
|
clearScene: () => void
|
|
unloadScene: () => void
|
|
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
|
|
|
|
markDirty: (id: AnyNodeId) => void
|
|
clearDirty: (id: AnyNodeId) => void
|
|
|
|
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
|
|
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
|
|
applyNodeChanges: (changes: {
|
|
create?: { node: AnyNode; parentId?: AnyNodeId }[]
|
|
update?: { id: AnyNodeId; data: Partial<AnyNode> }[]
|
|
delete?: AnyNodeId[]
|
|
}) => void
|
|
|
|
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
|
|
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
|
|
|
|
deleteNode: (id: AnyNodeId) => void
|
|
deleteNodes: (ids: AnyNodeId[]) => void
|
|
|
|
// Collection actions
|
|
createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId
|
|
deleteCollection: (id: CollectionId) => void
|
|
updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void
|
|
addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
|
removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
|
}
|
|
|
|
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
|
|
|
|
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
|
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>
|
|
}
|
|
|
|
const useScene: UseSceneStore = create<SceneState>()(
|
|
temporal(
|
|
(set, get) => ({
|
|
// 1. Flat dictionary of all nodes
|
|
nodes: {},
|
|
|
|
// 2. Root node IDs
|
|
rootNodeIds: [],
|
|
|
|
// 3. Dirty set
|
|
dirtyNodes: new Set<AnyNodeId>(),
|
|
|
|
// 4. Collections
|
|
collections: {} as Record<CollectionId, Collection>,
|
|
|
|
// 5. Read-only lock
|
|
readOnly: false,
|
|
setReadOnly: (readOnly: boolean) => set({ readOnly }),
|
|
|
|
unloadScene: () => {
|
|
set({
|
|
nodes: {},
|
|
rootNodeIds: [],
|
|
dirtyNodes: new Set<AnyNodeId>(),
|
|
collections: {},
|
|
})
|
|
},
|
|
|
|
clearScene: () => {
|
|
get().unloadScene()
|
|
get().loadScene() // Default scene
|
|
},
|
|
|
|
setScene: (nodes, rootNodeIds) => {
|
|
// Apply backward compatibility migrations
|
|
const patchedNodes = migrateNodes(nodes)
|
|
|
|
// Remove orphans: nodes whose parentId points to a non-existent node
|
|
const cleanedNodes = { ...patchedNodes }
|
|
for (const node of Object.values(cleanedNodes)) {
|
|
if (node.parentId && !cleanedNodes[node.parentId]) {
|
|
console.warn(
|
|
'[Scene] Removing orphan node',
|
|
node.id,
|
|
'(parentId',
|
|
node.parentId,
|
|
'not found)',
|
|
)
|
|
delete cleanedNodes[node.id]
|
|
}
|
|
}
|
|
|
|
set({
|
|
nodes: cleanedNodes,
|
|
rootNodeIds,
|
|
dirtyNodes: new Set<AnyNodeId>(),
|
|
collections: {},
|
|
})
|
|
|
|
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
|
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
|
|
if (normalizedRootNodeIds.length > 0) {
|
|
for (const node of Object.values(cleanedNodes)) {
|
|
if (reachableNodeIds.has(node.id as AnyNodeId)) continue
|
|
console.warn('[Scene] Removing unreachable node', node.id)
|
|
delete cleanedNodes[node.id]
|
|
}
|
|
}
|
|
|
|
set({
|
|
nodes: cleanedNodes,
|
|
rootNodeIds: normalizedRootNodeIds,
|
|
dirtyNodes: new Set<AnyNodeId>(),
|
|
collections: {},
|
|
})
|
|
// Mark all nodes as dirty to trigger re-validation
|
|
Object.values(cleanedNodes).forEach((node) => {
|
|
get().markDirty(node.id)
|
|
})
|
|
},
|
|
|
|
loadScene: () => {
|
|
if (get().rootNodeIds.length > 0) {
|
|
// Assign all nodes as dirty to force re-validation
|
|
Object.values(get().nodes).forEach((node) => {
|
|
get().markDirty(node.id)
|
|
})
|
|
return // Scene already loaded
|
|
}
|
|
|
|
// Create hierarchy: Site → Building → Level
|
|
const level0 = LevelNode.parse({
|
|
level: 0,
|
|
children: [],
|
|
})
|
|
|
|
const building = BuildingNode.parse({
|
|
children: [level0.id],
|
|
})
|
|
|
|
const site = SiteNode.parse({
|
|
children: [building],
|
|
})
|
|
|
|
// Define all nodes flat
|
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
|
[site.id]: site,
|
|
[building.id]: building,
|
|
[level0.id]: level0,
|
|
}
|
|
|
|
// Site is the root
|
|
const rootNodeIds = [site.id]
|
|
|
|
set({ nodes, rootNodeIds })
|
|
},
|
|
|
|
markDirty: (id) => {
|
|
get().dirtyNodes.add(id)
|
|
},
|
|
|
|
clearDirty: (id) => {
|
|
get().dirtyNodes.delete(id)
|
|
},
|
|
|
|
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
|
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
|
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
|
|
|
|
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
|
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
|
|
|
// --- DELETE ---
|
|
|
|
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
|
|
|
|
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
|
|
|
|
// --- COLLECTIONS ---
|
|
|
|
createCollection: (name, nodeIds = []) => {
|
|
if (get().readOnly) return '' as CollectionId
|
|
const id = generateCollectionId()
|
|
const collection: Collection = { id, name, nodeIds }
|
|
set((state) => {
|
|
const nextCollections = { ...state.collections, [id]: collection }
|
|
// Denormalize: stamp collectionId onto each node
|
|
const nextNodes = { ...state.nodes }
|
|
for (const nodeId of nodeIds) {
|
|
const node = nextNodes[nodeId]
|
|
if (!node) continue
|
|
const existing =
|
|
('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
|
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
|
|
}
|
|
return { collections: nextCollections, nodes: nextNodes }
|
|
})
|
|
return id
|
|
},
|
|
|
|
deleteCollection: (id) => {
|
|
if (get().readOnly) return
|
|
set((state) => {
|
|
const col = state.collections[id]
|
|
const nextCollections = { ...state.collections }
|
|
delete nextCollections[id]
|
|
// Remove collectionId from all member nodes
|
|
const nextNodes = { ...state.nodes }
|
|
for (const nodeId of col?.nodeIds ?? []) {
|
|
const node = nextNodes[nodeId]
|
|
if (!(node && 'collectionIds' in node)) continue
|
|
nextNodes[nodeId] = {
|
|
...node,
|
|
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
|
} as AnyNode
|
|
}
|
|
return { collections: nextCollections, nodes: nextNodes }
|
|
})
|
|
},
|
|
|
|
updateCollection: (id, data) => {
|
|
if (get().readOnly) return
|
|
set((state) => {
|
|
const col = state.collections[id]
|
|
if (!col) return state
|
|
return { collections: { ...state.collections, [id]: { ...col, ...data } } }
|
|
})
|
|
},
|
|
|
|
addToCollection: (id, nodeId) => {
|
|
if (get().readOnly) return
|
|
set((state) => {
|
|
const col = state.collections[id]
|
|
if (!col || col.nodeIds.includes(nodeId)) return state
|
|
const nextCollections = {
|
|
...state.collections,
|
|
[id]: { ...col, nodeIds: [...col.nodeIds, nodeId] },
|
|
}
|
|
const node = state.nodes[nodeId]
|
|
if (!node) return { collections: nextCollections }
|
|
const existing =
|
|
('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
|
const nextNodes = {
|
|
...state.nodes,
|
|
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
|
|
}
|
|
return { collections: nextCollections, nodes: nextNodes }
|
|
})
|
|
},
|
|
|
|
removeFromCollection: (id, nodeId) => {
|
|
if (get().readOnly) return
|
|
set((state) => {
|
|
const col = state.collections[id]
|
|
if (!col) return state
|
|
const nextCollections = {
|
|
...state.collections,
|
|
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
|
}
|
|
const node = state.nodes[nodeId]
|
|
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
|
const nextNodes = {
|
|
...state.nodes,
|
|
[nodeId]: {
|
|
...node,
|
|
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
|
} as AnyNode,
|
|
}
|
|
return { collections: nextCollections, nodes: nextNodes }
|
|
})
|
|
},
|
|
}),
|
|
{
|
|
partialize: (state) => {
|
|
const { nodes, rootNodeIds, collections } = state
|
|
return { nodes, rootNodeIds, collections }
|
|
},
|
|
limit: 50, // Limit to last 50 actions
|
|
},
|
|
),
|
|
)
|
|
|
|
export default useScene
|
|
|
|
// Track previous temporal state lengths and node snapshot for diffing
|
|
let prevPastLength = 0
|
|
let prevFutureLength = 0
|
|
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
|
|
|
export function clearSceneHistory() {
|
|
resetSceneHistoryPauseDepth()
|
|
useScene.temporal.getState().clear()
|
|
prevPastLength = 0
|
|
prevFutureLength = 0
|
|
prevNodesSnapshot = null
|
|
}
|
|
|
|
// Subscribe to the temporal store (Undo/Redo events)
|
|
useScene.temporal.subscribe((state) => {
|
|
const currentPastLength = state.pastStates.length
|
|
const currentFutureLength = state.futureStates.length
|
|
|
|
// Undo: futureStates increases (state moved from past to future)
|
|
// Redo: pastStates increases while futureStates decreases (state moved from future to past)
|
|
const didUndo = currentFutureLength > prevFutureLength
|
|
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
|
|
|
|
if (didUndo || didRedo) {
|
|
// Capture the previous snapshot before RAF fires
|
|
const snapshotBefore = prevNodesSnapshot
|
|
|
|
// Defer to a microtask so the scene store has settled before we diff,
|
|
// but still mark walls/items dirty before the next paint.
|
|
queueMicrotask(() => {
|
|
const currentNodes = useScene.getState().nodes
|
|
const { markDirty } = useScene.getState()
|
|
|
|
if (snapshotBefore) {
|
|
// Diff: only mark nodes that actually changed
|
|
for (const [id, node] of Object.entries(currentNodes) as [AnyNodeId, AnyNode][]) {
|
|
if (snapshotBefore[id] !== node) {
|
|
markDirty(id)
|
|
// Also mark parent so merged geometries update
|
|
if (node.parentId) markDirty(node.parentId as AnyNodeId)
|
|
}
|
|
}
|
|
// Nodes that were deleted (exist in prev but not current)
|
|
for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) {
|
|
if (!currentNodes[id]) {
|
|
const parentId = node.parentId as AnyNodeId | undefined
|
|
if (parentId) {
|
|
markDirty(parentId)
|
|
// Mark sibling nodes dirty so they can update their geometry
|
|
// (e.g. adjacent walls need to recalculate miter/junction geometry)
|
|
const parent = currentNodes[parentId]
|
|
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
|
for (const childId of parent.children) {
|
|
markDirty(childId as AnyNodeId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// No snapshot to diff against — fall back to marking all
|
|
for (const node of Object.values(currentNodes)) {
|
|
markDirty(node.id)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Update tracked lengths and snapshot
|
|
prevPastLength = currentPastLength
|
|
prevFutureLength = currentFutureLength
|
|
prevNodesSnapshot = useScene.getState().nodes
|
|
})
|