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:
co-authored by
Claude Opus 4.6
parent
bafc5973a3
commit
1d28e4208f
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user