* Fix: Restore floor drag/delete functionality for imported legacy JSON files Summary Fixed an issue where floors became impossible to drag or delete after importing JSON configuration files created in versions prior to 0.9.1. Root Cause The migration process was executing elevator parent migration before all level nodes had their children fully normalized. As a result, imported scenes could end up with inconsistent parent-child relationships, causing floor management operations such as dragging and deletion to fail. Changes Made Refactored migrateNodes() into a two-pass migration process. Added normalization for level nodes: Ensures level values are valid finite numbers. Removes references to missing child nodes. Preserves only valid children during migration. Moved elevator migration logic to a dedicated second pass: Elevator parent migration now runs only after all level.children relationships have been stabilized. Prevents invalid hierarchy reconstruction when importing legacy JSON files. Result Imported layouts from versions prior to 0.9.1 now correctly preserve floor hierarchy, allowing floors to be dragged, reordered, and deleted as expected. * Update use-scene.ts
1322 lines
47 KiB
TypeScript
1322 lines
47 KiB
TypeScript
'use client'
|
|
|
|
import type { TemporalState } from 'zundo'
|
|
import { temporal } from 'zundo'
|
|
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
|
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
|
|
import { nodeRegistry } from '../registry/registry'
|
|
import { BuildingNode } from '../schema'
|
|
import type { Collection, CollectionId } from '../schema/collections'
|
|
import { generateCollectionId } from '../schema/collections'
|
|
import { DoorNode as DoorNodeSchema } from '../schema/nodes/door'
|
|
import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator'
|
|
import { LevelNode } from '../schema/nodes/level'
|
|
import {
|
|
getPitchFromActiveRoofHeight,
|
|
type RoofSegmentNode,
|
|
type RoofType,
|
|
} from '../schema/nodes/roof-segment'
|
|
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls'
|
|
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
|
|
import { SiteNode } from '../schema/nodes/site'
|
|
import {
|
|
getEffectiveStairSurfaceMaterial,
|
|
StairNode as StairNodeSchema,
|
|
} from '../schema/nodes/stair'
|
|
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
|
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
|
|
import {
|
|
generateSceneMaterialId,
|
|
type SceneMaterial,
|
|
type SceneMaterialId,
|
|
} from '../schema/scene-material'
|
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
|
import { healSceneNodes } from '../utils/heal-scene-graph'
|
|
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 getFiniteNumberInRange(value: unknown, fallback: number, min: number, max: number) {
|
|
const finite = getFiniteNumber(value, fallback)
|
|
return Math.min(Math.max(finite, min), max)
|
|
}
|
|
|
|
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.length > 0 ? 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 normalizeDoorNode(node: Record<string, unknown>) {
|
|
const parsed = DoorNodeSchema.safeParse(node)
|
|
return parsed.success ? { ...node, ...parsed.data } : null
|
|
}
|
|
|
|
function normalizeShelfNode(node: Record<string, unknown>) {
|
|
const sanitized = {
|
|
...node,
|
|
children: getStringArray(node.children),
|
|
position: getVector3(node.position, [0, 0, 0]),
|
|
rotation: getVector3(node.rotation, [0, 0, 0]),
|
|
width: getFiniteNumberInRange(node.width, 1.2, 0.3, 3.0),
|
|
depth: getFiniteNumberInRange(node.depth, 0.3, 0.1, 1.0),
|
|
thickness: getFiniteNumberInRange(node.thickness, 0.04, 0.01, 0.1),
|
|
height: getFiniteNumberInRange(node.height, 0.9, 0.05, 2.5),
|
|
rows: Math.round(getFiniteNumberInRange(node.rows, 1, 1, 8)),
|
|
columns: Math.round(getFiniteNumberInRange(node.columns, 1, 1, 6)),
|
|
style: getEnumValue(
|
|
node.style,
|
|
['wall-shelf', 'bookshelf', 'open-rack', 'cubby'] as const,
|
|
'wall-shelf',
|
|
),
|
|
withBack: getBoolean(node.withBack, false),
|
|
withSides: getBoolean(node.withSides, true),
|
|
withBottom: getBoolean(node.withBottom, false),
|
|
bracketStyle: getEnumValue(
|
|
node.bracketStyle,
|
|
['minimal', 'industrial', 'hidden'] as const,
|
|
'minimal',
|
|
),
|
|
}
|
|
|
|
const parsed = ShelfNodeSchema.safeParse(sanitized)
|
|
return parsed.success ? parsed.data : null
|
|
}
|
|
|
|
function normalizeElevatorNode(node: Record<string, unknown>) {
|
|
const sanitized = {
|
|
...node,
|
|
position: getVector3(node.position, [0, 0, 0]),
|
|
rotation: getFiniteNumber(node.rotation, 0),
|
|
width: getFiniteNumber(node.width, 1.84),
|
|
depth: getFiniteNumber(node.depth, 1.84),
|
|
shaftWidth: node.shaftWidth === undefined ? undefined : getFiniteNumber(node.shaftWidth, 1.84),
|
|
shaftDepth: node.shaftDepth === undefined ? undefined : getFiniteNumber(node.shaftDepth, 1.84),
|
|
shaftWallThickness: getFiniteNumber(node.shaftWallThickness, 0.09),
|
|
cabHeight: getFiniteNumber(node.cabHeight, 2.35),
|
|
doorWidth: getFiniteNumber(node.doorWidth, 0.95),
|
|
doorHeight: getFiniteNumber(node.doorHeight, 2.1),
|
|
fromLevelId: getNullableString(node.fromLevelId),
|
|
toLevelId: getNullableString(node.toLevelId),
|
|
servedLevelIds:
|
|
node.servedLevelIds === undefined ? undefined : getStringArray(node.servedLevelIds),
|
|
disabledLevelIds: getStringArray(node.disabledLevelIds),
|
|
serviceOnlyLevelIds: getStringArray(node.serviceOnlyLevelIds),
|
|
defaultLevelId: getNullableString(node.defaultLevelId),
|
|
speed: getFiniteNumber(node.speed, 2.2),
|
|
doorDurationMs: getFiniteNumber(node.doorDurationMs, 900),
|
|
dwellMs: getFiniteNumber(node.dwellMs, 1400),
|
|
}
|
|
|
|
const parsed = ElevatorNodeSchema.safeParse(sanitized)
|
|
return parsed.success ? parsed.data : null
|
|
}
|
|
|
|
function findBuildingIdForLevel(levelId: string, nodes: Record<string, any>): string | null {
|
|
const level = nodes[levelId]
|
|
const directBuildingId = typeof level?.parentId === 'string' ? level.parentId : null
|
|
if (directBuildingId && nodes[directBuildingId]?.type === 'building') {
|
|
return directBuildingId
|
|
}
|
|
|
|
for (const [candidateId, candidate] of Object.entries(nodes)) {
|
|
if (candidate?.type !== 'building') continue
|
|
if (getStringArray(candidate.children).includes(levelId)) {
|
|
return candidateId
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function migrateElevatorParent(
|
|
id: string,
|
|
node: Record<string, unknown>,
|
|
nodes: Record<string, any>,
|
|
) {
|
|
const parentId = typeof node.parentId === 'string' ? node.parentId : null
|
|
if (!parentId) return node
|
|
const parent = parentId ? nodes[parentId] : null
|
|
if (parent?.type !== 'level') return node
|
|
|
|
const buildingId = findBuildingIdForLevel(parentId, nodes)
|
|
if (!buildingId) return node
|
|
const building = buildingId ? nodes[buildingId] : null
|
|
if (building?.type !== 'building') return node
|
|
|
|
nodes[parentId] = {
|
|
...parent,
|
|
children: getStringArray(parent.children).filter((childId) => childId !== id),
|
|
}
|
|
|
|
const buildingChildren = getStringArray(building.children)
|
|
nodes[buildingId] = {
|
|
...building,
|
|
children: buildingChildren.includes(id) ? buildingChildren : [...buildingChildren, id],
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
parentId: buildingId,
|
|
}
|
|
}
|
|
|
|
// Reuse an already-minted scene material for an identical inline legacy
|
|
// material so a whole building painted one custom colour collapses to one
|
|
// shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match).
|
|
function findMintedSceneMaterialRef(
|
|
material: unknown,
|
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
|
|
): string | undefined {
|
|
const target = JSON.stringify(material)
|
|
for (const sceneMaterial of Object.values(mintedMaterials)) {
|
|
if (JSON.stringify(sceneMaterial.material) === target) {
|
|
return toSceneMaterialRef(sceneMaterial.id)
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// Turn a legacy surface spec (`{ material, materialPreset }`) into a
|
|
// `MaterialRef`: a preset that's already a `library:`/`scene:` ref is used
|
|
// as-is; an inline material mints (or reuses) a scene material. Returns
|
|
// undefined when the spec carries no material. Shared by every legacy→slots
|
|
// migration below.
|
|
function legacySpecToMaterialRef(
|
|
spec: { material?: unknown; materialPreset?: unknown },
|
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
|
|
): string | undefined {
|
|
if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) {
|
|
return spec.materialPreset
|
|
}
|
|
if (spec.material !== undefined) {
|
|
const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials)
|
|
if (existing) return existing
|
|
const id = generateSceneMaterialId()
|
|
mintedMaterials[id] = {
|
|
id,
|
|
name: `Material ${Object.keys(mintedMaterials).length + 1}`,
|
|
material: spec.material as SceneMaterial['material'],
|
|
}
|
|
return toSceneMaterialRef(id)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// Move the retired inline `material*` / `interiorMaterial*` / `exteriorMaterial*`
|
|
// fields onto the unified `node.slots` model (interior / exterior → a
|
|
// `library:`/`scene:` ref), minting scene materials for inline customs into
|
|
// `mintedMaterials` (merged into the scene material map by the caller). Already
|
|
// slot-modelled walls and walls with no legacy material are left untouched.
|
|
function migrateWallSurfaceMaterials(
|
|
node: Record<string, any>,
|
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
|
|
) {
|
|
if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) {
|
|
return node
|
|
}
|
|
|
|
const slots: Record<string, string> = { ...(node.slots ?? {}) }
|
|
for (const side of ['interior', 'exterior'] as WallSurfaceSide[]) {
|
|
const spec = getEffectiveWallSurfaceMaterial(
|
|
node as Parameters<typeof getEffectiveWallSurfaceMaterial>[0],
|
|
side,
|
|
)
|
|
const ref = legacySpecToMaterialRef(spec, mintedMaterials)
|
|
if (ref) slots[side] = ref
|
|
}
|
|
|
|
if (Object.keys(slots).length === 0) {
|
|
return node
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
slots,
|
|
material: undefined,
|
|
materialPreset: undefined,
|
|
interiorMaterial: undefined,
|
|
interiorMaterialPreset: undefined,
|
|
exteriorMaterial: undefined,
|
|
exteriorMaterialPreset: undefined,
|
|
}
|
|
}
|
|
|
|
// Move a kind's single legacy `material` / `materialPreset` onto its declared
|
|
// slots. A pre-slot-model node painted one material rendered that material on
|
|
// every part (each slot resolves `node.slots[slot]` → legacy → default), so the
|
|
// migration writes the same ref to every slot id the kind can expose — unused
|
|
// conditional slots are harmless. Already slot-modelled or unpainted nodes are
|
|
// left untouched. Mirrors `migrateWallSurfaceMaterials` for single-surface and
|
|
// whole-object kinds (slab, ceiling, fence, column, shelf).
|
|
function migrateSingleMaterialSlots(
|
|
node: Record<string, any>,
|
|
slotIds: readonly string[],
|
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
|
|
) {
|
|
if (node.slots && Object.keys(node.slots).length > 0) {
|
|
return node
|
|
}
|
|
|
|
const ref = legacySpecToMaterialRef(
|
|
{ material: node.material, materialPreset: node.materialPreset },
|
|
mintedMaterials,
|
|
)
|
|
if (!ref) {
|
|
return node
|
|
}
|
|
|
|
const slots: Record<string, string> = {}
|
|
for (const slotId of slotIds) slots[slotId] = ref
|
|
|
|
return { ...node, slots, material: undefined, materialPreset: undefined }
|
|
}
|
|
|
|
// Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` /
|
|
// `railingMaterial*`) plus a catch-all. Map each to its slot via the same
|
|
// fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`):
|
|
// tread→treads, side→body, railing→railing. Runs after
|
|
// `migrateStairSurfaceMaterials` has normalised the legacy fields.
|
|
function migrateStairSurfaceSlots(
|
|
node: Record<string, any>,
|
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>,
|
|
) {
|
|
if (node.slots && Object.keys(node.slots).length > 0) {
|
|
return node
|
|
}
|
|
|
|
const roleToSlot = [
|
|
['tread', 'treads'],
|
|
['side', 'body'],
|
|
['railing', 'railing'],
|
|
] as const
|
|
|
|
const slots: Record<string, string> = {}
|
|
for (const [role, slotId] of roleToSlot) {
|
|
const spec = getEffectiveStairSurfaceMaterial(
|
|
node as Parameters<typeof getEffectiveStairSurfaceMaterial>[0],
|
|
role,
|
|
)
|
|
const ref = legacySpecToMaterialRef(spec, mintedMaterials)
|
|
if (ref) slots[slotId] = ref
|
|
}
|
|
|
|
if (Object.keys(slots).length === 0) {
|
|
return node
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
slots,
|
|
material: undefined,
|
|
materialPreset: undefined,
|
|
treadMaterial: undefined,
|
|
treadMaterialPreset: undefined,
|
|
sideMaterial: undefined,
|
|
sideMaterialPreset: undefined,
|
|
railingMaterial: undefined,
|
|
railingMaterialPreset: undefined,
|
|
}
|
|
}
|
|
|
|
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>): {
|
|
nodes: Record<string, AnyNode>
|
|
mintedMaterials: Record<SceneMaterialId, SceneMaterial>
|
|
} {
|
|
// Repair pre-existing corruption (null children, zero-length walls) before
|
|
// any per-type migration runs, so already-saved scenes load cleanly.
|
|
const { nodes: healed } = healSceneNodes(nodes)
|
|
const patchedNodes = { ...healed } as Record<string, any>
|
|
|
|
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
|
// merged into the scene material map by the caller (`setScene`).
|
|
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
|
|
|
|
// Pass 1: all node types except elevator.
|
|
// Elevator migration (migrateElevatorParent) mutates level.children to remove
|
|
// the elevator ID. If the elevator is processed before its parent level in
|
|
// Object.entries order, the level migration in this same pass would then see
|
|
// a children array that still contains the elevator ID and filter it out as
|
|
// "missing" — corrupting the level. Running elevators in a second pass after
|
|
// all levels are stable avoids the race entirely.
|
|
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 segWidth = oldRoof.length ?? 8
|
|
const segDepth = (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2)
|
|
const legacyRoofHeight = oldRoof.height ?? 2.5
|
|
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: segWidth,
|
|
depth: segDepth,
|
|
// Schema default (0.5), NOT 0: a zero-height wall builds a flat,
|
|
// degenerate CSG brush → "Coplanar clip not handled" + NaN geometry, so
|
|
// the migrated legacy roof never renders. New roofs use 0.5 too.
|
|
wallHeight: 0.5,
|
|
pitch: getPitchFromActiveRoofHeight({
|
|
roofType: 'gable',
|
|
width: segWidth,
|
|
depth: segDepth,
|
|
roofHeight: legacyRoofHeight,
|
|
}),
|
|
wallThickness: 0.1,
|
|
deckThickness: 0.1,
|
|
overhang: 0.3,
|
|
shingleThickness: 0.05,
|
|
}
|
|
|
|
patchedNodes[segmentId] = segment
|
|
patchedNodes[id] = {
|
|
...oldRoof,
|
|
children: [segmentId],
|
|
}
|
|
}
|
|
|
|
// 2b. roof-segment: guarantee a valid positive pitch (degrees).
|
|
// Saved scenes wrote `roofHeight` in metres; the schema now stores `pitch`
|
|
// in degrees. Convert the legacy field when present, and — crucially — fall
|
|
// back to the schema default for any segment that carries no usable pitch or
|
|
// roofHeight (older/partial saves). Without this, the slope-frame guard
|
|
// resolves a missing pitch to a FLAT frame, so the roof renders as a slab.
|
|
// The migration result is cast, not zod-parsed, so the schema default never
|
|
// applies on its own — this branch is the only place it lands.
|
|
if (node.type === 'roof-segment') {
|
|
const currentPitch = (node as { pitch?: unknown }).pitch
|
|
const hasValidPitch = typeof currentPitch === 'number' && currentPitch > 0
|
|
if (!hasValidPitch) {
|
|
const { roofHeight, ...rest } = node as RoofSegmentNode & { roofHeight?: unknown }
|
|
const width = typeof node.width === 'number' ? node.width : 8
|
|
const depth = typeof node.depth === 'number' ? node.depth : 6
|
|
const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType
|
|
const derived =
|
|
typeof roofHeight === 'number' && roofHeight > 0
|
|
? getPitchFromActiveRoofHeight({ roofType, width, depth, roofHeight })
|
|
: 0
|
|
// 40° matches the RoofSegmentNode schema default.
|
|
patchedNodes[id] = { ...rest, pitch: derived > 0 ? derived : 40 }
|
|
}
|
|
}
|
|
|
|
if (node.type === 'door') {
|
|
const normalized = normalizeDoorNode(node)
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
}
|
|
|
|
if (node.type === 'stair') {
|
|
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials)
|
|
}
|
|
|
|
if (node.type === 'stair-segment') {
|
|
const normalized = normalizeStairSegmentNode(node)
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
}
|
|
|
|
if (node.type === 'wall') {
|
|
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials)
|
|
}
|
|
|
|
if (node.type === 'slab' || node.type === 'ceiling') {
|
|
patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials)
|
|
}
|
|
|
|
if (node.type === 'fence') {
|
|
patchedNodes[id] = migrateSingleMaterialSlots(
|
|
patchedNodes[id],
|
|
['posts', 'infill', 'base', 'rail'],
|
|
mintedMaterials,
|
|
)
|
|
}
|
|
|
|
if (node.type === 'column') {
|
|
patchedNodes[id] = migrateSingleMaterialSlots(
|
|
patchedNodes[id],
|
|
['shaft', 'base', 'capital', 'frame'],
|
|
mintedMaterials,
|
|
)
|
|
}
|
|
|
|
if (node.type === 'shelf') {
|
|
const normalized = normalizeShelfNode(node)
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
patchedNodes[id] = migrateSingleMaterialSlots(
|
|
patchedNodes[id],
|
|
['shelves', 'frame', 'back'],
|
|
mintedMaterials,
|
|
)
|
|
}
|
|
|
|
// Roof-segment hosting was added in this migration cycle (the same
|
|
// pattern as shelf above). Older segments saved before the schema
|
|
// gained `children` need the field initialised so
|
|
// `createNode(chimney, segmentId)` finds an array to append to —
|
|
// without this every "Add Element" click on the roof panel results
|
|
// in an orphaned accessory (parented in scene state but never
|
|
// appended to `seg.children`, so the renderer's recursive
|
|
// `<NodeRenderer>` mount never sees it).
|
|
if (node.type === 'roof-segment' && !Array.isArray((node as { children?: unknown }).children)) {
|
|
patchedNodes[id] = { ...node, children: [] } as AnyNode
|
|
}
|
|
|
|
// Roof-hosted wall children (door / window / item) originally stored
|
|
// SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
|
|
// format moved to explicit `roofFace` + FACE-LOCAL coords so the
|
|
// renderer's face frame can track segment edits live. Convert in
|
|
// place: face from the old cardinal yaw, u/v from the outer-plane
|
|
// projection, z re-based from the outer plane to the wall mid-plane.
|
|
if (
|
|
(node.type === 'door' || node.type === 'window' || node.type === 'item') &&
|
|
typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' &&
|
|
(node as { roofFace?: unknown }).roofFace === undefined
|
|
) {
|
|
const current = patchedNodes[id] as AnyNode & {
|
|
roofSegmentId: string
|
|
position: [number, number, number]
|
|
rotation: [number, number, number]
|
|
}
|
|
const segment = patchedNodes[current.roofSegmentId] as
|
|
| (AnyNode & { wallThickness?: number })
|
|
| undefined
|
|
if (segment?.type === 'roof-segment') {
|
|
const tau = Math.PI * 2
|
|
const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau
|
|
const eps = 1e-3
|
|
const face =
|
|
yaw < eps || tau - yaw < eps
|
|
? ('front' as const)
|
|
: Math.abs(yaw - Math.PI) < eps
|
|
? ('back' as const)
|
|
: Math.abs(yaw - Math.PI / 2) < eps
|
|
? ('right' as const)
|
|
: Math.abs(yaw - (3 * Math.PI) / 2) < eps
|
|
? ('left' as const)
|
|
: null
|
|
if (face) {
|
|
const { u, v, dist } = segmentPointToRoofWallFace(
|
|
segment as never,
|
|
face,
|
|
current.position,
|
|
)
|
|
patchedNodes[id] = {
|
|
...current,
|
|
roofFace: face,
|
|
position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
|
|
rotation: [0, 0, 0],
|
|
} as 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 }
|
|
}
|
|
}
|
|
|
|
// Level children normalization.
|
|
// Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node
|
|
// map (e.g. elevator IDs that lived under a level before the elevator
|
|
// parent migration moved them up to building). If those dangling IDs are
|
|
// left in place, collectReachableNodeIds marks the level as having
|
|
// reachable children that don't exist, which corrupts the scene graph
|
|
// traversal and leaves the LevelNode in a broken state — making floors
|
|
// impossible to drag or delete after import.
|
|
// We intentionally do NOT filter by type prefix here; being permissive
|
|
// about which types are allowed as children prevents data loss when new
|
|
// child types are added to the schema in the future.
|
|
if (node.type === 'level') {
|
|
const rawChildren = getStringArray(node.children)
|
|
const validChildren = rawChildren.filter((childId) => {
|
|
const exists = Boolean(patchedNodes[childId])
|
|
if (!exists) {
|
|
console.warn(
|
|
'[migrateNodes] level',
|
|
id,
|
|
'references missing child',
|
|
childId,
|
|
'— dropping',
|
|
)
|
|
}
|
|
return exists
|
|
})
|
|
const levelNumber = getFiniteNumber(node.level, 0)
|
|
patchedNodes[id] = {
|
|
...node,
|
|
level: levelNumber,
|
|
children: validChildren,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pass 2: elevator migration.
|
|
// migrateElevatorParent mutates the parent level's children array (removes
|
|
// the elevator ID from it). Running this after Pass 1 guarantees that the
|
|
// level normalization above has already seen a clean children list — if we
|
|
// ran elevator migration inside Pass 1, the order of Object.entries
|
|
// iteration would be non-deterministic: processing an elevator before its
|
|
// parent level would mutate the level's children mid-iteration, potentially
|
|
// causing the level branch above to see a stale node reference.
|
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
if (node.type !== 'elevator') continue
|
|
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
|
|
const normalized = normalizeElevatorNode(parentMigrated)
|
|
if (normalized) {
|
|
patchedNodes[id] = normalized
|
|
}
|
|
}
|
|
|
|
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
|
|
}
|
|
|
|
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>
|
|
materials: Record<SceneMaterialId, SceneMaterial>
|
|
|
|
// 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[],
|
|
extra?: {
|
|
collections?: Record<CollectionId, Collection>
|
|
materials?: Record<SceneMaterialId, SceneMaterial>
|
|
},
|
|
) => 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
|
|
|
|
// Scene material actions
|
|
addSceneMaterial: (material: SceneMaterial) => void
|
|
updateSceneMaterial: (id: SceneMaterialId, data: Partial<Omit<SceneMaterial, 'id'>>) => void
|
|
removeSceneMaterial: (id: SceneMaterialId) => void
|
|
}
|
|
|
|
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
|
|
|
|
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
|
temporal: StoreApi<
|
|
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>>
|
|
>
|
|
}
|
|
|
|
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>,
|
|
materials: {} as Record<SceneMaterialId, SceneMaterial>,
|
|
|
|
// 5. Read-only lock
|
|
readOnly: false,
|
|
setReadOnly: (readOnly: boolean) => set({ readOnly }),
|
|
|
|
unloadScene: () => {
|
|
set({
|
|
nodes: {},
|
|
rootNodeIds: [],
|
|
dirtyNodes: new Set<AnyNodeId>(),
|
|
collections: {},
|
|
materials: {},
|
|
})
|
|
},
|
|
|
|
clearScene: () => {
|
|
get().unloadScene()
|
|
get().loadScene() // Default scene
|
|
},
|
|
|
|
setScene: (nodes, rootNodeIds, extra) => {
|
|
// Apply backward compatibility migrations
|
|
const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes)
|
|
// Scene materials minted by the wall legacy→slots migration join the
|
|
// loaded palette (existing refs win on id collision — there are none,
|
|
// ids are freshly generated).
|
|
const materials = { ...mintedMaterials, ...(extra?.materials ?? {}) }
|
|
|
|
// 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: extra?.collections ?? {},
|
|
materials,
|
|
})
|
|
|
|
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: extra?.collections ?? {},
|
|
materials,
|
|
})
|
|
// 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.id],
|
|
})
|
|
|
|
// 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) => {
|
|
const node = get().nodes[id]
|
|
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
|
|
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 }
|
|
})
|
|
},
|
|
|
|
// --- SCENE MATERIALS ---
|
|
|
|
addSceneMaterial: (material) => {
|
|
if (get().readOnly) return
|
|
set((state) => ({
|
|
materials: { ...state.materials, [material.id]: material },
|
|
}))
|
|
},
|
|
|
|
updateSceneMaterial: (id, data) => {
|
|
if (get().readOnly) return
|
|
set((state) => {
|
|
const material = state.materials[id]
|
|
if (!material) return state
|
|
return { materials: { ...state.materials, [id]: { ...material, ...data } } }
|
|
})
|
|
},
|
|
|
|
removeSceneMaterial: (id) => {
|
|
if (get().readOnly) return
|
|
set((state) => {
|
|
const materials = { ...state.materials }
|
|
delete materials[id]
|
|
return { materials }
|
|
})
|
|
},
|
|
}),
|
|
{
|
|
partialize: (state) => {
|
|
const { nodes, rootNodeIds, collections, materials } = state
|
|
return { nodes, rootNodeIds, collections, materials }
|
|
},
|
|
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
|
|
})
|