@pascal-app/core 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events/bus.d.ts +39 -13
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts +6 -22
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +33 -26
- package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +14 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.js +51 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +2 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +273 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +13 -2
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +95 -9
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +28 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +91 -18
- package/dist/index.d.ts +22 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/lib/polygon-relations.d.ts +10 -0
- package/dist/lib/polygon-relations.d.ts.map +1 -0
- package/dist/lib/polygon-relations.js +73 -0
- package/dist/lib/space-detection-pause.test.d.ts +2 -0
- package/dist/lib/space-detection-pause.test.d.ts.map +1 -0
- package/dist/lib/space-detection-pause.test.js +42 -0
- package/dist/lib/space-detection.d.ts +30 -1
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +215 -36
- package/dist/lib/space-detection.test.d.ts +2 -0
- package/dist/lib/space-detection.test.d.ts.map +1 -0
- package/dist/lib/space-detection.test.js +77 -0
- package/dist/material-library.d.ts +1 -1
- package/dist/material-library.d.ts.map +1 -1
- package/dist/material-library.js +1940 -143
- package/dist/registry/__bench__/relations-resolver.bench.d.ts +15 -0
- package/dist/registry/__bench__/relations-resolver.bench.d.ts.map +1 -0
- package/dist/registry/__bench__/relations-resolver.bench.js +170 -0
- package/dist/registry/handles.d.ts +321 -0
- package/dist/registry/handles.d.ts.map +1 -0
- package/dist/registry/handles.js +24 -0
- package/dist/registry/index.d.ts +7 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +4 -0
- package/dist/registry/registry.d.ts +93 -0
- package/dist/registry/registry.d.ts.map +1 -0
- package/dist/registry/registry.js +201 -0
- package/dist/registry/registry.test.d.ts +2 -0
- package/dist/registry/registry.test.d.ts.map +1 -0
- package/dist/registry/registry.test.js +183 -0
- package/dist/registry/relations-resolver.d.ts +42 -0
- package/dist/registry/relations-resolver.d.ts.map +1 -0
- package/dist/registry/relations-resolver.js +77 -0
- package/dist/registry/relations-resolver.test.d.ts +2 -0
- package/dist/registry/relations-resolver.test.d.ts.map +1 -0
- package/dist/registry/relations-resolver.test.js +183 -0
- package/dist/registry/scene-api.d.ts +44 -0
- package/dist/registry/scene-api.d.ts.map +1 -0
- package/dist/registry/scene-api.js +111 -0
- package/dist/registry/scene-api.test.d.ts +2 -0
- package/dist/registry/scene-api.test.d.ts.map +1 -0
- package/dist/registry/scene-api.test.js +183 -0
- package/dist/registry/subtree.d.ts +67 -0
- package/dist/registry/subtree.d.ts.map +1 -0
- package/dist/registry/subtree.js +119 -0
- package/dist/registry/subtree.test.d.ts +2 -0
- package/dist/registry/subtree.test.d.ts.map +1 -0
- package/dist/registry/subtree.test.js +133 -0
- package/dist/registry/types.d.ts +1420 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +1 -0
- package/dist/schema/index.d.ts +17 -2
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +17 -3
- package/dist/schema/material.d.ts +10 -0
- package/dist/schema/material.d.ts.map +1 -1
- package/dist/schema/material.js +10 -0
- package/dist/schema/nodes/box-vent.d.ts +74 -0
- package/dist/schema/nodes/box-vent.d.ts.map +1 -0
- package/dist/schema/nodes/box-vent.js +63 -0
- package/dist/schema/nodes/building.d.ts +1 -1
- package/dist/schema/nodes/building.d.ts.map +1 -1
- package/dist/schema/nodes/building.js +3 -2
- package/dist/schema/nodes/ceiling.d.ts +2 -0
- package/dist/schema/nodes/ceiling.d.ts.map +1 -1
- package/dist/schema/nodes/ceiling.js +1 -1
- package/dist/schema/nodes/chimney.d.ts +158 -0
- package/dist/schema/nodes/chimney.d.ts.map +1 -0
- package/dist/schema/nodes/chimney.js +67 -0
- package/dist/schema/nodes/column.d.ts +597 -5
- package/dist/schema/nodes/column.d.ts.map +1 -1
- package/dist/schema/nodes/column.js +586 -6
- package/dist/schema/nodes/cupola.d.ts +66 -0
- package/dist/schema/nodes/cupola.d.ts.map +1 -0
- package/dist/schema/nodes/cupola.js +28 -0
- package/dist/schema/nodes/dormer.d.ts +228 -0
- package/dist/schema/nodes/dormer.d.ts.map +1 -0
- package/dist/schema/nodes/dormer.js +119 -0
- package/dist/schema/nodes/downspout.d.ts +75 -0
- package/dist/schema/nodes/downspout.d.ts.map +1 -0
- package/dist/schema/nodes/downspout.js +63 -0
- package/dist/schema/nodes/elevator.d.ts +105 -0
- package/dist/schema/nodes/elevator.d.ts.map +1 -0
- package/dist/schema/nodes/elevator.js +54 -0
- package/dist/schema/nodes/eyebrow-vent.d.ts +68 -0
- package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -0
- package/dist/schema/nodes/eyebrow-vent.js +40 -0
- package/dist/schema/nodes/fence.d.ts +1 -0
- package/dist/schema/nodes/fence.d.ts.map +1 -1
- package/dist/schema/nodes/fence.js +2 -0
- package/dist/schema/nodes/gutter.d.ts +84 -0
- package/dist/schema/nodes/gutter.d.ts.map +1 -0
- package/dist/schema/nodes/gutter.js +76 -0
- package/dist/schema/nodes/item.d.ts +4 -0
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +9 -0
- package/dist/schema/nodes/level.d.ts +1 -1
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +2 -0
- package/dist/schema/nodes/ridge-vent.d.ts +67 -0
- package/dist/schema/nodes/ridge-vent.d.ts.map +1 -0
- package/dist/schema/nodes/ridge-vent.js +30 -0
- package/dist/schema/nodes/roof-segment.d.ts +195 -1
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment.js +254 -3
- package/dist/schema/nodes/roof.d.ts.map +1 -1
- package/dist/schema/nodes/roof.js +4 -16
- package/dist/schema/nodes/shelf.d.ts +105 -0
- package/dist/schema/nodes/shelf.d.ts.map +1 -0
- package/dist/schema/nodes/shelf.js +84 -0
- package/dist/schema/nodes/site.d.ts +1 -122
- package/dist/schema/nodes/site.d.ts.map +1 -1
- package/dist/schema/nodes/site.js +2 -6
- package/dist/schema/nodes/skylight.d.ts +279 -0
- package/dist/schema/nodes/skylight.d.ts.map +1 -0
- package/dist/schema/nodes/skylight.js +152 -0
- package/dist/schema/nodes/slab.d.ts +2 -0
- package/dist/schema/nodes/slab.d.ts.map +1 -1
- package/dist/schema/nodes/slab.js +1 -1
- package/dist/schema/nodes/solar-panel.d.ts +117 -0
- package/dist/schema/nodes/solar-panel.d.ts.map +1 -0
- package/dist/schema/nodes/solar-panel.js +47 -0
- package/dist/schema/nodes/surface-hole-metadata.d.ts +2 -0
- package/dist/schema/nodes/surface-hole-metadata.d.ts.map +1 -1
- package/dist/schema/nodes/surface-hole-metadata.js +3 -1
- package/dist/schema/nodes/turbine-vent.d.ts +68 -0
- package/dist/schema/nodes/turbine-vent.d.ts.map +1 -0
- package/dist/schema/nodes/turbine-vent.js +41 -0
- package/dist/schema/types.d.ts +1325 -125
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +26 -0
- package/dist/services/alignment-anchors.d.ts +100 -0
- package/dist/services/alignment-anchors.d.ts.map +1 -0
- package/dist/services/alignment-anchors.js +283 -0
- package/dist/services/alignment-anchors.test.d.ts +2 -0
- package/dist/services/alignment-anchors.test.d.ts.map +1 -0
- package/dist/services/alignment-anchors.test.js +383 -0
- package/dist/services/alignment.d.ts +126 -0
- package/dist/services/alignment.d.ts.map +1 -0
- package/dist/services/alignment.js +169 -0
- package/dist/services/alignment.test.d.ts +2 -0
- package/dist/services/alignment.test.d.ts.map +1 -0
- package/dist/services/alignment.test.js +92 -0
- package/dist/services/drag-session.d.ts +47 -0
- package/dist/services/drag-session.d.ts.map +1 -0
- package/dist/services/drag-session.js +100 -0
- package/dist/services/drag-session.test.d.ts +2 -0
- package/dist/services/drag-session.test.d.ts.map +1 -0
- package/dist/services/drag-session.test.js +219 -0
- package/dist/services/hosting.d.ts +80 -0
- package/dist/services/hosting.d.ts.map +1 -0
- package/dist/services/hosting.js +122 -0
- package/dist/services/hosting.test.d.ts +2 -0
- package/dist/services/hosting.test.d.ts.map +1 -0
- package/dist/services/hosting.test.js +199 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +6 -0
- package/dist/services/movement.d.ts +48 -0
- package/dist/services/movement.d.ts.map +1 -0
- package/dist/services/movement.js +65 -0
- package/dist/services/movement.test.d.ts +2 -0
- package/dist/services/movement.test.d.ts.map +1 -0
- package/dist/services/movement.test.js +133 -0
- package/dist/services/single-undo-dance.test.d.ts +2 -0
- package/dist/services/single-undo-dance.test.d.ts.map +1 -0
- package/dist/services/single-undo-dance.test.js +210 -0
- package/dist/services/snap.d.ts +72 -0
- package/dist/services/snap.d.ts.map +1 -0
- package/dist/services/snap.js +120 -0
- package/dist/services/snap.test.d.ts +2 -0
- package/dist/services/snap.test.d.ts.map +1 -0
- package/dist/services/snap.test.js +95 -0
- package/dist/solar-panel-presets.d.ts +17 -0
- package/dist/solar-panel-presets.d.ts.map +1 -0
- package/dist/solar-panel-presets.js +43 -0
- package/dist/store/actions/node-actions.d.ts +14 -2
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +492 -15
- package/dist/store/actions/node-mutation-sanitize.test.d.ts +2 -0
- package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +1 -0
- package/dist/store/actions/node-mutation-sanitize.test.js +147 -0
- package/dist/store/actions/reparent.test.d.ts +2 -0
- package/dist/store/actions/reparent.test.d.ts.map +1 -0
- package/dist/store/actions/reparent.test.js +212 -0
- package/dist/store/use-interactive.d.ts +39 -0
- package/dist/store/use-interactive.d.ts.map +1 -1
- package/dist/store/use-interactive.js +75 -0
- package/dist/store/use-live-node-overrides.d.ts +22 -0
- package/dist/store/use-live-node-overrides.d.ts.map +1 -0
- package/dist/store/use-live-node-overrides.js +59 -0
- package/dist/store/use-scene-elevator-migration.test.d.ts +2 -0
- package/dist/store/use-scene-elevator-migration.test.d.ts.map +1 -0
- package/dist/store/use-scene-elevator-migration.test.js +109 -0
- package/dist/store/use-scene.d.ts +11 -0
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +196 -6
- package/dist/systems/elevator/elevator-dispatch.d.ts +12 -0
- package/dist/systems/elevator/elevator-dispatch.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-dispatch.js +57 -0
- package/dist/systems/elevator/elevator-geometry.d.ts +15 -0
- package/dist/systems/elevator/elevator-geometry.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-geometry.js +50 -0
- package/dist/systems/elevator/elevator-opening-sync.d.ts +6 -0
- package/dist/systems/elevator/elevator-opening-sync.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-opening-sync.js +218 -0
- package/dist/systems/elevator/elevator-opening-sync.test.d.ts +2 -0
- package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-opening-sync.test.js +146 -0
- package/dist/systems/elevator/elevator-opening-system.d.ts +2 -0
- package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-opening-system.js +48 -0
- package/dist/systems/elevator/elevator-runtime-system.d.ts +2 -0
- package/dist/systems/elevator/elevator-runtime-system.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-runtime-system.js +8 -0
- package/dist/systems/elevator/elevator-runtime.d.ts +18 -0
- package/dist/systems/elevator/elevator-runtime.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-runtime.js +225 -0
- package/dist/systems/elevator/elevator-runtime.test.d.ts +2 -0
- package/dist/systems/elevator/elevator-runtime.test.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-runtime.test.js +64 -0
- package/dist/systems/elevator/elevator-service.d.ts +19 -0
- package/dist/systems/elevator/elevator-service.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-service.js +111 -0
- package/dist/systems/stair/stair-footprint.d.ts +44 -0
- package/dist/systems/stair/stair-footprint.d.ts.map +1 -0
- package/dist/systems/stair/stair-footprint.js +168 -0
- package/dist/systems/stair/stair-opening-preview.d.ts +1777 -0
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-preview.js +93 -0
- package/dist/systems/stair/stair-opening-preview.test.d.ts +2 -0
- package/dist/systems/stair/stair-opening-preview.test.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-preview.test.js +80 -0
- package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.js +172 -147
- package/dist/systems/stair/stair-opening-sync.test.js +385 -2
- package/dist/systems/stair/stair-opening-system.d.ts +2 -0
- package/dist/systems/stair/stair-opening-system.d.ts.map +1 -0
- package/dist/systems/stair/stair-opening-system.js +104 -0
- package/dist/systems/wall/wall-move.d.ts +48 -0
- package/dist/systems/wall/wall-move.d.ts.map +1 -0
- package/dist/systems/wall/wall-move.js +193 -0
- package/dist/utils/clone-scene-graph.d.ts +6 -4
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +6 -4
- package/dist/utils/clone-scene-graph.test.d.ts +2 -0
- package/dist/utils/clone-scene-graph.test.d.ts.map +1 -0
- package/dist/utils/clone-scene-graph.test.js +59 -0
- package/dist/validation/validate-build-json.d.ts +43 -0
- package/dist/validation/validate-build-json.d.ts.map +1 -0
- package/dist/validation/validate-build-json.js +227 -0
- package/package.json +9 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { nodeRegistry, registerNode } from './registry';
|
|
4
|
+
import { cascadeDirty, collectDescendants } from './relations-resolver';
|
|
5
|
+
const id = (s) => s;
|
|
6
|
+
function makeDef(kind, relations, overrides = {}) {
|
|
7
|
+
return {
|
|
8
|
+
kind,
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
schema: z.object({ type: z.literal(kind) }),
|
|
11
|
+
category: 'utility',
|
|
12
|
+
defaults: () => ({}),
|
|
13
|
+
capabilities: {},
|
|
14
|
+
relations,
|
|
15
|
+
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function makeNode(kind, idStr, extra = {}) {
|
|
20
|
+
return {
|
|
21
|
+
id: id(idStr),
|
|
22
|
+
type: kind,
|
|
23
|
+
parentId: null,
|
|
24
|
+
visible: true,
|
|
25
|
+
...extra,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function makeFakeScene(nodes) {
|
|
29
|
+
return {
|
|
30
|
+
get: ((nid) => nodes[nid]),
|
|
31
|
+
nodes: () => nodes,
|
|
32
|
+
update: () => { },
|
|
33
|
+
upsert: () => id(''),
|
|
34
|
+
delete: () => { },
|
|
35
|
+
restore: () => { },
|
|
36
|
+
restoreAll: () => { },
|
|
37
|
+
markDirty: () => { },
|
|
38
|
+
pauseHistory: () => { },
|
|
39
|
+
resumeHistory: () => { },
|
|
40
|
+
getSubtree: () => null,
|
|
41
|
+
cloneNodesInto: () => null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
describe('cascadeDirty', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
nodeRegistry._reset();
|
|
47
|
+
});
|
|
48
|
+
test('starting node alone when registry is empty', () => {
|
|
49
|
+
const scene = makeFakeScene({ a: makeNode('unknown', 'a') });
|
|
50
|
+
const dirty = cascadeDirty(id('a'), { scene });
|
|
51
|
+
expect(Array.from(dirty)).toEqual([id('a')]);
|
|
52
|
+
});
|
|
53
|
+
test('starting node alone when no relations declared', () => {
|
|
54
|
+
registerNode(makeDef('thing'));
|
|
55
|
+
const scene = makeFakeScene({ a: makeNode('thing', 'a') });
|
|
56
|
+
const dirty = cascadeDirty(id('a'), { scene });
|
|
57
|
+
expect(Array.from(dirty)).toEqual([id('a')]);
|
|
58
|
+
});
|
|
59
|
+
test('hosts cascade marks matching children dirty', () => {
|
|
60
|
+
registerNode(makeDef('wall', { hosts: ['door', 'window'] }));
|
|
61
|
+
registerNode(makeDef('door'));
|
|
62
|
+
registerNode(makeDef('window'));
|
|
63
|
+
registerNode(makeDef('lamp'));
|
|
64
|
+
const wall = makeNode('wall', 'w1', {
|
|
65
|
+
children: [id('d1'), id('w2'), id('l1')],
|
|
66
|
+
});
|
|
67
|
+
const scene = makeFakeScene({
|
|
68
|
+
w1: wall,
|
|
69
|
+
d1: makeNode('door', 'd1', { parentId: id('w1') }),
|
|
70
|
+
w2: makeNode('window', 'w2', { parentId: id('w1') }),
|
|
71
|
+
l1: makeNode('lamp', 'l1', { parentId: id('w1') }), // not in hosts list
|
|
72
|
+
});
|
|
73
|
+
const dirty = cascadeDirty(id('w1'), { scene });
|
|
74
|
+
const ids = Array.from(dirty).sort();
|
|
75
|
+
expect(ids).toEqual([id('d1'), id('w1'), id('w2')]); // l1 excluded
|
|
76
|
+
});
|
|
77
|
+
test('hosts cascade is recursive but bounded by maxDepth', () => {
|
|
78
|
+
registerNode(makeDef('a', { hosts: ['a'] })); // a hosts more a
|
|
79
|
+
const nodes = {};
|
|
80
|
+
for (let i = 0; i < 25; i++) {
|
|
81
|
+
const childId = i < 24 ? id(`a${i + 1}`) : undefined;
|
|
82
|
+
nodes[`a${i}`] = makeNode('a', `a${i}`, {
|
|
83
|
+
children: childId ? [childId] : [],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const scene = makeFakeScene(nodes);
|
|
87
|
+
const dirty = cascadeDirty(id('a0'), { scene, maxDepth: 5 });
|
|
88
|
+
expect(dirty.size).toBe(6); // a0 + 5 descendants
|
|
89
|
+
});
|
|
90
|
+
test('affectsSpatial cascade uses spatialQuery to find neighbors', () => {
|
|
91
|
+
registerNode(makeDef('wall', { affectsSpatial: ['slab', 'zone'] }));
|
|
92
|
+
registerNode(makeDef('slab'));
|
|
93
|
+
registerNode(makeDef('zone'));
|
|
94
|
+
const scene = makeFakeScene({
|
|
95
|
+
w1: makeNode('wall', 'w1'),
|
|
96
|
+
s1: makeNode('slab', 's1'),
|
|
97
|
+
z1: makeNode('zone', 'z1'),
|
|
98
|
+
unrelated: makeNode('door', 'unrelated'),
|
|
99
|
+
});
|
|
100
|
+
const spatialQuery = (node, kinds) => {
|
|
101
|
+
if (node.id !== id('w1'))
|
|
102
|
+
return [];
|
|
103
|
+
const matches = [];
|
|
104
|
+
if (kinds.includes('slab'))
|
|
105
|
+
matches.push(id('s1'));
|
|
106
|
+
if (kinds.includes('zone'))
|
|
107
|
+
matches.push(id('z1'));
|
|
108
|
+
return matches;
|
|
109
|
+
};
|
|
110
|
+
const dirty = cascadeDirty(id('w1'), { scene, spatialQuery });
|
|
111
|
+
expect(Array.from(dirty).sort()).toEqual([id('s1'), id('w1'), id('z1')]);
|
|
112
|
+
});
|
|
113
|
+
test('affectsSpatial is a no-op when no spatialQuery is provided', () => {
|
|
114
|
+
registerNode(makeDef('wall', { affectsSpatial: ['slab'] }));
|
|
115
|
+
const scene = makeFakeScene({ w1: makeNode('wall', 'w1') });
|
|
116
|
+
const dirty = cascadeDirty(id('w1'), { scene });
|
|
117
|
+
expect(Array.from(dirty)).toEqual([id('w1')]); // spatial branch silently skipped
|
|
118
|
+
});
|
|
119
|
+
test('cycle in hosts cascade does not loop forever', () => {
|
|
120
|
+
registerNode(makeDef('a', { hosts: ['a'] }));
|
|
121
|
+
const nodes = {
|
|
122
|
+
a1: makeNode('a', 'a1', { children: [id('a2')] }),
|
|
123
|
+
a2: makeNode('a', 'a2', { children: [id('a1')] }), // cycle
|
|
124
|
+
};
|
|
125
|
+
const scene = makeFakeScene(nodes);
|
|
126
|
+
const dirty = cascadeDirty(id('a1'), { scene });
|
|
127
|
+
expect(dirty.size).toBe(2);
|
|
128
|
+
expect(dirty.has(id('a1'))).toBe(true);
|
|
129
|
+
expect(dirty.has(id('a2'))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
test('custom childQuery overrides the default node.children lookup', () => {
|
|
132
|
+
registerNode(makeDef('wall', { hosts: ['door'] }));
|
|
133
|
+
registerNode(makeDef('door'));
|
|
134
|
+
const scene = makeFakeScene({
|
|
135
|
+
w1: makeNode('wall', 'w1'), // no children field
|
|
136
|
+
d1: makeNode('door', 'd1', { parentId: id('w1') }),
|
|
137
|
+
});
|
|
138
|
+
// childQuery iterates the scene to find parentId matches — what you would
|
|
139
|
+
// do for kinds that don't carry an explicit children array.
|
|
140
|
+
const childQuery = (node) => {
|
|
141
|
+
const result = [];
|
|
142
|
+
for (const candidate of [id('d1')]) {
|
|
143
|
+
const c = scene.get(candidate);
|
|
144
|
+
if (c && c.parentId === node.id)
|
|
145
|
+
result.push(c.id);
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
};
|
|
149
|
+
const dirty = cascadeDirty(id('w1'), { scene, childQuery });
|
|
150
|
+
expect(Array.from(dirty).sort()).toEqual([id('d1'), id('w1')]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('collectDescendants', () => {
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
nodeRegistry._reset();
|
|
156
|
+
});
|
|
157
|
+
test('returns just the start when no children', () => {
|
|
158
|
+
const scene = makeFakeScene({ a: makeNode('thing', 'a') });
|
|
159
|
+
const result = collectDescendants(id('a'), { scene });
|
|
160
|
+
expect(Array.from(result)).toEqual([id('a')]);
|
|
161
|
+
});
|
|
162
|
+
test('returns full subtree regardless of relations declarations', () => {
|
|
163
|
+
// No def registered — descendants still found via the children array.
|
|
164
|
+
const scene = makeFakeScene({
|
|
165
|
+
root: makeNode('thing', 'root', { children: [id('c1'), id('c2')] }),
|
|
166
|
+
c1: makeNode('thing', 'c1', { children: [id('g1')] }),
|
|
167
|
+
c2: makeNode('thing', 'c2'),
|
|
168
|
+
g1: makeNode('thing', 'g1'),
|
|
169
|
+
});
|
|
170
|
+
const result = collectDescendants(id('root'), { scene });
|
|
171
|
+
expect(Array.from(result).sort()).toEqual([id('c1'), id('c2'), id('g1'), id('root')]);
|
|
172
|
+
});
|
|
173
|
+
test('respects maxDepth', () => {
|
|
174
|
+
const scene = makeFakeScene({
|
|
175
|
+
a: makeNode('thing', 'a', { children: [id('b')] }),
|
|
176
|
+
b: makeNode('thing', 'b', { children: [id('c')] }),
|
|
177
|
+
c: makeNode('thing', 'c', { children: [id('d')] }),
|
|
178
|
+
d: makeNode('thing', 'd'),
|
|
179
|
+
});
|
|
180
|
+
const result = collectDescendants(id('a'), { scene, maxDepth: 2 });
|
|
181
|
+
expect(Array.from(result).sort()).toEqual([id('a'), id('b'), id('c')]); // d truncated
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId } from '../schema/types';
|
|
2
|
+
import type { SceneApi } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Minimal store shape this module depends on.
|
|
5
|
+
*
|
|
6
|
+
* Decoupled from `useScene` directly so the production singleton and tests can
|
|
7
|
+
* share one factory. The full store implements a superset.
|
|
8
|
+
*/
|
|
9
|
+
export type SceneStoreLike = {
|
|
10
|
+
getState: () => {
|
|
11
|
+
nodes: Record<AnyNodeId, AnyNode>;
|
|
12
|
+
rootNodeIds: AnyNodeId[];
|
|
13
|
+
dirtyNodes: Set<AnyNodeId>;
|
|
14
|
+
createNode: (node: AnyNode, parentId?: AnyNodeId) => void;
|
|
15
|
+
createNodes?: (ops: {
|
|
16
|
+
node: AnyNode;
|
|
17
|
+
parentId?: AnyNodeId;
|
|
18
|
+
}[]) => void;
|
|
19
|
+
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void;
|
|
20
|
+
deleteNode: (id: AnyNodeId) => void;
|
|
21
|
+
markDirty: (id: AnyNodeId) => void;
|
|
22
|
+
};
|
|
23
|
+
temporal: {
|
|
24
|
+
getState: () => {
|
|
25
|
+
pause: () => void;
|
|
26
|
+
resume: () => void;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Creates a {@link SceneApi} backed by a store.
|
|
32
|
+
*
|
|
33
|
+
* Snapshot semantics:
|
|
34
|
+
* - `pauseHistory()` starts a copy-on-write window. The first time `update`,
|
|
35
|
+
* `upsert`, or `delete` touches a node id, the pre-change value is captured.
|
|
36
|
+
* - `restore(id)` and `restoreAll()` apply the captured value back. Either is
|
|
37
|
+
* safe to call only while a pause window is active.
|
|
38
|
+
* - `resumeHistory()` drops the snapshot.
|
|
39
|
+
*
|
|
40
|
+
* Snapshots are lazy and bounded by the number of nodes touched during the
|
|
41
|
+
* pause window — never an upfront clone of the entire scene.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createSceneApi(store: SceneStoreLike): SceneApi;
|
|
44
|
+
//# sourceMappingURL=scene-api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-api.d.ts","sourceRoot":"","sources":["../../src/registry/scene-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAOzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAEvC;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,MAAM;QACd,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;QACjC,WAAW,EAAE,SAAS,EAAE,CAAA;QACxB,UAAU,EAAE,GAAG,CAAC,SAAS,CAAC,CAAA;QAC1B,UAAU,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,KAAK,IAAI,CAAA;QACzD,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE;YAAE,IAAI,EAAE,OAAO,CAAC;YAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;SAAE,EAAE,KAAK,IAAI,CAAA;QACtE,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAA;QAC3D,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;QACnC,SAAS,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;KACnC,CAAA;IACD,QAAQ,EAAE;QACR,QAAQ,EAAE,MAAM;YAAE,KAAK,EAAE,MAAM,IAAI,CAAC;YAAC,MAAM,EAAE,MAAM,IAAI,CAAA;SAAE,CAAA;KAC1D,CAAA;CACF,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,GAAG,QAAQ,CAgG9D"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control';
|
|
2
|
+
import { collectSubtree, cloneNodesInto as runCloneNodesInto, } from './subtree';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a {@link SceneApi} backed by a store.
|
|
5
|
+
*
|
|
6
|
+
* Snapshot semantics:
|
|
7
|
+
* - `pauseHistory()` starts a copy-on-write window. The first time `update`,
|
|
8
|
+
* `upsert`, or `delete` touches a node id, the pre-change value is captured.
|
|
9
|
+
* - `restore(id)` and `restoreAll()` apply the captured value back. Either is
|
|
10
|
+
* safe to call only while a pause window is active.
|
|
11
|
+
* - `resumeHistory()` drops the snapshot.
|
|
12
|
+
*
|
|
13
|
+
* Snapshots are lazy and bounded by the number of nodes touched during the
|
|
14
|
+
* pause window — never an upfront clone of the entire scene.
|
|
15
|
+
*/
|
|
16
|
+
export function createSceneApi(store) {
|
|
17
|
+
let snapshot = null;
|
|
18
|
+
function captureIfNeeded(id) {
|
|
19
|
+
if (!snapshot || snapshot.has(id))
|
|
20
|
+
return;
|
|
21
|
+
const existing = store.getState().nodes[id];
|
|
22
|
+
snapshot.set(id, existing ?? null);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
get(id) {
|
|
26
|
+
return store.getState().nodes[id];
|
|
27
|
+
},
|
|
28
|
+
nodes() {
|
|
29
|
+
return store.getState().nodes;
|
|
30
|
+
},
|
|
31
|
+
update(id, patch) {
|
|
32
|
+
captureIfNeeded(id);
|
|
33
|
+
store.getState().updateNode(id, patch);
|
|
34
|
+
},
|
|
35
|
+
upsert(node, parentId) {
|
|
36
|
+
captureIfNeeded(node.id);
|
|
37
|
+
store.getState().createNode(node, parentId);
|
|
38
|
+
return node.id;
|
|
39
|
+
},
|
|
40
|
+
delete(id) {
|
|
41
|
+
captureIfNeeded(id);
|
|
42
|
+
store.getState().deleteNode(id);
|
|
43
|
+
},
|
|
44
|
+
restore(id) {
|
|
45
|
+
if (!snapshot)
|
|
46
|
+
return;
|
|
47
|
+
const original = snapshot.get(id);
|
|
48
|
+
if (original === undefined)
|
|
49
|
+
return;
|
|
50
|
+
const current = store.getState().nodes[id];
|
|
51
|
+
if (original === null) {
|
|
52
|
+
if (current)
|
|
53
|
+
store.getState().deleteNode(id);
|
|
54
|
+
}
|
|
55
|
+
else if (!current) {
|
|
56
|
+
store.getState().createNode(original);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
store.getState().updateNode(id, original);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
restoreAll() {
|
|
63
|
+
if (!snapshot)
|
|
64
|
+
return;
|
|
65
|
+
for (const id of snapshot.keys()) {
|
|
66
|
+
this.restore(id);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
markDirty(id) {
|
|
70
|
+
store.getState().markDirty(id);
|
|
71
|
+
},
|
|
72
|
+
pauseHistory() {
|
|
73
|
+
pauseSceneHistory(store);
|
|
74
|
+
if (!snapshot)
|
|
75
|
+
snapshot = new Map();
|
|
76
|
+
},
|
|
77
|
+
resumeHistory() {
|
|
78
|
+
resumeSceneHistory(store);
|
|
79
|
+
snapshot = null;
|
|
80
|
+
},
|
|
81
|
+
getSubtree(rootId) {
|
|
82
|
+
return collectSubtree(store.getState().nodes, rootId);
|
|
83
|
+
},
|
|
84
|
+
cloneNodesInto(nodes, opts) {
|
|
85
|
+
const { rootId, nodes: cloned } = runCloneNodesInto(nodes, opts);
|
|
86
|
+
const root = cloned[0];
|
|
87
|
+
if (!root)
|
|
88
|
+
return null;
|
|
89
|
+
const state = store.getState();
|
|
90
|
+
const ops = [];
|
|
91
|
+
for (let i = 0; i < cloned.length; i += 1) {
|
|
92
|
+
const node = cloned[i];
|
|
93
|
+
if (i === 0) {
|
|
94
|
+
ops.push(opts.parentId ? { node, parentId: opts.parentId } : { node });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
ops.push({ node });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const batch = state.createNodes;
|
|
101
|
+
if (batch) {
|
|
102
|
+
batch(ops);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
for (const op of ops)
|
|
106
|
+
state.createNode(op.node, op.parentId);
|
|
107
|
+
}
|
|
108
|
+
return rootId;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scene-api.test.d.ts","sourceRoot":"","sources":["../../src/registry/scene-api.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { resetSceneHistoryPauseDepth } from '../store/history-control';
|
|
3
|
+
import { createSceneApi } from './scene-api';
|
|
4
|
+
function makeFakeStore(initial = {}) {
|
|
5
|
+
const state = {
|
|
6
|
+
nodes: { ...initial },
|
|
7
|
+
rootNodeIds: [],
|
|
8
|
+
dirtyNodes: new Set(),
|
|
9
|
+
createNode(node) {
|
|
10
|
+
state.nodes[node.id] = node;
|
|
11
|
+
},
|
|
12
|
+
updateNode(id, data) {
|
|
13
|
+
const existing = state.nodes[id];
|
|
14
|
+
if (existing)
|
|
15
|
+
state.nodes[id] = { ...existing, ...data };
|
|
16
|
+
},
|
|
17
|
+
deleteNode(id) {
|
|
18
|
+
delete state.nodes[id];
|
|
19
|
+
},
|
|
20
|
+
markDirty(id) {
|
|
21
|
+
state.dirtyNodes.add(id);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
let paused = 0;
|
|
25
|
+
const temporal = {
|
|
26
|
+
getState: () => ({
|
|
27
|
+
pause: () => {
|
|
28
|
+
paused += 1;
|
|
29
|
+
},
|
|
30
|
+
resume: () => {
|
|
31
|
+
paused -= 1;
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
const store = {
|
|
36
|
+
getState: () => state,
|
|
37
|
+
temporal,
|
|
38
|
+
_state: state,
|
|
39
|
+
_pausedCount: () => paused,
|
|
40
|
+
};
|
|
41
|
+
return store;
|
|
42
|
+
}
|
|
43
|
+
function makeNode(id, extra = {}) {
|
|
44
|
+
return { id, type: 'site', parentId: null, visible: true, ...extra };
|
|
45
|
+
}
|
|
46
|
+
// Tests use short string IDs ("a", "b") for readability. The store's
|
|
47
|
+
// AnyNodeId is a branded template-literal type — cast at the boundary.
|
|
48
|
+
const id = (s) => s;
|
|
49
|
+
const nodes = (store) => store._state.nodes;
|
|
50
|
+
describe('SceneApi', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
resetSceneHistoryPauseDepth();
|
|
53
|
+
});
|
|
54
|
+
test('get reads node from store', () => {
|
|
55
|
+
const store = makeFakeStore({ a: makeNode('a') });
|
|
56
|
+
const api = createSceneApi(store);
|
|
57
|
+
expect(api.get(id('a'))).toEqual(makeNode('a'));
|
|
58
|
+
expect(api.get(id('missing'))).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
test('update applies patch via store.updateNode', () => {
|
|
61
|
+
const store = makeFakeStore({ a: makeNode('a', { visible: true }) });
|
|
62
|
+
const api = createSceneApi(store);
|
|
63
|
+
api.update(id('a'), { visible: false });
|
|
64
|
+
expect(nodes(store)['a']).toMatchObject({ visible: false });
|
|
65
|
+
});
|
|
66
|
+
test('upsert calls createNode and returns the id', () => {
|
|
67
|
+
const store = makeFakeStore();
|
|
68
|
+
const api = createSceneApi(store);
|
|
69
|
+
const returnedId = api.upsert(makeNode('a'));
|
|
70
|
+
expect(returnedId).toBe(id('a'));
|
|
71
|
+
expect(nodes(store)['a']).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
test('delete removes node from store', () => {
|
|
74
|
+
const store = makeFakeStore({ a: makeNode('a') });
|
|
75
|
+
const api = createSceneApi(store);
|
|
76
|
+
api.delete(id('a'));
|
|
77
|
+
expect(nodes(store)['a']).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
test('markDirty forwards to store', () => {
|
|
80
|
+
const store = makeFakeStore({ a: makeNode('a') });
|
|
81
|
+
const api = createSceneApi(store);
|
|
82
|
+
api.markDirty(id('a'));
|
|
83
|
+
expect(store._state.dirtyNodes.has(id('a'))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
test('pauseHistory and resumeHistory bracket store.temporal pause/resume', () => {
|
|
86
|
+
const store = makeFakeStore();
|
|
87
|
+
const api = createSceneApi(store);
|
|
88
|
+
expect(store._pausedCount()).toBe(0);
|
|
89
|
+
api.pauseHistory();
|
|
90
|
+
expect(store._pausedCount()).toBe(1);
|
|
91
|
+
api.resumeHistory();
|
|
92
|
+
expect(store._pausedCount()).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
test('nested pause/resume use a depth counter (single pause call to temporal)', () => {
|
|
95
|
+
const store = makeFakeStore();
|
|
96
|
+
const api = createSceneApi(store);
|
|
97
|
+
api.pauseHistory();
|
|
98
|
+
api.pauseHistory();
|
|
99
|
+
expect(store._pausedCount()).toBe(1); // only one actual pause
|
|
100
|
+
api.resumeHistory();
|
|
101
|
+
expect(store._pausedCount()).toBe(1); // still paused — inner resume
|
|
102
|
+
api.resumeHistory();
|
|
103
|
+
expect(store._pausedCount()).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('SceneApi snapshot / restore', () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
resetSceneHistoryPauseDepth();
|
|
109
|
+
});
|
|
110
|
+
test('restore returns a touched node to its pre-pause state', () => {
|
|
111
|
+
const store = makeFakeStore({ a: makeNode('a', { visible: true }) });
|
|
112
|
+
const api = createSceneApi(store);
|
|
113
|
+
api.pauseHistory();
|
|
114
|
+
api.update(id('a'), { visible: false });
|
|
115
|
+
expect(nodes(store)['a']).toMatchObject({ visible: false });
|
|
116
|
+
api.restore(id('a'));
|
|
117
|
+
expect(nodes(store)['a']).toMatchObject({ visible: true });
|
|
118
|
+
api.resumeHistory();
|
|
119
|
+
});
|
|
120
|
+
test('restore on a node never touched is a no-op', () => {
|
|
121
|
+
const store = makeFakeStore({ a: makeNode('a', { visible: true }) });
|
|
122
|
+
const api = createSceneApi(store);
|
|
123
|
+
api.pauseHistory();
|
|
124
|
+
api.restore(id('a'));
|
|
125
|
+
expect(nodes(store)['a']).toMatchObject({ visible: true });
|
|
126
|
+
api.resumeHistory();
|
|
127
|
+
});
|
|
128
|
+
test('restoreAll reverts every touched node', () => {
|
|
129
|
+
const store = makeFakeStore({
|
|
130
|
+
a: makeNode('a', { visible: true }),
|
|
131
|
+
b: makeNode('b', { visible: true }),
|
|
132
|
+
});
|
|
133
|
+
const api = createSceneApi(store);
|
|
134
|
+
api.pauseHistory();
|
|
135
|
+
api.update(id('a'), { visible: false });
|
|
136
|
+
api.update(id('b'), { visible: false });
|
|
137
|
+
api.restoreAll();
|
|
138
|
+
expect(nodes(store)['a']).toMatchObject({ visible: true });
|
|
139
|
+
expect(nodes(store)['b']).toMatchObject({ visible: true });
|
|
140
|
+
api.resumeHistory();
|
|
141
|
+
});
|
|
142
|
+
test('restore re-creates a node that was deleted mid-pause', () => {
|
|
143
|
+
const original = makeNode('a', { visible: true });
|
|
144
|
+
const store = makeFakeStore({ a: original });
|
|
145
|
+
const api = createSceneApi(store);
|
|
146
|
+
api.pauseHistory();
|
|
147
|
+
api.delete(id('a'));
|
|
148
|
+
expect(nodes(store)['a']).toBeUndefined();
|
|
149
|
+
api.restore(id('a'));
|
|
150
|
+
expect(nodes(store)['a']).toEqual(original);
|
|
151
|
+
api.resumeHistory();
|
|
152
|
+
});
|
|
153
|
+
test('restore deletes a node that was upserted mid-pause', () => {
|
|
154
|
+
const store = makeFakeStore();
|
|
155
|
+
const api = createSceneApi(store);
|
|
156
|
+
api.pauseHistory();
|
|
157
|
+
api.upsert(makeNode('a'));
|
|
158
|
+
expect(nodes(store)['a']).toBeDefined();
|
|
159
|
+
api.restore(id('a'));
|
|
160
|
+
expect(nodes(store)['a']).toBeUndefined();
|
|
161
|
+
api.resumeHistory();
|
|
162
|
+
});
|
|
163
|
+
test('snapshot is dropped on resumeHistory; restore after resume is a no-op', () => {
|
|
164
|
+
const store = makeFakeStore({ a: makeNode('a', { visible: true }) });
|
|
165
|
+
const api = createSceneApi(store);
|
|
166
|
+
api.pauseHistory();
|
|
167
|
+
api.update(id('a'), { visible: false });
|
|
168
|
+
api.resumeHistory();
|
|
169
|
+
api.restore(id('a')); // snapshot gone — no effect
|
|
170
|
+
expect(nodes(store)['a']).toMatchObject({ visible: false });
|
|
171
|
+
});
|
|
172
|
+
test('only the first mutation in a pause window captures the original', () => {
|
|
173
|
+
const store = makeFakeStore({ a: makeNode('a', { visible: true }) });
|
|
174
|
+
const api = createSceneApi(store);
|
|
175
|
+
api.pauseHistory();
|
|
176
|
+
api.update(id('a'), { visible: false });
|
|
177
|
+
api.update(id('a'), { visible: true }); // second update — must not overwrite snapshot
|
|
178
|
+
api.update(id('a'), { visible: false });
|
|
179
|
+
api.restore(id('a'));
|
|
180
|
+
expect(nodes(store)['a']).toMatchObject({ visible: true }); // the *first* pre-pause value
|
|
181
|
+
api.resumeHistory();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId } from '../schema/types';
|
|
2
|
+
/** A flat live-scene subtree rooted at `root`. */
|
|
3
|
+
export type Subtree = {
|
|
4
|
+
/** The root node, exactly as stored in `useScene.nodes[rootId]`. */
|
|
5
|
+
root: AnyNode;
|
|
6
|
+
/** Every descendant reachable from `root` via the data-model `children` array, in BFS order. */
|
|
7
|
+
descendants: AnyNode[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Collect the subtree of nodes rooted at `rootId` from the live scene.
|
|
11
|
+
*
|
|
12
|
+
* - BFS walk via `node.children` arrays — order is stable and matches
|
|
13
|
+
* declaration order on container kinds.
|
|
14
|
+
* - Returns the live node references (not clones). Cheap; the caller
|
|
15
|
+
* chooses whether to deep-clone for persistence.
|
|
16
|
+
* - Returns `null` if `rootId` is missing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function collectSubtree(nodes: Readonly<Record<AnyNodeId, AnyNode>>, rootId: AnyNodeId): Subtree | null;
|
|
19
|
+
export type CloneNodesIntoOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* The id of the root node within `nodes` (i.e. the node whose
|
|
22
|
+
* `parentId` becomes `parentId` in the destination instead of being
|
|
23
|
+
* remapped to a sibling's fresh id). Required because `nodes` is a
|
|
24
|
+
* flat array — there's no other way to mark which one is the root.
|
|
25
|
+
*/
|
|
26
|
+
rootId: AnyNodeId;
|
|
27
|
+
/**
|
|
28
|
+
* Parent for the cloned root in the destination scene. When omitted,
|
|
29
|
+
* the root is inserted as a scene root (its `parentId` becomes the
|
|
30
|
+
* preserved value, often `null`).
|
|
31
|
+
*/
|
|
32
|
+
parentId?: AnyNodeId;
|
|
33
|
+
/**
|
|
34
|
+
* Optional override for the cloned root's `position` (most placement
|
|
35
|
+
* flows stamp the cursor / target point here). When omitted, the
|
|
36
|
+
* root's own `position` field is preserved verbatim. Descendants
|
|
37
|
+
* always keep their original positions — those are local to the root.
|
|
38
|
+
*/
|
|
39
|
+
position?: readonly [number, number, number];
|
|
40
|
+
};
|
|
41
|
+
export type CloneNodesIntoResult = {
|
|
42
|
+
/** Fresh id assigned to the root in the destination scene. */
|
|
43
|
+
rootId: AnyNodeId;
|
|
44
|
+
/** Every cloned node, root first, ready to feed into `createNodes`. */
|
|
45
|
+
nodes: AnyNode[];
|
|
46
|
+
/** Original id → fresh id map, mostly useful for tests and host-side bookkeeping. */
|
|
47
|
+
idMap: Map<AnyNodeId, AnyNodeId>;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Clone a flat array of nodes with fresh IDs and rewired references,
|
|
51
|
+
* ready to insert via `useScene.createNodes`.
|
|
52
|
+
*
|
|
53
|
+
* Transformations applied:
|
|
54
|
+
* 1. Deep-clone each node via JSON round-trip (strips three.js refs,
|
|
55
|
+
* functions, circular links — same trick `cloneLevelSubtree` uses).
|
|
56
|
+
* 2. Mint a fresh id for every node, preserving the prefix
|
|
57
|
+
* (`wall_…`, `door_…`, etc.) so logs and lookups stay readable.
|
|
58
|
+
* 3. Rewrite `parentId`, `children[]` to use the fresh ids.
|
|
59
|
+
* 4. Stamp `position` onto the root if provided.
|
|
60
|
+
* 5. Set the root's `parentId` to `opts.parentId` when supplied.
|
|
61
|
+
*
|
|
62
|
+
* Intentionally generic — no awareness of host refs (`wallId`/`wallT`
|
|
63
|
+
* etc.). The caller is responsible for stripping or re-deriving those
|
|
64
|
+
* before / after calling this function. See `getHostRefFields(def)`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function cloneNodesInto(nodes: ReadonlyArray<AnyNode>, opts: CloneNodesIntoOptions): CloneNodesIntoResult;
|
|
67
|
+
//# sourceMappingURL=subtree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subtree.d.ts","sourceRoot":"","sources":["../../src/registry/subtree.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAmBzD,kDAAkD;AAClD,MAAM,MAAM,OAAO,GAAG;IACpB,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;IACb,gGAAgG;IAChG,WAAW,EAAE,OAAO,EAAE,CAAA;CACvB,CAAA;AAcD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,EAC3C,MAAM,EAAE,SAAS,GAChB,OAAO,GAAG,IAAI,CAmBhB;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC;;;;;OAKG;IACH,MAAM,EAAE,SAAS,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CAC7C,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,8DAA8D;IAC9D,MAAM,EAAE,SAAS,CAAA;IACjB,uEAAuE;IACvE,KAAK,EAAE,OAAO,EAAE,CAAA;IAChB,qFAAqF;IACrF,KAAK,EAAE,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;CACjC,CAAA;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,aAAa,CAAC,OAAO,CAAC,EAC7B,IAAI,EAAE,qBAAqB,GAC1B,oBAAoB,CA6DtB"}
|