ai-chatbot/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
geopenta ed6ed3f018
fix(editor): release scene singletons on Editor unmount (#214)
The spatial-grid manager, scene-store subscriptions, and the viewer
outliner's Object3D arrays are module-level singletons that previously
lived for the whole page lifetime. When an app unmounted the Editor
(e.g. navigating away from the 3D editor and back in an SPA host) they
retained references to the disposed Three.js scene graph, which caused
the app to freeze on re-entry as the stale data was replayed against a
fresh scene.

- initSpatialGridSync now returns the unsubscribe handle from its
  scene-store subscription so callers can detach it.
- initializeEditorRuntime becomes reversible: it wires up the spatial
  grid, space detection and SFX bus on mount and returns a teardown
  closure that detaches both store subscriptions, clears the spatial
  grid manager, and truncates the viewer outliner's selected/hovered
  arrays in place.
- The Editor component's runtime-init effect now returns that teardown,
  so the singletons are reset cleanly every time the Editor unmounts.
- initSFXBus is now idempotent via an internal guard so repeated
  Editor mounts do not stack duplicate mitt listeners.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 23:37:30 -04:00

163 lines
5.2 KiB
TypeScript

import {
type AnyNode,
type AnyNodeId,
getScaledDimensions,
type ItemNode,
type SlabNode,
type WallNode,
} from '../../schema'
import useScene from '../../store/use-scene'
import {
itemOverlapsPolygon,
spatialGridManager,
wallOverlapsPolygon,
} from './spatial-grid-manager'
export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
// If the node itself is a level
if (node.type === 'level') return node.id
// Walk up parent chain to find level
// This assumes you track parentId or can derive it
let current: AnyNode | undefined = node
while (current) {
if (current.type === 'level') return current.id
// Find parent (you might need to add parentId to your schema or derive it)
if (current.parentId) {
current = nodes[current.parentId]
} else {
current = undefined
}
}
return 'default' // fallback for orphaned items
}
// Call this once at app initialization. Returns an unsubscribe function that
// detaches the scene-store listener (useful when the editor is unmounted so
// the spatial grid singleton does not hold stale references to old scenes).
export function initSpatialGridSync(): () => void {
const store = useScene
// 1. Initial sync - process all existing nodes
const state = store.getState()
for (const node of Object.values(state.nodes)) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeCreated(node, levelId)
}
// 2. Then subscribe to future changes
const markDirty = (id: AnyNodeId) => store.getState().markDirty(id)
// Subscribe to all changes
const unsubscribe = store.subscribe((state, prevState) => {
// Detect added nodes
for (const [id, node] of Object.entries(state.nodes)) {
if (!prevState.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeCreated(node, levelId)
// When a slab is added, mark overlapping items/walls dirty
if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
}
}
}
// Detect removed nodes
for (const [id, node] of Object.entries(prevState.nodes)) {
if (!state.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, prevState.nodes)
spatialGridManager.handleNodeDeleted(id, node.type, levelId)
// When a slab is removed, mark items/walls that were on it dirty (using current state)
if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
}
}
}
// Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes)
for (const [id, node] of Object.entries(state.nodes)) {
const prev = prevState.nodes[id as AnyNode['id']]
if (!prev) continue
if (node.type === 'item' && prev.type === 'item') {
if (
!(
arraysEqual(node.position, prev.position) &&
arraysEqual(node.rotation, prev.rotation) &&
arraysEqual(node.scale, prev.scale)
) ||
node.parentId !== prev.parentId ||
node.side !== prev.side
) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
// Scale changes affect footprint size — mark dirty so slab elevation recalculates
if (!arraysEqual(node.scale, prev.scale)) {
markDirty(node.id)
}
}
} else if (node.type === 'slab' && prev.type === 'slab') {
if (
node.polygon !== prev.polygon ||
node.elevation !== prev.elevation ||
node.holes !== prev.holes
) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
// Mark nodes overlapping old polygon and new polygon as dirty
markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty)
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
}
}
}
})
return unsubscribe
}
function arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i])
}
/**
* Mark all floor items and walls that overlap a slab polygon as dirty.
*/
function markNodesOverlappingSlab(
slab: SlabNode,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
if (slab.polygon.length < 3) return
const slabLevelId = resolveLevelId(slab, nodes)
for (const node of Object.values(nodes)) {
if (node.type === 'item') {
const item = node as ItemNode
// Only floor items are affected by slabs
if (item.asset.attachTo) continue
if (resolveLevelId(node, nodes) !== slabLevelId) continue
if (
itemOverlapsPolygon(
item.position,
getScaledDimensions(item),
item.rotation,
slab.polygon,
0.01,
)
) {
markDirty(node.id)
}
} else if (node.type === 'wall') {
const wall = node as WallNode
if (resolveLevelId(node, nodes) !== slabLevelId) continue
if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) {
markDirty(node.id)
}
}
}
}