feat(mcp): add headless scene bridge with RAF polyfill
SceneBridge class wraps @pascal-app/core's Zustand store for Node, exposing a clean programmatic API for scene load/mutate/export plus Zundo undo/redo. Requires a requestAnimationFrame polyfill loaded before any core import to work around the store's RAF-batched dirty marking. - 51 tests, 99.68% line coverage on scene-bridge.ts - All-or-nothing applyPatch with Zod dry-run validation - Safeguards against prototype-polluting keys in loadJSON - Resolves children through the flat nodes dict (handles the SiteNode.children-as-objects inconsistency) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
eebbef502d
commit
07ed429d58
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Node-compatibility shims for `@pascal-app/core`.
|
||||
*
|
||||
* The core store uses `requestAnimationFrame` inside `updateNodesAction` (to batch
|
||||
* dirty-marking) and inside the temporal undo/redo subscribe callback. Both are
|
||||
* load-reachable — the subscribe callback registers at module import time.
|
||||
*
|
||||
* This file installs a no-op-if-already-defined polyfill that works both in
|
||||
* Node and in the browser. It MUST be imported FIRST from any module that
|
||||
* transitively loads `@pascal-app/core/store`, otherwise the core module will
|
||||
* throw at import time.
|
||||
*
|
||||
* Side-effectful on import: there is no exported API — just import this file.
|
||||
*/
|
||||
|
||||
type RafCallback = (timestamp: number) => void
|
||||
|
||||
type GlobalWithRaf = typeof globalThis & {
|
||||
requestAnimationFrame?: (cb: RafCallback) => number
|
||||
cancelAnimationFrame?: (id: number) => void
|
||||
}
|
||||
|
||||
const g = globalThis as GlobalWithRaf
|
||||
|
||||
if (typeof g.requestAnimationFrame === 'undefined') {
|
||||
g.requestAnimationFrame = (cb: RafCallback): number => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
return setTimeout(() => cb(now), 0) as unknown as number
|
||||
}
|
||||
g.cancelAnimationFrame = (id: number) => {
|
||||
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
BuildingNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
SiteNode,
|
||||
WallNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core/schema'
|
||||
import { SceneBridge } from './scene-bridge'
|
||||
|
||||
function tick() {
|
||||
return new Promise((r) => setTimeout(r, 5))
|
||||
}
|
||||
|
||||
describe('SceneBridge', () => {
|
||||
let bridge: SceneBridge
|
||||
|
||||
beforeEach(() => {
|
||||
bridge = new SceneBridge()
|
||||
// Ensure a clean slate even if a prior test left store state around
|
||||
// (the core store is a module-singleton).
|
||||
bridge.setScene({}, [])
|
||||
bridge.clearHistory()
|
||||
bridge.loadDefault()
|
||||
bridge.clearHistory()
|
||||
bridge.flushDirty()
|
||||
})
|
||||
|
||||
describe('loadDefault / getters', () => {
|
||||
test('creates default Site → Building → Level', () => {
|
||||
const nodes = bridge.getNodes()
|
||||
const types = Object.values(nodes)
|
||||
.map((n) => n.type)
|
||||
.sort()
|
||||
expect(types).toEqual(['building', 'level', 'site'])
|
||||
expect(bridge.getRootNodeIds().length).toBe(1)
|
||||
})
|
||||
|
||||
test('loadDefault is idempotent when scene already loaded', () => {
|
||||
const before = Object.keys(bridge.getNodes()).length
|
||||
bridge.loadDefault()
|
||||
const after = Object.keys(bridge.getNodes()).length
|
||||
expect(after).toBe(before)
|
||||
})
|
||||
|
||||
test('getNode returns the node by id', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const fetched = bridge.getNode(level.id)
|
||||
expect(fetched?.id).toBe(level.id)
|
||||
})
|
||||
|
||||
test('getNode returns null for unknown id', () => {
|
||||
expect(bridge.getNode('wall_does_not_exist')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNode', () => {
|
||||
test('creates a wall attached to a level', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
|
||||
const id = bridge.createNode(wall, level.id)
|
||||
expect(id).toBe(wall.id)
|
||||
expect(bridge.getNode(wall.id)).not.toBeNull()
|
||||
// Level should list the wall as a child.
|
||||
const freshLevel = bridge.getNode(level.id) as any
|
||||
expect(freshLevel.children).toContain(wall.id)
|
||||
})
|
||||
|
||||
test('created wall has the correct parentId', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
const w = bridge.getNode(wall.id)!
|
||||
expect(w.parentId).toBe(level.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateNode', () => {
|
||||
test('merges new fields on existing node', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
bridge.updateNode(wall.id, { thickness: 0.25, height: 3 } as any)
|
||||
await tick()
|
||||
const w = bridge.getNode(wall.id) as any
|
||||
expect(w.thickness).toBe(0.25)
|
||||
expect(w.height).toBe(3)
|
||||
})
|
||||
|
||||
test('throws on unknown id', () => {
|
||||
expect(() => bridge.updateNode('wall_missing' as any, { height: 3 } as any)).toThrow(
|
||||
/node not found/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNode', () => {
|
||||
test('deletes a leaf node', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
const removed = bridge.deleteNode(wall.id)
|
||||
expect(removed).toContain(wall.id)
|
||||
expect(bridge.getNode(wall.id)).toBeNull()
|
||||
})
|
||||
|
||||
test('cascade=false throws if node has children', () => {
|
||||
// Level (with a child wall) — deleting non-cascaded must throw.
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
expect(() => bridge.deleteNode(level.id, false)).toThrow(/descendant/)
|
||||
// Node still exists.
|
||||
expect(bridge.getNode(level.id)).not.toBeNull()
|
||||
expect(bridge.getNode(wall.id)).not.toBeNull()
|
||||
})
|
||||
|
||||
test('cascade=true removes node and all descendants', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall1 = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
const wall2 = WallNode.parse({ start: [1, 0], end: [1, 1] })
|
||||
bridge.createNode(wall1, level.id)
|
||||
bridge.createNode(wall2, level.id)
|
||||
const removed = bridge.deleteNode(level.id, true)
|
||||
expect(removed).toContain(level.id)
|
||||
expect(removed).toContain(wall1.id)
|
||||
expect(removed).toContain(wall2.id)
|
||||
expect(bridge.getNode(level.id)).toBeNull()
|
||||
expect(bridge.getNode(wall1.id)).toBeNull()
|
||||
})
|
||||
|
||||
test('throws on unknown id', () => {
|
||||
expect(() => bridge.deleteNode('wall_nope' as any, false)).toThrow(/node not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo / redo', () => {
|
||||
test('round-trips create + update', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
await tick()
|
||||
bridge.updateNode(wall.id, { thickness: 0.25 } as any)
|
||||
await tick()
|
||||
|
||||
// Undo update
|
||||
const u1 = bridge.undo()
|
||||
await tick()
|
||||
expect(u1).toBe(1)
|
||||
const w1 = bridge.getNode(wall.id) as any
|
||||
expect(w1).not.toBeNull()
|
||||
expect(w1.thickness).not.toBe(0.25)
|
||||
|
||||
// Undo create — wall should be gone
|
||||
const u2 = bridge.undo()
|
||||
await tick()
|
||||
expect(u2).toBe(1)
|
||||
expect(bridge.getNode(wall.id)).toBeNull()
|
||||
|
||||
// Redo both
|
||||
const r = bridge.redo(2)
|
||||
await tick()
|
||||
expect(r).toBe(2)
|
||||
const w3 = bridge.getNode(wall.id) as any
|
||||
expect(w3).not.toBeNull()
|
||||
expect(w3.thickness).toBe(0.25)
|
||||
})
|
||||
|
||||
test('getHistory tracks pointers', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
await tick()
|
||||
let h = bridge.getHistory()
|
||||
expect(h.pastCount).toBe(1)
|
||||
expect(h.futureCount).toBe(0)
|
||||
bridge.undo()
|
||||
await tick()
|
||||
h = bridge.getHistory()
|
||||
expect(h.pastCount).toBe(0)
|
||||
expect(h.futureCount).toBe(1)
|
||||
})
|
||||
|
||||
test('clearHistory wipes past/future', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id)
|
||||
await tick()
|
||||
bridge.clearHistory()
|
||||
const h = bridge.getHistory()
|
||||
expect(h.pastCount).toBe(0)
|
||||
expect(h.futureCount).toBe(0)
|
||||
})
|
||||
|
||||
test('undo/redo without history returns 0', () => {
|
||||
bridge.clearHistory()
|
||||
expect(bridge.undo()).toBe(0)
|
||||
expect(bridge.redo()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPatch', () => {
|
||||
test('applies mixed create/update/delete atomically', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wallA = WallNode.parse({ start: [0, 0], end: [2, 0] })
|
||||
const wallB = WallNode.parse({ start: [2, 0], end: [2, 2] })
|
||||
// pre-seed one wall, then exercise update + delete
|
||||
bridge.createNode(wallA, level.id)
|
||||
await tick()
|
||||
|
||||
const res = bridge.applyPatch([
|
||||
{ op: 'create', node: wallB, parentId: level.id },
|
||||
{ op: 'update', id: wallA.id, data: { thickness: 0.3 } as any },
|
||||
{ op: 'delete', id: wallA.id },
|
||||
])
|
||||
await tick()
|
||||
|
||||
expect(res.appliedOps).toBe(3)
|
||||
expect(res.createdIds).toContain(wallB.id)
|
||||
expect(res.deletedIds).toContain(wallA.id)
|
||||
expect(bridge.getNode(wallA.id)).toBeNull()
|
||||
expect(bridge.getNode(wallB.id)).not.toBeNull()
|
||||
})
|
||||
|
||||
test('is all-or-nothing: invalid op rolls back no changes', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const pre = Object.keys(bridge.getNodes()).length
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
expect(() =>
|
||||
bridge.applyPatch([
|
||||
{ op: 'create', node: wall, parentId: level.id },
|
||||
// This op is invalid — id does not exist.
|
||||
{ op: 'update', id: 'wall_missing' as any, data: { thickness: 0.1 } as any },
|
||||
]),
|
||||
).toThrow(/invalid patch/)
|
||||
// The wall must NOT have been created.
|
||||
expect(bridge.getNode(wall.id)).toBeNull()
|
||||
// Node count is unchanged.
|
||||
expect(Object.keys(bridge.getNodes()).length).toBe(pre)
|
||||
})
|
||||
|
||||
test('rejects create with non-existent parentId', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
expect(() =>
|
||||
bridge.applyPatch([{ op: 'create', node: wall, parentId: 'level_nope' as any }]),
|
||||
).toThrow(/invalid patch/)
|
||||
})
|
||||
|
||||
test('rejects delete of unknown id', () => {
|
||||
expect(() => bridge.applyPatch([{ op: 'delete', id: 'wall_nope' as any }])).toThrow(
|
||||
/invalid patch/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects delete with cascade=false on a node with children', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id)
|
||||
await tick()
|
||||
expect(() => bridge.applyPatch([{ op: 'delete', id: level.id, cascade: false }])).toThrow(
|
||||
/invalid patch/,
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts delete with cascade=true on a node with children', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
await tick()
|
||||
const res = bridge.applyPatch([{ op: 'delete', id: level.id, cascade: true }])
|
||||
expect(res.deletedIds).toContain(level.id)
|
||||
expect(res.deletedIds).toContain(wall.id)
|
||||
})
|
||||
|
||||
test('rejects create with schema-invalid node', () => {
|
||||
// Bypass .parse so we can feed an invalid node through the union.
|
||||
const bogus = {
|
||||
object: 'node',
|
||||
id: 'wall_bogus',
|
||||
type: 'wall',
|
||||
// missing start/end
|
||||
} as any
|
||||
expect(() => bridge.applyPatch([{ op: 'create', node: bogus }])).toThrow(/invalid patch/)
|
||||
})
|
||||
|
||||
test('rejects unknown op', () => {
|
||||
expect(() => bridge.applyPatch([{ op: 'wat', id: 'x' } as any])).toThrow(/invalid patch/)
|
||||
})
|
||||
|
||||
test('rejects update with non-object data', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
expect(() => bridge.applyPatch([{ op: 'update', id: level.id, data: null as any }])).toThrow(
|
||||
/invalid patch/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects undefined patch entry', () => {
|
||||
expect(() => bridge.applyPatch([undefined as any])).toThrow(/invalid patch/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateScene', () => {
|
||||
test('returns valid for default scene', () => {
|
||||
const res = bridge.validateScene()
|
||||
expect(res.valid).toBe(true)
|
||||
expect(res.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('flags bad nodes fed in via setScene', () => {
|
||||
const site = SiteNode.parse({})
|
||||
// Bypass the schema by constructing a bogus wall object directly.
|
||||
const bogus = {
|
||||
object: 'node',
|
||||
id: 'wall_bogus',
|
||||
type: 'wall',
|
||||
parentId: site.id,
|
||||
// missing required `start`/`end`
|
||||
} as any
|
||||
bridge.setScene({ [site.id]: site, [bogus.id]: bogus }, [site.id])
|
||||
const res = bridge.validateScene()
|
||||
expect(res.valid).toBe(false)
|
||||
expect(res.errors.some((e) => e.nodeId === 'wall_bogus')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traversal: site quirk & generic helpers', () => {
|
||||
test('getChildren uses the flat dict (handles site children-as-objects)', () => {
|
||||
const site = bridge.findNodes({ type: 'site' })[0]!
|
||||
const children = bridge.getChildren(site.id)
|
||||
// Building is the expected child of site via parentId.
|
||||
const types = children.map((c) => c.type).sort()
|
||||
expect(types).toContain('building')
|
||||
})
|
||||
|
||||
test('getChildren works for level (children-as-ids)', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
const children = bridge.getChildren(level.id)
|
||||
expect(children.map((c) => c.id)).toContain(wall.id)
|
||||
})
|
||||
|
||||
test('getAncestry walks to root', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
const ancestry = bridge.getAncestry(wall.id)
|
||||
const types = ancestry.map((n) => n.type)
|
||||
expect(types[0]).toBe('wall')
|
||||
expect(types).toContain('level')
|
||||
expect(types).toContain('building')
|
||||
expect(types).toContain('site')
|
||||
})
|
||||
|
||||
test('getAncestry returns [] for unknown id', () => {
|
||||
expect(bridge.getAncestry('wall_nope' as any)).toEqual([])
|
||||
})
|
||||
|
||||
test('resolveLevelId returns the enclosing level', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
expect(bridge.resolveLevelId(wall.id)).toBe(level.id)
|
||||
})
|
||||
|
||||
test('resolveLevelId returns null if no level ancestor', () => {
|
||||
// Site itself has no level ancestor.
|
||||
const site = bridge.findNodes({ type: 'site' })[0]!
|
||||
expect(bridge.resolveLevelId(site.id)).toBeNull()
|
||||
})
|
||||
|
||||
test('findNodes filters by type', () => {
|
||||
const levels = bridge.findNodes({ type: 'level' })
|
||||
expect(levels.length).toBe(1)
|
||||
expect(levels[0]?.type).toBe('level')
|
||||
})
|
||||
|
||||
test('findNodes filters by parentId', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
const childrenOfLevel = bridge.findNodes({ parentId: level.id })
|
||||
expect(childrenOfLevel.map((n) => n.id)).toContain(wall.id)
|
||||
})
|
||||
|
||||
test('findNodes filters by levelId (via ancestry)', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
const door = DoorNode.parse({ wallId: wall.id })
|
||||
bridge.createNode(door, wall.id)
|
||||
// Door lives under wall→level; findNodes with levelId should match.
|
||||
const filtered = bridge.findNodes({ type: 'door', levelId: level.id })
|
||||
expect(filtered.map((n) => n.id)).toContain(door.id)
|
||||
})
|
||||
|
||||
test('findNodes with parentId: null finds roots', () => {
|
||||
const roots = bridge.findNodes({ parentId: null })
|
||||
expect(roots.map((n) => n.type)).toContain('site')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setScene / exportJSON / loadJSON', () => {
|
||||
test('exportJSON returns the scene shape', () => {
|
||||
const exp = bridge.exportJSON()
|
||||
expect(typeof exp.nodes).toBe('object')
|
||||
expect(Array.isArray(exp.rootNodeIds)).toBe(true)
|
||||
expect(exp.rootNodeIds.length).toBe(1)
|
||||
})
|
||||
|
||||
test('exportJSON deep-clones (mutation does not leak back)', () => {
|
||||
const exp = bridge.exportJSON()
|
||||
const someId = Object.keys(exp.nodes)[0]!
|
||||
;(exp.nodes as any)[someId] = 'tampered'
|
||||
// Store is unchanged.
|
||||
expect(typeof bridge.getNodes()[someId]).toBe('object')
|
||||
})
|
||||
|
||||
test('loadJSON accepts a parsed object', () => {
|
||||
const snap = bridge.exportJSON()
|
||||
// Unload first so loadJSON does the heavy lift.
|
||||
bridge.setScene({}, [])
|
||||
bridge.loadJSON(snap)
|
||||
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
|
||||
})
|
||||
|
||||
test('loadJSON accepts a JSON string', () => {
|
||||
const snap = bridge.exportJSON()
|
||||
const str = JSON.stringify(snap)
|
||||
bridge.setScene({}, [])
|
||||
bridge.loadJSON(str)
|
||||
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
|
||||
})
|
||||
|
||||
test('loadJSON throws on malformed JSON string', () => {
|
||||
expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/)
|
||||
})
|
||||
|
||||
test('loadJSON throws when parsed JSON is not an object', () => {
|
||||
expect(() => bridge.loadJSON('null')).toThrow(/expected object/)
|
||||
expect(() => bridge.loadJSON(null as any)).toThrow(/expected object/)
|
||||
})
|
||||
|
||||
test('loadJSON throws on wrong top-level shape', () => {
|
||||
expect(() => bridge.loadJSON({} as any)).toThrow(/invalid scene/)
|
||||
expect(() => bridge.loadJSON({ nodes: 1, rootNodeIds: [] } as any)).toThrow(/invalid scene/)
|
||||
expect(() => bridge.loadJSON({ nodes: {}, rootNodeIds: 'nope' } as any)).toThrow(
|
||||
/invalid scene/,
|
||||
)
|
||||
})
|
||||
|
||||
test('loadJSON rejects prototype-polluting keys in string form', () => {
|
||||
const bad = '{"nodes": {"__proto__": {"polluted": true}}, "rootNodeIds": []}'
|
||||
expect(() => bridge.loadJSON(bad)).toThrow(/forbidden key/)
|
||||
})
|
||||
|
||||
test('loadJSON rejects prototype-polluting keys in object form', () => {
|
||||
// Build object so the key is an actual own-property (not a prototype
|
||||
// assignment).
|
||||
const nodes: Record<string, unknown> = {}
|
||||
Object.defineProperty(nodes, '__proto__', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { polluted: true },
|
||||
})
|
||||
const bad = { nodes, rootNodeIds: [] }
|
||||
expect(() => bridge.loadJSON(bad as any)).toThrow(/forbidden key/)
|
||||
})
|
||||
|
||||
test('setScene round-trip preserves node count', () => {
|
||||
const pre = Object.keys(bridge.getNodes()).length
|
||||
const snap = bridge.exportJSON()
|
||||
bridge.setScene({}, [])
|
||||
bridge.setScene(snap.nodes as any, snap.rootNodeIds as any)
|
||||
expect(Object.keys(bridge.getNodes()).length).toBe(pre)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushDirty', () => {
|
||||
test('drains the dirty set', async () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
|
||||
bridge.createNode(wall, level.id)
|
||||
await tick()
|
||||
const drained = bridge.flushDirty()
|
||||
// Wall was just created, should have dirty-marked itself + parent.
|
||||
expect(drained.length).toBeGreaterThan(0)
|
||||
// Calling again drains nothing new.
|
||||
const again = bridge.flushDirty()
|
||||
expect(again.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('composite nodes', () => {
|
||||
test('can build a small scene via LevelNode/BuildingNode helpers', () => {
|
||||
// Construct a second site via explicit schema parse to exercise
|
||||
// exportJSON/setScene on custom shapes.
|
||||
const level = LevelNode.parse({ level: 0, children: [] })
|
||||
const building = BuildingNode.parse({ children: [level.id] })
|
||||
const site = SiteNode.parse({ children: [] })
|
||||
bridge.setScene(
|
||||
{
|
||||
[site.id]: { ...site, children: [] } as any,
|
||||
[building.id]: { ...building, parentId: site.id } as any,
|
||||
[level.id]: { ...level, parentId: building.id } as any,
|
||||
},
|
||||
[site.id],
|
||||
)
|
||||
expect(bridge.getNodes()[site.id]).toBeDefined()
|
||||
expect(bridge.resolveLevelId(level.id)).toBe(level.id)
|
||||
})
|
||||
|
||||
test('zone and item nodes are creatable and discoverable', () => {
|
||||
const level = bridge.findNodes({ type: 'level' })[0]!
|
||||
const zone = ZoneNode.parse({
|
||||
name: 'Zone A',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
],
|
||||
})
|
||||
bridge.createNode(zone, level.id)
|
||||
|
||||
const item = ItemNode.parse({
|
||||
asset: {
|
||||
id: 'asset_test',
|
||||
category: 'test',
|
||||
name: 'Test Asset',
|
||||
thumbnail: 'data:image/png;base64,',
|
||||
src: 'data:model/gltf-binary;base64,',
|
||||
},
|
||||
})
|
||||
// Place item directly on level — ItemNode supports arbitrary parents in the model.
|
||||
bridge.createNode(item, level.id)
|
||||
|
||||
const zones = bridge.findNodes({ type: 'zone' })
|
||||
const items = bridge.findNodes({ type: 'item' })
|
||||
expect(zones.map((n) => n.id)).toContain(zone.id)
|
||||
expect(items.map((n) => n.id)).toContain(item.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,500 @@
|
||||
// Side-effect import MUST come first: installs RAF polyfill before core loads.
|
||||
import './node-shims'
|
||||
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import type { AnyNode } from '@pascal-app/core/schema'
|
||||
import { type AnyNodeId, AnyNode as AnyNodeSchema, type AnyNodeType } from '@pascal-app/core/schema'
|
||||
// Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`.
|
||||
import useScene from '@pascal-app/core/store'
|
||||
|
||||
export type ValidationError = { nodeId: string; path: string; message: string }
|
||||
export type ValidationResult = { valid: boolean; errors: ValidationError[] }
|
||||
|
||||
export type CreatePatch = { op: 'create'; node: AnyNode; parentId?: AnyNodeId }
|
||||
export type UpdatePatch = { op: 'update'; id: AnyNodeId; data: Partial<AnyNode> }
|
||||
export type DeletePatch = { op: 'delete'; id: AnyNodeId; cascade?: boolean }
|
||||
export type Patch = CreatePatch | UpdatePatch | DeletePatch
|
||||
|
||||
/**
|
||||
* Headless bridge to the `@pascal-app/core` Zustand store.
|
||||
*
|
||||
* All mutation flows through the real core store so undo/redo works via Zundo.
|
||||
* No renderer is attached; `dirtyNodes` accumulates and can be drained via
|
||||
* `flushDirty()` for observability.
|
||||
*/
|
||||
export class SceneBridge {
|
||||
/** Load initial state; if empty, creates default Site → Building → Level. */
|
||||
loadDefault(): void {
|
||||
useScene.getState().loadScene()
|
||||
}
|
||||
|
||||
/** Replace entire scene (undoable via Zundo). */
|
||||
setScene(nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void {
|
||||
useScene.getState().setScene(nodes, rootNodeIds)
|
||||
}
|
||||
|
||||
/** Full snapshot for export, including collections. */
|
||||
exportJSON(): SceneGraph & { collections: Record<string, unknown> } {
|
||||
const state = useScene.getState()
|
||||
// Deep-clone so callers can't mutate store state directly.
|
||||
return JSON.parse(
|
||||
JSON.stringify({
|
||||
nodes: state.nodes,
|
||||
rootNodeIds: state.rootNodeIds,
|
||||
collections: state.collections ?? {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import. Accepts either a JSON string or a parsed SceneGraph object.
|
||||
* Throws on invalid JSON, unexpected shape, or prototype-polluting keys.
|
||||
*/
|
||||
loadJSON(json: string | SceneGraph): void {
|
||||
let parsed: unknown
|
||||
if (typeof json === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(json)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`invalid JSON: ${msg}`)
|
||||
}
|
||||
} else {
|
||||
parsed = json
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('invalid scene: expected object with {nodes, rootNodeIds}')
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const nodes = obj.nodes
|
||||
const rootNodeIds = obj.rootNodeIds
|
||||
|
||||
if (!nodes || typeof nodes !== 'object' || Array.isArray(nodes)) {
|
||||
throw new Error('invalid scene: `nodes` must be an object')
|
||||
}
|
||||
if (!Array.isArray(rootNodeIds)) {
|
||||
throw new Error('invalid scene: `rootNodeIds` must be an array')
|
||||
}
|
||||
|
||||
// Reject prototype-polluting keys as top-level `nodes` keys.
|
||||
const BANNED = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
for (const key of Object.keys(nodes)) {
|
||||
if (BANNED.has(key)) {
|
||||
throw new Error(`invalid scene: forbidden key "${key}" in nodes`)
|
||||
}
|
||||
}
|
||||
|
||||
this.setScene(nodes as Record<AnyNodeId, AnyNode>, rootNodeIds as AnyNodeId[])
|
||||
}
|
||||
|
||||
/** Read a single node, or `null` if not present. */
|
||||
getNode(id: AnyNodeId): AnyNode | null {
|
||||
const node = useScene.getState().nodes[id]
|
||||
return node ?? null
|
||||
}
|
||||
|
||||
/** All nodes (live reference into the store — do NOT mutate). */
|
||||
getNodes(): Record<AnyNodeId, AnyNode> {
|
||||
return useScene.getState().nodes
|
||||
}
|
||||
|
||||
/** Root node IDs. */
|
||||
getRootNodeIds(): AnyNodeId[] {
|
||||
return useScene.getState().rootNodeIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve children via the flat `nodes` dict. Uses THREE fallbacks because
|
||||
* the codebase's parent-tracking is not uniform:
|
||||
*
|
||||
* 1. `node.parentId === parentId` (normal case post-store-mutation).
|
||||
* 2. Parent has `children: string[]` of IDs (building, level, wall, ...).
|
||||
* 3. Parent has `children: Array<node-object>` (the SiteNode quirk — see
|
||||
* PLAN §0.7). We resolve each object to its flat-dict entry by `id`.
|
||||
*
|
||||
* The `loadScene()` default assembler skips the store mutation paths so the
|
||||
* default site/building/level tree has `parentId === null` on every node —
|
||||
* only the `children` arrays reflect the hierarchy.
|
||||
*
|
||||
* Results are de-duplicated by id, in flat-dict iteration order.
|
||||
*/
|
||||
getChildren(parentId: AnyNodeId): AnyNode[] {
|
||||
const nodes = useScene.getState().nodes
|
||||
const out: AnyNode[] = []
|
||||
const seen = new Set<AnyNodeId>()
|
||||
|
||||
// Strategy 1: parentId scan.
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.parentId === parentId && !seen.has(node.id as AnyNodeId)) {
|
||||
seen.add(node.id as AnyNodeId)
|
||||
out.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategies 2 & 3: parent's own `children` field.
|
||||
const parent = nodes[parentId]
|
||||
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
||||
for (const child of parent.children as unknown[]) {
|
||||
let childId: string | null = null
|
||||
if (typeof child === 'string') childId = child
|
||||
else if (
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
'id' in (child as Record<string, unknown>) &&
|
||||
typeof (child as { id: unknown }).id === 'string'
|
||||
) {
|
||||
childId = (child as { id: string }).id
|
||||
}
|
||||
if (!childId) continue
|
||||
const childNode = nodes[childId as AnyNodeId]
|
||||
if (!childNode) continue
|
||||
if (seen.has(childNode.id as AnyNodeId)) continue
|
||||
seen.add(childNode.id as AnyNodeId)
|
||||
out.push(childNode)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up `parentId` chain; returns `[self, parent, grandparent, ...]`.
|
||||
*
|
||||
* Falls back to reverse-scanning `children` arrays when `parentId` is
|
||||
* unset (see the default-scene quirk documented on `getChildren`).
|
||||
*/
|
||||
getAncestry(id: AnyNodeId): AnyNode[] {
|
||||
const nodes = useScene.getState().nodes
|
||||
const out: AnyNode[] = []
|
||||
let current: AnyNode | undefined = nodes[id]
|
||||
const seen = new Set<AnyNodeId>()
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
out.push(current)
|
||||
const pid = current.parentId as AnyNodeId | null | undefined
|
||||
if (pid && nodes[pid]) {
|
||||
current = nodes[pid]
|
||||
continue
|
||||
}
|
||||
// Fallback: scan for any node whose `children` includes this id.
|
||||
const fallback = this._findParentByChildrenScan(current.id as AnyNodeId)
|
||||
if (!fallback) break
|
||||
current = fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Find all nodes matching the given filters (all filters ANDed). */
|
||||
findNodes(filter: {
|
||||
type?: AnyNodeType
|
||||
parentId?: AnyNodeId | null
|
||||
levelId?: AnyNodeId
|
||||
}): AnyNode[] {
|
||||
const nodes = useScene.getState().nodes
|
||||
const out: AnyNode[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (filter.type !== undefined && node.type !== filter.type) continue
|
||||
if (filter.parentId !== undefined) {
|
||||
const np = (node.parentId ?? null) as AnyNodeId | null
|
||||
if (np !== filter.parentId) continue
|
||||
}
|
||||
if (filter.levelId !== undefined) {
|
||||
if (this.resolveLevelId(node.id as AnyNodeId) !== filter.levelId) continue
|
||||
}
|
||||
out.push(node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Resolve the level-ancestor of a node, or `null` if none in the chain. */
|
||||
resolveLevelId(id: AnyNodeId): AnyNodeId | null {
|
||||
const ancestry = this.getAncestry(id)
|
||||
for (const node of ancestry) {
|
||||
if (node.type === 'level') return node.id as AnyNodeId
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a node. Caller must pass an already-parsed `AnyNode` (with a valid
|
||||
* `id`, generated by the schema default if they did `XxxNode.parse({...})`).
|
||||
* Returns the generated id.
|
||||
*/
|
||||
createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId {
|
||||
useScene.getState().createNode(node, parentId)
|
||||
return node.id as AnyNodeId
|
||||
}
|
||||
|
||||
/** Update node fields (shallow merge through the core store). */
|
||||
updateNode(id: AnyNodeId, data: Partial<AnyNode>): void {
|
||||
if (!useScene.getState().nodes[id]) {
|
||||
throw new Error(`node not found: ${id}`)
|
||||
}
|
||||
useScene.getState().updateNode(id, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node. If the node has children and `cascade === false`, throws.
|
||||
* If `cascade` is true (or undefined and no children), delegates to the core
|
||||
* action which already recursively removes descendants.
|
||||
*
|
||||
* Returns the list of ids actually removed from the scene.
|
||||
*/
|
||||
deleteNode(id: AnyNodeId, cascade = false): string[] {
|
||||
const state = useScene.getState()
|
||||
const node = state.nodes[id]
|
||||
if (!node) {
|
||||
throw new Error(`node not found: ${id}`)
|
||||
}
|
||||
|
||||
const descendants = this._collectDescendants(id)
|
||||
if (!cascade && descendants.length > 1) {
|
||||
throw new Error(
|
||||
`node has ${descendants.length - 1} descendant(s); pass cascade: true to delete recursively`,
|
||||
)
|
||||
}
|
||||
|
||||
const before = new Set(Object.keys(state.nodes))
|
||||
useScene.getState().deleteNode(id)
|
||||
const afterNodes = useScene.getState().nodes
|
||||
const removed: string[] = []
|
||||
for (const prevId of before) {
|
||||
if (!(prevId in afterNodes)) removed.push(prevId)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic multi-op patch. Validates EVERY patch first (dry run); only if all
|
||||
* pass does it apply in a single batch via `createNodes` / `updateNodes` /
|
||||
* `deleteNodes`. Throws on any validation failure without mutating state.
|
||||
*/
|
||||
applyPatch(patches: Patch[]): {
|
||||
appliedOps: number
|
||||
deletedIds: AnyNodeId[]
|
||||
createdIds: AnyNodeId[]
|
||||
} {
|
||||
const state = useScene.getState()
|
||||
const nodes = state.nodes
|
||||
|
||||
// Track synthesized state as we dry-run so later ops can reference
|
||||
// earlier-created ids and reflect earlier-deleted ids.
|
||||
const simAvailable = new Set<string>(Object.keys(nodes))
|
||||
const simDeleted = new Set<string>()
|
||||
|
||||
for (let i = 0; i < patches.length; i++) {
|
||||
const p = patches[i]
|
||||
if (!p) throw new Error(`invalid patch: patches[${i}] is undefined`)
|
||||
if (p.op === 'create') {
|
||||
const res = AnyNodeSchema.safeParse(p.node)
|
||||
if (!res.success) {
|
||||
throw new Error(
|
||||
`invalid patch: patches[${i}] create node failed schema: ${res.error.message}`,
|
||||
)
|
||||
}
|
||||
if (p.parentId !== undefined && !simAvailable.has(p.parentId)) {
|
||||
throw new Error(`invalid patch: patches[${i}] create parentId "${p.parentId}" not found`)
|
||||
}
|
||||
simAvailable.add(p.node.id)
|
||||
} else if (p.op === 'update') {
|
||||
if (!simAvailable.has(p.id) || simDeleted.has(p.id)) {
|
||||
throw new Error(`invalid patch: patches[${i}] update id "${p.id}" not found`)
|
||||
}
|
||||
if (!p.data || typeof p.data !== 'object') {
|
||||
throw new Error(`invalid patch: patches[${i}] update data is not an object`)
|
||||
}
|
||||
} else if (p.op === 'delete') {
|
||||
if (!simAvailable.has(p.id) || simDeleted.has(p.id)) {
|
||||
throw new Error(`invalid patch: patches[${i}] delete id "${p.id}" not found`)
|
||||
}
|
||||
if (p.cascade === false) {
|
||||
// Only inspect the current store state — we don't simulate
|
||||
// descendant additions during dry-run, because that would require
|
||||
// building a full shadow tree. This matches the semantics of the
|
||||
// single-op deleteNode guard.
|
||||
const desc = this._collectDescendants(p.id)
|
||||
if (desc.length > 1) {
|
||||
throw new Error(
|
||||
`invalid patch: patches[${i}] delete "${p.id}" has descendants; pass cascade: true`,
|
||||
)
|
||||
}
|
||||
}
|
||||
simAvailable.delete(p.id)
|
||||
simDeleted.add(p.id)
|
||||
} else {
|
||||
throw new Error(`invalid patch: patches[${i}] unknown op`)
|
||||
}
|
||||
}
|
||||
|
||||
// Dry-run succeeded — apply in order, batching adjacent ops of the same
|
||||
// op type so Zundo groups them tightly.
|
||||
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = []
|
||||
const updateOps: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
||||
const deleteIds: AnyNodeId[] = []
|
||||
const createdIds: AnyNodeId[] = []
|
||||
|
||||
// Simple approach: queue by type, flush in original order by walking
|
||||
// patches and interleaving flushes when the op type changes, so ids
|
||||
// created/updated/deleted stay temporally consistent.
|
||||
const flush = (kind: 'create' | 'update' | 'delete' | 'none') => {
|
||||
if (kind !== 'create' && createOps.length > 0) {
|
||||
useScene.getState().createNodes(createOps)
|
||||
createOps.length = 0
|
||||
}
|
||||
if (kind !== 'update' && updateOps.length > 0) {
|
||||
useScene.getState().updateNodes(updateOps)
|
||||
updateOps.length = 0
|
||||
}
|
||||
if (kind !== 'delete' && deleteIds.length > 0) {
|
||||
useScene.getState().deleteNodes(deleteIds)
|
||||
deleteIds.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of patches) {
|
||||
if (p.op === 'create') {
|
||||
flush('create')
|
||||
createOps.push({ node: p.node, parentId: p.parentId })
|
||||
createdIds.push(p.node.id as AnyNodeId)
|
||||
} else if (p.op === 'update') {
|
||||
flush('update')
|
||||
updateOps.push({ id: p.id, data: p.data })
|
||||
} else {
|
||||
flush('delete')
|
||||
deleteIds.push(p.id)
|
||||
}
|
||||
}
|
||||
flush('none')
|
||||
|
||||
// Compute actual deleted ids by diffing pre/post snapshots.
|
||||
const postNodes = useScene.getState().nodes
|
||||
const deletedIds: AnyNodeId[] = []
|
||||
for (const prevId of Object.keys(nodes)) {
|
||||
if (!(prevId in postNodes)) deletedIds.push(prevId as AnyNodeId)
|
||||
}
|
||||
|
||||
return {
|
||||
appliedOps: patches.length,
|
||||
deletedIds,
|
||||
createdIds,
|
||||
}
|
||||
}
|
||||
|
||||
/** Undo. Returns the number of steps actually undone. */
|
||||
undo(steps = 1): number {
|
||||
const before = useScene.temporal.getState().pastStates.length
|
||||
useScene.temporal.getState().undo(steps)
|
||||
const after = useScene.temporal.getState().pastStates.length
|
||||
return Math.max(0, before - after)
|
||||
}
|
||||
|
||||
/** Redo. Returns the number of steps actually redone. */
|
||||
redo(steps = 1): number {
|
||||
const before = useScene.temporal.getState().futureStates.length
|
||||
useScene.temporal.getState().redo(steps)
|
||||
const after = useScene.temporal.getState().futureStates.length
|
||||
return Math.max(0, before - after)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod-validate every node in the scene. Reports one error per failed node,
|
||||
* concatenating Zod issue paths.
|
||||
*/
|
||||
validateScene(): ValidationResult {
|
||||
const errors: ValidationError[] = []
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
const res = AnyNodeSchema.safeParse(node)
|
||||
if (res.success) continue
|
||||
for (const issue of res.error.issues) {
|
||||
errors.push({
|
||||
nodeId: id,
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the dirtyNodes set. Returns the ids that were present. No-op for
|
||||
* renderer (there is no renderer in MCP mode); useful for observability.
|
||||
*/
|
||||
flushDirty(): string[] {
|
||||
const state = useScene.getState()
|
||||
const ids = Array.from(state.dirtyNodes)
|
||||
for (const id of ids) {
|
||||
state.clearDirty(id as AnyNodeId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Current temporal history pointers. */
|
||||
getHistory(): { pastCount: number; futureCount: number } {
|
||||
const t = useScene.temporal.getState()
|
||||
return {
|
||||
pastCount: t.pastStates.length,
|
||||
futureCount: t.futureStates.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the temporal undo/redo history. */
|
||||
clearHistory(): void {
|
||||
useScene.temporal.getState().clear()
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
/**
|
||||
* Return the node whose `children` array (string or object form) contains
|
||||
* the given id, or null if none. Used as a fallback when `parentId` is
|
||||
* missing on a node.
|
||||
*/
|
||||
private _findParentByChildrenScan(id: AnyNodeId): AnyNode | null {
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const candidate of Object.values(nodes)) {
|
||||
if (!('children' in candidate) || !Array.isArray(candidate.children)) continue
|
||||
for (const child of candidate.children as unknown[]) {
|
||||
let childId: string | null = null
|
||||
if (typeof child === 'string') childId = child
|
||||
else if (
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
'id' in (child as Record<string, unknown>) &&
|
||||
typeof (child as { id: unknown }).id === 'string'
|
||||
) {
|
||||
childId = (child as { id: string }).id
|
||||
}
|
||||
if (childId === id) return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect ids of a node and all its descendants. Uses the same combined
|
||||
* strategy as `getChildren` (parentId scan + children-array walk) so that
|
||||
* the SiteNode quirk and the default-scene parentId-unset case both work.
|
||||
*/
|
||||
private _collectDescendants(id: AnyNodeId): AnyNodeId[] {
|
||||
const nodes = useScene.getState().nodes
|
||||
if (!nodes[id]) return []
|
||||
const out: AnyNodeId[] = []
|
||||
const stack: AnyNodeId[] = [id]
|
||||
const seen = new Set<AnyNodeId>()
|
||||
// Precompute parent → child[] index from parentId only. `children` arrays
|
||||
// are consulted on-the-fly via getChildren.
|
||||
while (stack.length > 0) {
|
||||
const curr = stack.pop()!
|
||||
if (seen.has(curr)) continue
|
||||
seen.add(curr)
|
||||
out.push(curr)
|
||||
const children = this.getChildren(curr)
|
||||
for (const c of children) stack.push(c.id as AnyNodeId)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user