sync: update core and viewer from monorepo (#143)

Major changes:
- Roof system rewrite with roof-segment support
- Scene store refactor
- Spatial grid improvements
- Item light system
- Post-processing and selection manager updates
- Perf monitor component

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-03-20 23:18:30 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bafc5973a3
commit 1d28e4208f
32 changed files with 1921 additions and 619 deletions
+3
View File
@@ -7,6 +7,7 @@ import type {
ItemNode,
LevelNode,
RoofNode,
RoofSegmentNode,
SiteNode,
SlabNode,
WallNode,
@@ -39,6 +40,7 @@ export type ZoneEvent = NodeEvent<ZoneNode>
export type SlabEvent = NodeEvent<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
@@ -100,6 +102,7 @@ type EditorEvents = GridEvents &
NodeEvents<'slab', SlabEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
CameraControlEvents &
@@ -17,11 +17,20 @@ export const sceneRegistry = {
slab: new Set<string>(),
zone: new Set<string>(),
roof: new Set<string>(),
'roof-segment': new Set<string>(),
scan: new Set<string>(),
guide: new Set<string>(),
window: new Set<string>(),
door: new Set<string>(),
},
/** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */
clear() {
this.nodes.clear()
for (const set of Object.values(this.byType)) {
set.clear()
}
},
}
export function useRegistry(
@@ -273,15 +273,19 @@ export function wallOverlapsPolygon(
}
export class SpatialGridManager {
private floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
private wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
private walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
private slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)
private ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid
private ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data
private itemCeilingMap = new Map<string, string>() // itemId -> ceilingId (reverse lookup)
private readonly floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
private readonly wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
private readonly walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
private readonly slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)
private readonly ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid
private readonly ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data
private readonly itemCeilingMap = new Map<string, string>() // itemId -> ceilingId (reverse lookup)
constructor(private cellSize = 0.5) {}
private readonly cellSize: number
constructor(cellSize = 0.5) {
this.cellSize = cellSize
}
private getFloorGrid(levelId: string): SpatialGrid {
if (!this.floorGrids.has(levelId)) {
@@ -9,10 +9,14 @@ interface SpatialGridConfig {
}
export class SpatialGrid {
private cells = new Map<CellKey, GridCell>()
private itemCells = new Map<string, Set<CellKey>>() // reverse lookup
private readonly cells = new Map<CellKey, GridCell>()
private readonly itemCells = new Map<string, Set<CellKey>>() // reverse lookup
constructor(private config: SpatialGridConfig) {}
private readonly config: SpatialGridConfig
constructor(config: SpatialGridConfig) {
this.config = config
}
private posToCell(x: number, z: number): [number, number] {
return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)]
@@ -75,7 +79,7 @@ export class SpatialGrid {
if (!this.cells.has(key)) {
this.cells.set(key, { itemIds: new Set() })
}
this.cells.get(key)!.itemIds.add(itemId)
this.cells.get(key)?.itemIds.add(itemId)
}
}
@@ -49,8 +49,8 @@ function autoAdjustYPosition(
}
export class WallSpatialGrid {
private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
private readonly wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
private readonly itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
/**
* Check if an item can be placed on a wall with auto-adjustment for vertical position
@@ -152,7 +152,7 @@ export class WallSpatialGrid {
if (!this.wallItems.has(wallId)) {
this.wallItems.set(wallId, [])
}
this.wallItems.get(wallId)!.push(placement)
this.wallItems.get(wallId)?.push(placement)
this.itemToWall.set(itemId, wallId)
}
+2 -1
View File
@@ -11,6 +11,7 @@ export type {
LevelEvent,
NodeEvent,
RoofEvent,
RoofSegmentEvent,
SiteEvent,
SlabEvent,
WallEvent,
@@ -46,7 +47,7 @@ export {
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
export { default as useScene } from './store/use-scene'
export { clearSceneHistory, default as useScene } from './store/use-scene'
// Systems
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system'
+1 -1
View File
@@ -42,7 +42,7 @@ export function initSpaceDetectionSync(
if (!currentWallsByLevel.has(levelId)) {
currentWallsByLevel.set(levelId, new Set())
}
currentWallsByLevel.get(levelId)!.add((node as any).id)
currentWallsByLevel.get(levelId)?.add((node as any).id)
}
}
+1
View File
@@ -23,6 +23,7 @@ export type {
export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level'
export { RoofNode } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
@@ -0,0 +1,44 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'])
export type RoofType = z.infer<typeof RoofType>
export const RoofSegmentNode = BaseNode.extend({
id: objectId('rseg'),
type: nodeType('roof-segment'),
// Position relative to parent roof group
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Roof shape type
roofType: RoofType.default('gable'),
// Footprint dimensions
width: z.number().default(8),
depth: z.number().default(6),
// Vertical dimensions
wallHeight: z.number().default(0.5),
roofHeight: z.number().default(2.5),
// Structure thicknesses
wallThickness: z.number().default(0.1),
deckThickness: z.number().default(0.1),
overhang: z.number().default(0.3),
shingleThickness: z.number().default(0.05),
}).describe(
dedent`
Roof segment node - an individual roof module within a roof group.
Each segment generates a complete architectural volume (walls + roof).
Multiple segments can be combined to form complex roof shapes.
- roofType: hip, gable, shed, gambrel, dutch, mansard, flat
- width/depth: footprint dimensions
- wallHeight: height of walls below the roof
- roofHeight: height of the roof peak above the walls
- wallThickness/deckThickness: structural thicknesses
- overhang: eave overhang distance
- shingleThickness: outer shingle layer thickness
`,
)
export type RoofSegmentNode = z.infer<typeof RoofSegmentNode>
+9 -16
View File
@@ -1,32 +1,25 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { RoofSegmentNode } from './roof-segment'
export const RoofNode = BaseNode.extend({
id: objectId('roof'),
type: nodeType('roof'),
// Position of the roof center (Y should typically be 0)
// Position of the roof group center
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Length of the roof along the ridge direction (in meters)
length: z.number().default(4),
// Height of the roof peak from the base
height: z.number().default(1.5),
// Width of the left slope (in meters, measured horizontally from ridge)
leftWidth: z.number().default(1.5),
// Width of the right slope (in meters, measured horizontally from ridge)
rightWidth: z.number().default(1.5),
// Child roof segment IDs
children: z.array(RoofSegmentNode.shape.id).default([]),
}).describe(
dedent`
Roof node - used to represent a gable roof in the building
- position: center position of the roof (Y typically 0)
Roof node - a container for roof segments.
Acts as a group that holds one or more RoofSegmentNodes.
When not being edited, segments are visually combined into a single solid.
- position: center position of the roof group
- rotation: rotation around Y axis
- length: length of the roof along the ridge
- height: height of the roof peak
- leftWidth: horizontal width of the left slope (from ridge to eave)
- rightWidth: horizontal width of the right slope (from ridge to eave)
Total width = leftWidth + rightWidth
- children: array of RoofSegmentNode IDs
`,
)
+2
View File
@@ -6,6 +6,7 @@ import { GuideNode } from './nodes/guide'
import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
import { RoofNode } from './nodes/roof'
import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab'
@@ -23,6 +24,7 @@ export const AnyNode = z.discriminatedUnion('type', [
SlabNode,
CeilingNode,
RoofNode,
RoofSegmentNode,
ScanNode,
GuideNode,
WindowNode,
@@ -164,9 +164,15 @@ export const deleteNodesAction = (
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
})
// Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations)
const currentNodes = get().nodes
Object.values(currentNodes).forEach((node) => {
get().markDirty(node.id)
// Mark affected nodes dirty: parents of deleted nodes and their remaining children
// (e.g. deleting a slab affects sibling walls via level elevation changes)
parentsToMarkDirty.forEach((parentId) => {
get().markDirty(parentId)
const parent = get().nodes[parentId]
if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const childId of parent.children) {
get().markDirty(childId as AnyNodeId)
}
}
})
}
+247 -256
View File
@@ -3,16 +3,57 @@
import type { TemporalState } from 'zundo'
import { temporal } from 'zundo'
import { create, type StoreApi, type UseBoundStore } from 'zustand'
import { persist } from 'zustand/middleware'
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 type { AnyNode, AnyNodeId } from '../schema/types'
import { isObject } from '../utils/types'
import * as nodeActions from './actions/node-actions'
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],
}
}
}
return patchedNodes as Record<string, AnyNode>
}
export type SceneState = {
// 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>
@@ -29,6 +70,7 @@ export type SceneState = {
// Actions
loadScene: () => void
clearScene: () => void
unloadScene: () => void
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
markDirty: (id: AnyNodeId) => void
@@ -58,290 +100,217 @@ type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
}
const useScene: UseSceneStore = create<SceneState>()(
persist(
temporal(
(set, get) => ({
// 1. Flat dictionary of all nodes
nodes: {},
temporal(
(set, get) => ({
// 1. Flat dictionary of all nodes
nodes: {},
// 2. Root node IDs
rootNodeIds: [],
// 2. Root node IDs
rootNodeIds: [],
// 3. Dirty set
dirtyNodes: new Set<AnyNodeId>(),
// 3. Dirty set
dirtyNodes: new Set<AnyNodeId>(),
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
clearScene: () => {
set({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
get().loadScene() // Default scene
},
unloadScene: () => {
set({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
},
setScene: (nodes, rootNodeIds) => {
// Backward compat: add default scale to item nodes loaded from external sources
// (pascal_local_projects, Supabase) saved before scale was added to ItemNode
const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) {
if (node.type === 'item' && !('scale' in node)) {
patchedNodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode
}
}
set({
nodes: patchedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
})
// Mark all nodes as dirty to trigger re-validation
Object.values(patchedNodes).forEach((node) => {
clearScene: () => {
get().unloadScene()
get().loadScene() // Default scene
},
setScene: (nodes, rootNodeIds) => {
// Apply backward compatibility migrations
const patchedNodes = migrateNodes(nodes)
set({
nodes: patchedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
})
// Mark all nodes as dirty to trigger re-validation
Object.values(patchedNodes).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
}
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: [],
})
// Create hierarchy: Site → Building → Level
const level0 = LevelNode.parse({
level: 0,
children: [],
})
const building = BuildingNode.parse({
children: [level0.id],
})
const building = BuildingNode.parse({
children: [level0.id],
})
const site = SiteNode.parse({
children: [building],
})
const site = SiteNode.parse({
children: [building],
})
// Define all nodes flat
const nodes: Record<AnyNodeId, AnyNode> = {
[site.id]: site,
[building.id]: building,
[level0.id]: level0,
}
// 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]
// Site is the root
const rootNodeIds = [site.id]
set({ nodes, rootNodeIds })
},
set({ nodes, rootNodeIds })
},
markDirty: (id) => {
get().dirtyNodes.add(id)
},
markDirty: (id) => {
get().dirtyNodes.add(id)
},
clearDirty: (id) => {
get().dirtyNodes.delete(id)
},
clearDirty: (id) => {
get().dirtyNodes.delete(id)
},
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) =>
nodeActions.createNodesAction(set, get, [{ node, parentId }]),
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
// --- DELETE ---
// --- DELETE ---
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
// --- COLLECTIONS ---
// --- COLLECTIONS ---
createCollection: (name, nodeIds = []) => {
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) => {
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) => {
set((state) => {
const col = state.collections[id]
if (!col) return state
return { collections: { ...state.collections, [id]: { ...col, ...data } } }
})
},
addToCollection: (id, nodeId) => {
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 }
createCollection: (name, nodeIds = []) => {
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) ?? []
const nextNodes = {
...state.nodes,
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
}
return { collections: nextCollections, nodes: nextNodes }
})
},
removeFromCollection: (id, nodeId) => {
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
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
}
return { collections: nextCollections, nodes: nextNodes }
})
return id
},
),
deleteCollection: (id) => {
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) => {
set((state) => {
const col = state.collections[id]
if (!col) return state
return { collections: { ...state.collections, [id]: { ...col, ...data } } }
})
},
addToCollection: (id, nodeId) => {
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) => {
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 }
})
},
}),
{
name: 'editor-storage',
version: 1,
// Keep existing local scenes when the persist version changes.
migrate: (persistedState) =>
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>,
partialize: (state) => ({
nodes: Object.fromEntries(
Object.entries(state.nodes).filter(([_, node]) => {
const meta = node.metadata
const isTransient = isObject(meta) && 'isTransient' in meta && meta.isTransient === true
return !isTransient
}),
),
rootNodeIds: state.rootNodeIds,
collections: state.collections,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<SceneState>
// Backward compat: add default scale to item nodes saved before scale was added
if (persisted.nodes) {
for (const [id, node] of Object.entries(persisted.nodes)) {
if (node.type === 'item' && !('scale' in node)) {
persisted.nodes[id as AnyNodeId] = {
...(node as object),
scale: [1, 1, 1],
} as AnyNode
}
}
}
return { ...currentState, ...persisted }
},
onRehydrateStorage: (state) => {
console.log('hydrating...')
return (state, error) => {
if (error) {
console.log('an error happened during hydration', error)
return
}
if (!state) {
console.log('hydration finished - no state')
return
}
// Migration: Wrap old scenes (where root is not a SiteNode) in a SiteNode
const rootId = state.rootNodeIds?.[0]
const rootNode = rootId ? state.nodes[rootId] : null
if (rootNode && rootNode.type !== 'site') {
console.log('Migrating old scene: wrapping in SiteNode')
// Collect existing root nodes (should be BuildingNode or ItemNode)
const existingRoots = (state.rootNodeIds || [])
.map((id) => state.nodes[id])
.filter((node) => node?.type === 'building' || node?.type === 'item')
// Create a new SiteNode with existing roots as children
const site = SiteNode.parse({
children: existingRoots,
})
// Add site to nodes
state.nodes[site.id] = site
// Update root to be the site
state.rootNodeIds = [site.id]
console.log('Migration complete: scene now has SiteNode as root')
}
console.log('hydration finished')
}
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
// 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() {
useScene.temporal.getState().clear()
prevPastLength = 0
prevFutureLength = 0
prevNodesSnapshot = null
}
// Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state) => {
@@ -354,18 +323,40 @@ useScene.temporal.subscribe((state) => {
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
if (didUndo || didRedo) {
// Capture the previous snapshot before RAF fires
const snapshotBefore = prevNodesSnapshot
// Use RAF to ensure all middleware and store updates are complete
requestAnimationFrame(() => {
const currentNodes = useScene.getState().nodes
const { markDirty } = useScene.getState()
// Trigger a full scene re-validation after undo/redo
Object.values(currentNodes).forEach((node) => {
useScene.getState().markDirty(node.id)
})
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]) {
if (node.parentId) markDirty(node.parentId 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
// Update tracked lengths and snapshot
prevPastLength = currentPastLength
prevFutureLength = currentFutureLength
prevNodesSnapshot = useScene.getState().nodes
})
File diff suppressed because it is too large Load Diff
@@ -95,12 +95,12 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
if (!junctions.has(keyStart)) {
junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] })
}
junctions.get(keyStart)!.connectedWalls.push({ wall, endType: 'start' })
junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' })
if (!junctions.has(keyEnd)) {
junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] })
}
junctions.get(keyEnd)!.connectedWalls.push({ wall, endType: 'end' })
junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' })
}
// Second pass: detect T-junctions (walls passing through junction points)
@@ -46,7 +46,7 @@ export const WallSystem = () => {
if (!dirtyWallsByLevel.has(levelId)) {
dirtyWallsByLevel.set(levelId, new Set())
}
dirtyWallsByLevel.get(levelId)!.add(id)
dirtyWallsByLevel.get(levelId)?.add(id)
})
// Process each level that has dirty walls