@pascal-app/core 1.0.0-beta.4 → 1.0.0
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/LICENSE +21 -0
- package/README.md +23 -0
- package/dist/capture/index.d.ts +3 -0
- package/dist/capture/index.d.ts.map +1 -0
- package/dist/capture/index.js +2 -0
- package/dist/capture/schema.d.ts +417 -0
- package/dist/capture/schema.d.ts.map +1 -0
- package/dist/capture/schema.js +348 -0
- package/dist/capture/source.d.ts +60 -0
- package/dist/capture/source.d.ts.map +1 -0
- package/dist/capture/source.js +254 -0
- package/dist/events/bus.d.ts +55 -7
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/hidden-wall-pointer-hold.d.ts +28 -0
- package/dist/events/hidden-wall-pointer-hold.d.ts.map +1 -0
- package/dist/events/hidden-wall-pointer-hold.js +37 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +2785 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +132 -19
- package/dist/hooks/spatial-grid/support-host-patch.d.ts +45 -1
- package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/support-host-patch.js +171 -6
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/lib/asset-storage.d.ts.map +1 -1
- package/dist/lib/asset-storage.js +4 -1
- package/dist/lib/polygon-union.d.ts +4 -0
- package/dist/lib/polygon-union.d.ts.map +1 -0
- package/dist/lib/polygon-union.js +306 -0
- package/dist/lib/roof-overlap.d.ts +32 -0
- package/dist/lib/roof-overlap.d.ts.map +1 -0
- package/dist/lib/roof-overlap.js +72 -0
- package/dist/lib/room-topology-index.d.ts +50 -0
- package/dist/lib/room-topology-index.d.ts.map +1 -0
- package/dist/lib/room-topology-index.js +237 -0
- package/dist/lib/slab-polygon.d.ts +35 -2
- package/dist/lib/slab-polygon.d.ts.map +1 -1
- package/dist/lib/slab-polygon.js +133 -27
- package/dist/lib/space-detection.d.ts +40 -3
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +890 -246
- package/dist/registry/handles.d.ts +24 -3
- package/dist/registry/handles.d.ts.map +1 -1
- package/dist/registry/index.d.ts +3 -3
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/registry.d.ts +29 -6
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +111 -4
- package/dist/registry/scene-api.d.ts +16 -0
- package/dist/registry/scene-api.d.ts.map +1 -1
- package/dist/registry/scene-api.js +49 -1
- package/dist/registry/types.d.ts +261 -13
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry/use-registry-version.d.ts +11 -0
- package/dist/registry/use-registry-version.d.ts.map +1 -0
- package/dist/registry/use-registry-version.js +15 -0
- package/dist/schema/__bench__/node-parsers.bench.d.ts +24 -0
- package/dist/schema/__bench__/node-parsers.bench.d.ts.map +1 -0
- package/dist/schema/__bench__/node-parsers.bench.js +314 -0
- package/dist/schema/__fixtures__/node-fixtures.d.ts +28 -0
- package/dist/schema/__fixtures__/node-fixtures.d.ts.map +1 -0
- package/dist/schema/__fixtures__/node-fixtures.js +125 -0
- package/dist/schema/base.d.ts +1 -1
- package/dist/schema/base.d.ts.map +1 -1
- package/dist/schema/base.js +8 -1
- package/dist/schema/compiled-node-parsers.d.ts +40 -0
- package/dist/schema/compiled-node-parsers.d.ts.map +1 -0
- package/dist/schema/compiled-node-parsers.js +95 -0
- package/dist/schema/index.d.ts +20 -15
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +19 -12
- package/dist/schema/material.d.ts +2 -1
- package/dist/schema/material.d.ts.map +1 -1
- package/dist/schema/material.js +1 -0
- package/dist/schema/nodes/automatic-downspout.d.ts +17 -0
- package/dist/schema/nodes/automatic-downspout.d.ts.map +1 -0
- package/dist/schema/nodes/automatic-downspout.js +237 -0
- package/dist/schema/nodes/block.d.ts +107 -0
- package/dist/schema/nodes/block.d.ts.map +1 -0
- package/dist/schema/nodes/block.js +207 -0
- package/dist/schema/nodes/box-vent.d.ts +8 -2
- package/dist/schema/nodes/box-vent.d.ts.map +1 -1
- package/dist/schema/nodes/box-vent.js +2 -0
- package/dist/schema/nodes/building.d.ts +1 -1
- package/dist/schema/nodes/cabinet.d.ts +25 -3
- package/dist/schema/nodes/cabinet.d.ts.map +1 -1
- package/dist/schema/nodes/cabinet.js +26 -5
- package/dist/schema/nodes/ceiling.d.ts +1 -1
- package/dist/schema/nodes/chimney.d.ts +2 -2
- package/dist/schema/nodes/column.d.ts +6 -6
- package/dist/schema/nodes/construction-dimension.d.ts +4 -4
- package/dist/schema/nodes/cupola.d.ts +9 -1
- package/dist/schema/nodes/cupola.d.ts.map +1 -1
- package/dist/schema/nodes/cupola.js +2 -0
- package/dist/schema/nodes/door.d.ts +1 -1
- package/dist/schema/nodes/dormer.d.ts +44 -1
- package/dist/schema/nodes/dormer.d.ts.map +1 -1
- package/dist/schema/nodes/dormer.js +131 -7
- package/dist/schema/nodes/downspout.d.ts +11 -1
- package/dist/schema/nodes/downspout.d.ts.map +1 -1
- package/dist/schema/nodes/downspout.js +24 -0
- package/dist/schema/nodes/duct-fitting.d.ts +18 -1
- package/dist/schema/nodes/duct-fitting.d.ts.map +1 -1
- package/dist/schema/nodes/duct-fitting.js +25 -4
- package/dist/schema/nodes/duct-segment.d.ts +23 -1
- package/dist/schema/nodes/duct-segment.d.ts.map +1 -1
- package/dist/schema/nodes/duct-segment.js +20 -0
- package/dist/schema/nodes/duct-terminal.d.ts +1 -1
- package/dist/schema/nodes/elevator.d.ts +1 -1
- package/dist/schema/nodes/eyebrow-vent.d.ts +8 -2
- package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -1
- package/dist/schema/nodes/eyebrow-vent.js +2 -0
- package/dist/schema/nodes/fence.d.ts +1 -1
- package/dist/schema/nodes/guide.d.ts +1 -1
- package/dist/schema/nodes/gutter.d.ts +36 -2
- package/dist/schema/nodes/gutter.d.ts.map +1 -1
- package/dist/schema/nodes/gutter.js +308 -1
- package/dist/schema/nodes/hanger-overrides.d.ts +7 -0
- package/dist/schema/nodes/hanger-overrides.d.ts.map +1 -0
- package/dist/schema/nodes/hanger-overrides.js +6 -0
- package/dist/schema/nodes/hvac-equipment.d.ts +1 -1
- package/dist/schema/nodes/item.d.ts +4 -3
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +5 -0
- package/dist/schema/nodes/lean-to-extension.d.ts +212 -0
- package/dist/schema/nodes/lean-to-extension.d.ts.map +1 -0
- package/dist/schema/nodes/lean-to-extension.js +114 -0
- package/dist/schema/nodes/level.d.ts +5 -2
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +9 -0
- package/dist/schema/nodes/lineset.d.ts +1 -1
- package/dist/schema/nodes/liquid-line.d.ts +1 -1
- package/dist/schema/nodes/measurement.d.ts +1 -1
- package/dist/schema/nodes/pipe-fitting.d.ts +9 -1
- package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -1
- package/dist/schema/nodes/pipe-fitting.js +6 -2
- package/dist/schema/nodes/pipe-segment.d.ts +23 -1
- package/dist/schema/nodes/pipe-segment.d.ts.map +1 -1
- package/dist/schema/nodes/pipe-segment.js +20 -0
- package/dist/schema/nodes/pipe-trap.d.ts +1 -1
- package/dist/schema/nodes/ridge-vent.d.ts +1 -1
- package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
- package/dist/schema/nodes/ridge-vent.js +4 -1
- package/dist/schema/nodes/roof-segment-shape.d.ts +4 -0
- package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment-shape.js +64 -1
- package/dist/schema/nodes/roof-segment-walls.d.ts +3 -1
- package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment-walls.js +1 -1
- package/dist/schema/nodes/roof-segment.d.ts +41 -1
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment.js +79 -2
- package/dist/schema/nodes/roof.d.ts +22 -1
- package/dist/schema/nodes/roof.d.ts.map +1 -1
- package/dist/schema/nodes/roof.js +14 -0
- package/dist/schema/nodes/scan.d.ts +19 -2
- package/dist/schema/nodes/scan.d.ts.map +1 -1
- package/dist/schema/nodes/scan.js +11 -1
- package/dist/schema/nodes/shelf.d.ts +2 -2
- package/dist/schema/nodes/site.d.ts +1 -1
- package/dist/schema/nodes/skylight.d.ts +1 -1
- package/dist/schema/nodes/slab.d.ts +1 -1
- package/dist/schema/nodes/solar-panel.d.ts +1 -1
- package/dist/schema/nodes/spawn.d.ts +1 -1
- package/dist/schema/nodes/stair-segment.d.ts +1 -1
- package/dist/schema/nodes/stair.d.ts +1 -1
- package/dist/schema/nodes/structural-grid.d.ts +1 -1
- package/dist/schema/nodes/turbine-vent.d.ts +7 -1
- package/dist/schema/nodes/turbine-vent.d.ts.map +1 -1
- package/dist/schema/nodes/turbine-vent.js +2 -0
- package/dist/schema/nodes/wall.d.ts +4 -4
- package/dist/schema/nodes/wall.d.ts.map +1 -1
- package/dist/schema/nodes/wall.js +7 -1
- package/dist/schema/nodes/window.d.ts +10 -3
- package/dist/schema/nodes/window.d.ts.map +1 -1
- package/dist/schema/nodes/window.js +4 -0
- package/dist/schema/nodes/zone.d.ts +2 -2
- package/dist/schema/nodes/zone.js +1 -1
- package/dist/schema/types.d.ts +485 -109
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +27 -1
- package/dist/services/hosting.d.ts +2 -2
- package/dist/services/hosting.d.ts.map +1 -1
- package/dist/services/hosting.js +4 -4
- package/dist/services/index.d.ts +2 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -2
- package/dist/services/storey.d.ts +3 -10
- package/dist/services/storey.d.ts.map +1 -1
- package/dist/services/storey.js +45 -15
- package/dist/services/system-graph.d.ts +11 -0
- package/dist/services/system-graph.d.ts.map +1 -1
- package/dist/services/system-graph.js +35 -5
- package/dist/store/actions/node-actions.d.ts +15 -4
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +458 -19
- package/dist/store/history-control.d.ts +5 -0
- package/dist/store/history-control.d.ts.map +1 -1
- package/dist/store/history-control.js +68 -6
- package/dist/store/history-invalidation.d.ts +3 -0
- package/dist/store/history-invalidation.d.ts.map +1 -0
- package/dist/store/history-invalidation.js +72 -0
- package/dist/store/scene-hydration.d.ts +5 -0
- package/dist/store/scene-hydration.d.ts.map +1 -0
- package/dist/store/scene-hydration.js +57 -0
- package/dist/store/use-scene.d.ts +3 -0
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +407 -227
- package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-service.js +16 -11
- package/dist/systems/roof/roof-elevation-system.d.ts +3 -0
- package/dist/systems/roof/roof-elevation-system.d.ts.map +1 -0
- package/dist/systems/roof/roof-elevation-system.js +94 -0
- package/dist/systems/roof/roof-elevation.d.ts +4 -0
- package/dist/systems/roof/roof-elevation.d.ts.map +1 -0
- package/dist/systems/roof/roof-elevation.js +95 -0
- package/dist/systems/roof/roof-footprint.d.ts +17 -0
- package/dist/systems/roof/roof-footprint.d.ts.map +1 -0
- package/dist/systems/roof/roof-footprint.js +83 -0
- package/dist/systems/slab/slab-support.d.ts +0 -31
- package/dist/systems/slab/slab-support.d.ts.map +1 -1
- package/dist/systems/slab/slab-support.js +26 -4
- package/dist/systems/stair/stair-flight.d.ts +17 -0
- package/dist/systems/stair/stair-flight.d.ts.map +1 -0
- package/dist/systems/stair/stair-flight.js +27 -0
- package/dist/systems/stair/stair-opening-preview.d.ts +1556 -1328
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-system.d.ts +1 -0
- package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-system.js +93 -96
- package/dist/systems/stair/stair-rise.js +21 -20
- package/dist/systems/wall/wall-curve.d.ts +1 -0
- package/dist/systems/wall/wall-curve.d.ts.map +1 -1
- package/dist/systems/wall/wall-curve.js +73 -0
- package/dist/systems/wall/wall-footprint.d.ts +1 -0
- package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
- package/dist/systems/wall/wall-footprint.js +1 -0
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +88 -9
- package/dist/systems/wall/wall-topology.d.ts +53 -0
- package/dist/systems/wall/wall-topology.d.ts.map +1 -0
- package/dist/systems/wall/wall-topology.js +414 -0
- package/dist/utils/clone-scene-graph.d.ts +2 -0
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +39 -2
- package/dist/utils/scene-migrations.d.ts +1 -0
- package/dist/utils/scene-migrations.d.ts.map +1 -1
- package/dist/utils/scene-migrations.js +1 -0
- package/dist/utils/vertical-scene-migration.d.ts +13 -0
- package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
- package/dist/utils/vertical-scene-migration.js +179 -0
- package/dist/validation/validate-build-json.d.ts +3 -0
- package/dist/validation/validate-build-json.d.ts.map +1 -1
- package/dist/validation/validate-build-json.js +49 -2
- package/package.json +10 -3
- package/dist/systems/elevator/elevator-runtime-system.d.ts +0 -2
- package/dist/systems/elevator/elevator-runtime-system.d.ts.map +0 -1
- package/dist/systems/elevator/elevator-runtime-system.js +0 -8
package/dist/store/use-scene.js
CHANGED
|
@@ -5,9 +5,11 @@ import { parseMaterialRef, toSceneMaterialRef } from '../material-library';
|
|
|
5
5
|
import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry';
|
|
6
6
|
import { BuildingNode } from '../schema';
|
|
7
7
|
import { generateCollectionId } from '../schema/collections';
|
|
8
|
+
import { compiledNodeSchema } from '../schema/compiled-node-parsers';
|
|
8
9
|
import { DoorNode as DoorNodeSchema } from '../schema/nodes/door';
|
|
10
|
+
import { createDormerDefaultWindow, DormerNode as DormerNodeSchema, getDormerDefaultWindowFace, } from '../schema/nodes/dormer';
|
|
9
11
|
import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator';
|
|
10
|
-
import { LevelNode } from '../schema/nodes/level';
|
|
12
|
+
import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level';
|
|
11
13
|
import { getPitchFromActiveRoofHeight, } from '../schema/nodes/roof-segment';
|
|
12
14
|
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls';
|
|
13
15
|
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf';
|
|
@@ -18,14 +20,16 @@ import { getEffectiveWallSurfaceMaterial } from '../schema/nodes/wall';
|
|
|
18
20
|
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window';
|
|
19
21
|
import { generateSceneMaterialId, SceneMaterial, } from '../schema/scene-material';
|
|
20
22
|
import { AnyNode as AnyNodeSchema } from '../schema/types';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint';
|
|
23
|
+
import { syncAutoElevatorOpenings } from '../systems/elevator/elevator-opening-sync';
|
|
24
|
+
import { syncAutoStairOpenings } from '../systems/stair/stair-opening-sync';
|
|
25
|
+
import { syncStairRises } from '../systems/stair/stair-rise';
|
|
25
26
|
import { healSceneNodes } from '../utils/heal-scene-graph';
|
|
26
27
|
import { removeRetiredDrawingSheetNodes } from '../utils/retired-scene-nodes';
|
|
28
|
+
import { migrateVerticalSceneNodes } from '../utils/vertical-scene-migration';
|
|
27
29
|
import * as nodeActions from './actions/node-actions';
|
|
28
|
-
import { areSceneSnapshotsEqual, getSceneHistoryPauseDepth, notifySceneCommit, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, } from './history-control';
|
|
30
|
+
import { areSceneSnapshotsEqual, getSceneHistoryPauseDepth, notifySceneCommit, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, runWithSceneCommitNodeIds, } from './history-control';
|
|
31
|
+
import { getHistoryDirtyNodeIds } from './history-invalidation';
|
|
32
|
+
import { invalidatePendingHydration, isHydrationNormalization, queueSceneNormalization, runSceneHydration, } from './scene-hydration';
|
|
29
33
|
import useLiveNodeOverrides from './use-live-node-overrides';
|
|
30
34
|
import useLiveTransforms from './use-live-transforms';
|
|
31
35
|
function getFiniteNumber(value, fallback) {
|
|
@@ -85,7 +89,7 @@ function normalizeStairNode(node) {
|
|
|
85
89
|
railingHeight: getFiniteNumber(node.railingHeight, 0.92),
|
|
86
90
|
children: getStringArray(node.children),
|
|
87
91
|
};
|
|
88
|
-
const parsed = StairNodeSchema.safeParse(sanitized);
|
|
92
|
+
const parsed = compiledNodeSchema(StairNodeSchema).safeParse(sanitized);
|
|
89
93
|
if (!parsed.success)
|
|
90
94
|
return null;
|
|
91
95
|
if (hasTotalRise)
|
|
@@ -110,18 +114,18 @@ function normalizeStairSegmentNode(node) {
|
|
|
110
114
|
fillToFloor: getBoolean(node.fillToFloor, true),
|
|
111
115
|
thickness: getFiniteNumber(node.thickness, 0.25),
|
|
112
116
|
};
|
|
113
|
-
const parsed = StairSegmentNodeSchema.safeParse(sanitized);
|
|
117
|
+
const parsed = compiledNodeSchema(StairSegmentNodeSchema).safeParse(sanitized);
|
|
114
118
|
return parsed.success ? parsed.data : null;
|
|
115
119
|
}
|
|
116
120
|
function normalizeDoorNode(node) {
|
|
117
|
-
const parsed = DoorNodeSchema.safeParse(node);
|
|
121
|
+
const parsed = compiledNodeSchema(DoorNodeSchema).safeParse(node);
|
|
118
122
|
return parsed.success ? { ...node, ...parsed.data } : null;
|
|
119
123
|
}
|
|
120
124
|
// Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
|
|
121
125
|
// `frameThickness`) load without it; the mesh builder then reads undefined and
|
|
122
126
|
// throws every frame. Zod-parse on load so schema defaults land, like doors.
|
|
123
127
|
function normalizeWindowNode(node) {
|
|
124
|
-
const parsed = WindowNodeSchema.safeParse(node);
|
|
128
|
+
const parsed = compiledNodeSchema(WindowNodeSchema).safeParse(node);
|
|
125
129
|
return parsed.success ? { ...node, ...parsed.data } : null;
|
|
126
130
|
}
|
|
127
131
|
function normalizeShelfNode(node) {
|
|
@@ -142,7 +146,7 @@ function normalizeShelfNode(node) {
|
|
|
142
146
|
withBottom: getBoolean(node.withBottom, false),
|
|
143
147
|
bracketStyle: getEnumValue(node.bracketStyle, ['minimal', 'industrial', 'hidden'], 'minimal'),
|
|
144
148
|
};
|
|
145
|
-
const parsed = ShelfNodeSchema.safeParse(sanitized);
|
|
149
|
+
const parsed = compiledNodeSchema(ShelfNodeSchema).safeParse(sanitized);
|
|
146
150
|
return parsed.success ? parsed.data : null;
|
|
147
151
|
}
|
|
148
152
|
function normalizeElevatorNode(node) {
|
|
@@ -168,7 +172,7 @@ function normalizeElevatorNode(node) {
|
|
|
168
172
|
doorDurationMs: getFiniteNumber(node.doorDurationMs, 900),
|
|
169
173
|
dwellMs: getFiniteNumber(node.dwellMs, 1400),
|
|
170
174
|
};
|
|
171
|
-
const parsed = ElevatorNodeSchema.safeParse(sanitized);
|
|
175
|
+
const parsed = compiledNodeSchema(ElevatorNodeSchema).safeParse(sanitized);
|
|
172
176
|
return parsed.success ? parsed.data : null;
|
|
173
177
|
}
|
|
174
178
|
function findBuildingIdForLevel(levelId, nodes) {
|
|
@@ -298,6 +302,42 @@ function migrateSingleMaterialSlots(node, slotIds, mintedMaterials) {
|
|
|
298
302
|
slots[slotId] = ref;
|
|
299
303
|
return { ...node, slots, material: undefined, materialPreset: undefined };
|
|
300
304
|
}
|
|
305
|
+
function migrateRoleMaterialSlots(node, roles, mintedMaterials) {
|
|
306
|
+
const slots = { ...(node.slots ?? {}) };
|
|
307
|
+
const next = { ...node };
|
|
308
|
+
let changed = false;
|
|
309
|
+
for (const role of roles) {
|
|
310
|
+
if (slots[role] === undefined) {
|
|
311
|
+
const ref = legacySpecToMaterialRef({
|
|
312
|
+
material: node[`${role}Material`] ?? node.material,
|
|
313
|
+
materialPreset: node[`${role}MaterialPreset`] ?? node.materialPreset,
|
|
314
|
+
}, mintedMaterials);
|
|
315
|
+
if (ref) {
|
|
316
|
+
slots[role] = ref;
|
|
317
|
+
changed = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (`${role}Material` in next || `${role}MaterialPreset` in next)
|
|
321
|
+
changed = true;
|
|
322
|
+
delete next[`${role}Material`];
|
|
323
|
+
delete next[`${role}MaterialPreset`];
|
|
324
|
+
}
|
|
325
|
+
return changed ? { ...next, slots } : node;
|
|
326
|
+
}
|
|
327
|
+
function migrateRenamedSlot(node, previousId, nextId) {
|
|
328
|
+
if (!node.slots || node.slots[previousId] === undefined)
|
|
329
|
+
return node;
|
|
330
|
+
const slots = { ...node.slots };
|
|
331
|
+
if (slots[nextId] === undefined)
|
|
332
|
+
slots[nextId] = slots[previousId];
|
|
333
|
+
delete slots[previousId];
|
|
334
|
+
return { ...node, slots };
|
|
335
|
+
}
|
|
336
|
+
function migrateCupolaLouverSlot(node) {
|
|
337
|
+
if (!node.slots || node.slots.louvers !== undefined || node.slots.body === undefined)
|
|
338
|
+
return node;
|
|
339
|
+
return { ...node, slots: { ...node.slots, louvers: node.slots.body } };
|
|
340
|
+
}
|
|
301
341
|
// Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` /
|
|
302
342
|
// `railingMaterial*`) plus a catch-all. Map each to its slot via the same
|
|
303
343
|
// fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`):
|
|
@@ -485,14 +525,39 @@ function migrateWallAssembly(node) {
|
|
|
485
525
|
const { assemblyLayers: _assemblyLayers, ...wall } = node;
|
|
486
526
|
return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall;
|
|
487
527
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
528
|
+
function migrateBlockRename(id, node, nodes) {
|
|
529
|
+
if (node.type !== 'custom-mesh')
|
|
530
|
+
return [id, node];
|
|
531
|
+
const desiredId = id.startsWith('custom-mesh_') ? `block_${id.slice('custom-mesh_'.length)}` : id;
|
|
532
|
+
let nextId = desiredId;
|
|
533
|
+
let suffix = 1;
|
|
534
|
+
while (nextId !== id && nodes[nextId]) {
|
|
535
|
+
nextId = `${desiredId}_${suffix}`;
|
|
536
|
+
suffix += 1;
|
|
537
|
+
}
|
|
538
|
+
const nextNode = { ...node, id: nextId, type: 'block' };
|
|
539
|
+
if (nextId !== id) {
|
|
540
|
+
for (const candidate of Object.values(nodes)) {
|
|
541
|
+
if (!candidate || typeof candidate !== 'object')
|
|
542
|
+
continue;
|
|
543
|
+
if (candidate.parentId === id)
|
|
544
|
+
candidate.parentId = nextId;
|
|
545
|
+
if (Array.isArray(candidate.children)) {
|
|
546
|
+
candidate.children = candidate.children.map((childId) => childId === id ? nextId : childId);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return [nextId, nextNode];
|
|
551
|
+
}
|
|
552
|
+
function migrateBlockHostedItem(node) {
|
|
553
|
+
if (node.type !== 'item' ||
|
|
554
|
+
node.blockFaceId !== undefined ||
|
|
555
|
+
node.customMeshFaceId === undefined) {
|
|
556
|
+
return node;
|
|
557
|
+
}
|
|
558
|
+
const { customMeshFaceId, ...item } = node;
|
|
559
|
+
return { ...item, blockFaceId: customMeshFaceId };
|
|
560
|
+
}
|
|
496
561
|
function migrateNodes(nodes) {
|
|
497
562
|
// Repair pre-existing corruption (null children, zero-length walls) before
|
|
498
563
|
// any per-type migration runs, so already-saved scenes load cleanly.
|
|
@@ -501,6 +566,13 @@ function migrateNodes(nodes) {
|
|
|
501
566
|
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
|
502
567
|
// merged into the scene material map by the caller (`setScene`).
|
|
503
568
|
const mintedMaterials = {};
|
|
569
|
+
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
570
|
+
const [nextId, nextNode] = migrateBlockRename(id, node, patchedNodes);
|
|
571
|
+
if (nextId !== id) {
|
|
572
|
+
delete patchedNodes[id];
|
|
573
|
+
}
|
|
574
|
+
patchedNodes[nextId] = migrateBlockHostedItem(nextNode);
|
|
575
|
+
}
|
|
504
576
|
// Pass 1: all node types except elevator.
|
|
505
577
|
// Elevator migration (migrateElevatorParent) mutates level.children to remove
|
|
506
578
|
// the elevator ID. If the elevator is processed before its parent level in
|
|
@@ -589,6 +661,34 @@ function migrateNodes(nodes) {
|
|
|
589
661
|
patchedNodes[id] = normalized;
|
|
590
662
|
}
|
|
591
663
|
}
|
|
664
|
+
// Dormers originally rendered one inline parametric window. Promote that
|
|
665
|
+
// default to a real hosted WindowNode so additional windows can use the
|
|
666
|
+
// regular window tool and inspector without changing the old appearance.
|
|
667
|
+
if (node.type === 'dormer') {
|
|
668
|
+
const hasLegacyInlineWindow = !Array.isArray(patchedNodes[id].children);
|
|
669
|
+
if (!hasLegacyInlineWindow)
|
|
670
|
+
continue;
|
|
671
|
+
const dormer = DormerNodeSchema.parse({
|
|
672
|
+
...patchedNodes[id],
|
|
673
|
+
children: getStringArray(patchedNodes[id].children),
|
|
674
|
+
});
|
|
675
|
+
const children = getStringArray(dormer.children);
|
|
676
|
+
const hasHostedWindow = children.some((childId) => patchedNodes[childId]?.type === 'window');
|
|
677
|
+
if (!hasHostedWindow) {
|
|
678
|
+
const baseWindowId = `window_${id.replace(/^dormer_/, '')}_default`;
|
|
679
|
+
let windowId = baseWindowId;
|
|
680
|
+
let suffix = 1;
|
|
681
|
+
while (patchedNodes[windowId]) {
|
|
682
|
+
windowId = `${baseWindowId}_${suffix}`;
|
|
683
|
+
suffix += 1;
|
|
684
|
+
}
|
|
685
|
+
const host = dormer.roofSegmentId ? patchedNodes[dormer.roofSegmentId] : undefined;
|
|
686
|
+
const hostSegment = host?.type === 'roof-segment' ? host : undefined;
|
|
687
|
+
const window = createDormerDefaultWindow(dormer, windowId, getDormerDefaultWindowFace(dormer, hostSegment));
|
|
688
|
+
patchedNodes[windowId] = window;
|
|
689
|
+
patchedNodes[id] = { ...dormer, children: [...children, window.id] };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
592
692
|
if (node.type === 'construction-dimension') {
|
|
593
693
|
patchedNodes[id] = migrateConstructionDimension(node);
|
|
594
694
|
}
|
|
@@ -646,6 +746,26 @@ function migrateNodes(nodes) {
|
|
|
646
746
|
if (node.type === 'column') {
|
|
647
747
|
patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['shaft', 'base', 'capital', 'frame'], mintedMaterials);
|
|
648
748
|
}
|
|
749
|
+
if (node.type === 'gutter') {
|
|
750
|
+
patchedNodes[id] = migrateRenamedSlot(patchedNodes[id], 'surface', 'gutter');
|
|
751
|
+
patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['gutter'], mintedMaterials);
|
|
752
|
+
}
|
|
753
|
+
if (node.type === 'downspout') {
|
|
754
|
+
patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials);
|
|
755
|
+
}
|
|
756
|
+
if (node.type === 'box-vent') {
|
|
757
|
+
patchedNodes[id] = migrateRoleMaterialSlots(patchedNodes[id], ['base', 'top'], mintedMaterials);
|
|
758
|
+
}
|
|
759
|
+
if (node.type === 'cupola') {
|
|
760
|
+
patchedNodes[id] = migrateCupolaLouverSlot(patchedNodes[id]);
|
|
761
|
+
patchedNodes[id] = migrateRoleMaterialSlots(patchedNodes[id], ['base', 'body', 'roof', 'louvers'], mintedMaterials);
|
|
762
|
+
}
|
|
763
|
+
if (node.type === 'eyebrow-vent') {
|
|
764
|
+
patchedNodes[id] = migrateRoleMaterialSlots(patchedNodes[id], ['hood', 'front'], mintedMaterials);
|
|
765
|
+
}
|
|
766
|
+
if (node.type === 'turbine-vent') {
|
|
767
|
+
patchedNodes[id] = migrateRoleMaterialSlots(patchedNodes[id], ['base', 'head'], mintedMaterials);
|
|
768
|
+
}
|
|
649
769
|
if (node.type === 'shelf') {
|
|
650
770
|
const normalized = normalizeShelfNode(node);
|
|
651
771
|
if (normalized) {
|
|
@@ -749,6 +869,7 @@ function migrateNodes(nodes) {
|
|
|
749
869
|
const levelNumber = getFiniteNumber(node.level, 0);
|
|
750
870
|
patchedNodes[id] = {
|
|
751
871
|
...node,
|
|
872
|
+
baseElevation: normalizeLevelBaseElevation(node.baseElevation),
|
|
752
873
|
level: levelNumber,
|
|
753
874
|
children: validChildren,
|
|
754
875
|
};
|
|
@@ -771,155 +892,8 @@ function migrateNodes(nodes) {
|
|
|
771
892
|
patchedNodes[id] = normalized;
|
|
772
893
|
}
|
|
773
894
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
// landed. Computed before this pass mutates anything: the stair-rise
|
|
777
|
-
// cleanup below must never run on already-migrated scenes.
|
|
778
|
-
const isLegacyScene = Object.values(patchedNodes).some((node) => node?.type === 'level' && !('height' in node));
|
|
779
|
-
// 3a. Ordinal renumber — always runs, per building (idempotent
|
|
780
|
-
// self-healing; MCP's create-level historically wrote its elevation PARAM
|
|
781
|
-
// into the ordinal, so fractional/duplicate ordinals exist in the wild).
|
|
782
|
-
const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building');
|
|
783
|
-
const levelsByBuilding = new Map();
|
|
784
|
-
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
785
|
-
if (node?.type !== 'level')
|
|
786
|
-
continue;
|
|
787
|
-
// Mirrors the building resolution in services/storey.ts: an explicit
|
|
788
|
-
// parentId pointing at a building wins, membership in a building's
|
|
789
|
-
// children array is the legacy fallback, and unresolvable levels share
|
|
790
|
-
// one orphan bucket.
|
|
791
|
-
const buildingId = buildingNodes.find((building) => building.id === node.parentId)?.id ??
|
|
792
|
-
buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ??
|
|
793
|
-
null;
|
|
794
|
-
const bucket = levelsByBuilding.get(buildingId) ?? [];
|
|
795
|
-
bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) });
|
|
796
|
-
levelsByBuilding.set(buildingId, bucket);
|
|
797
|
-
}
|
|
798
|
-
for (const bucket of levelsByBuilding.values()) {
|
|
799
|
-
// Anchored at zero on purpose: ordinals are semantic — `level < 0`
|
|
800
|
-
// renders "Basement N" and `level === 0` is the ground-floor default —
|
|
801
|
-
// so negatives compact upward toward −1 and non-negatives compact down
|
|
802
|
-
// to 0. A blind 0..n renumber would rename basements.
|
|
803
|
-
const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal);
|
|
804
|
-
const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length;
|
|
805
|
-
sorted.forEach((entry, index) => {
|
|
806
|
-
const nextOrdinal = index - negativeCount;
|
|
807
|
-
const current = patchedNodes[entry.id];
|
|
808
|
-
if (current.level !== nextOrdinal) {
|
|
809
|
-
patchedNodes[entry.id] = { ...current, level: nextOrdinal };
|
|
810
|
-
}
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
// 3b. Stored storey heights: materialize the legacy stacked height verbatim
|
|
814
|
-
// (never rounded or snapped — snapping would move existing buildings).
|
|
815
|
-
// All planes derive before any wall height below mutates.
|
|
816
|
-
const legacyLevelIds = Object.entries(patchedNodes)
|
|
817
|
-
.filter(([, node]) => node?.type === 'level' && !('height' in node))
|
|
818
|
-
.map(([id]) => id);
|
|
819
|
-
const derivedHeights = new Map();
|
|
820
|
-
for (const levelId of legacyLevelIds) {
|
|
821
|
-
derivedHeights.set(levelId, deriveLegacyLevelHeight(levelId, patchedNodes));
|
|
822
|
-
}
|
|
823
|
-
for (const levelId of legacyLevelIds) {
|
|
824
|
-
const plane = derivedHeights.get(levelId);
|
|
825
|
-
const level = patchedNodes[levelId];
|
|
826
|
-
patchedNodes[levelId] = { ...level, height: plane };
|
|
827
|
-
// 3c. Wall-top classification against the just-written plane, using the
|
|
828
|
-
// same slab-support election as deriveLegacyLevelHeight (call shape
|
|
829
|
-
// mirrored from services/level-height.ts). Walls whose top meets the
|
|
830
|
-
// plane drop their explicit height and follow the level from now on;
|
|
831
|
-
// walls ending short (or tall) keep an explicit height — materializing
|
|
832
|
-
// the 2.5 default onto absent-height walls that end short of the plane.
|
|
833
|
-
const children = getStringArray(level.children)
|
|
834
|
-
.map((childId) => patchedNodes[childId])
|
|
835
|
-
.filter((child) => child !== undefined);
|
|
836
|
-
const slabs = children.filter((child) => child.type === 'slab');
|
|
837
|
-
const walls = children.filter((child) => child.type === 'wall');
|
|
838
|
-
for (const wall of walls) {
|
|
839
|
-
const electedBase = computeWallSlabSupport({
|
|
840
|
-
start: wall.start,
|
|
841
|
-
end: wall.end,
|
|
842
|
-
curveOffset: wall.curveOffset,
|
|
843
|
-
thickness: wall.thickness,
|
|
844
|
-
}, slabs, walls).elevation;
|
|
845
|
-
const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT;
|
|
846
|
-
const top = Math.max(0, electedBase) + effectiveHeight;
|
|
847
|
-
if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) {
|
|
848
|
-
if ('height' in wall) {
|
|
849
|
-
const { height: _height, ...planeBound } = wall;
|
|
850
|
-
patchedNodes[wall.id] = planeBound;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
else {
|
|
854
|
-
patchedNodes[wall.id] = { ...wall, height: effectiveHeight };
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
// 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old
|
|
859
|
-
// schema default, not a user choice — drop it so the rise derives from the
|
|
860
|
-
// storey height. Gated on isLegacyScene because on a post-migration scene
|
|
861
|
-
// a stored 2.5 IS a deliberately typed value and must survive reloads.
|
|
862
|
-
if (isLegacyScene) {
|
|
863
|
-
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
864
|
-
if (node?.type !== 'stair')
|
|
865
|
-
continue;
|
|
866
|
-
if (node.totalRise !== 2.5)
|
|
867
|
-
continue;
|
|
868
|
-
const { totalRise: _totalRise, ...derivedRise } = node;
|
|
869
|
-
patchedNodes[id] = derivedRise;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
// 3e. Slab placement/thickness split. `elevation` stays the walking surface;
|
|
873
|
-
// the new `thickness` grows downward so the solid occupies
|
|
874
|
-
// [elevation − thickness, elevation]. Legacy solids extruded [0, elevation],
|
|
875
|
-
// so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS
|
|
876
|
-
// applies to edits only, never here) keeps the occupied interval identical.
|
|
877
|
-
// Legacy pools (elevation < 0) become explicit `recessed` intent with
|
|
878
|
-
// elevation unchanged. Gated per slab on a missing `thickness` — the
|
|
879
|
-
// migration output is cast, so schema defaults never materialize on load.
|
|
880
|
-
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
881
|
-
if (node?.type !== 'slab' || 'thickness' in node)
|
|
882
|
-
continue;
|
|
883
|
-
const elevation = getFiniteNumber(node.elevation, 0.05);
|
|
884
|
-
patchedNodes[id] =
|
|
885
|
-
elevation < 0
|
|
886
|
-
? { ...node, thickness: 0.05, recessed: true }
|
|
887
|
-
: { ...node, thickness: elevation };
|
|
888
|
-
}
|
|
889
|
-
// 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs
|
|
890
|
-
// after 3b/3e so the clamp bound sees stored level heights and split slab
|
|
891
|
-
// thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its
|
|
892
|
-
// clamp bound (min(storey plane, covering-slab underside) − margin, via
|
|
893
|
-
// getCeilingClampBound) is the legacy default tracking the level top, not
|
|
894
|
-
// a choice — drop it so the ceiling follows the level from now on.
|
|
895
|
-
// autoFromWalls ceilings always convert: their height was derived by the
|
|
896
|
-
// space-detection sync, never user intent. Gated on isLegacyScene, which
|
|
897
|
-
// is exact — nothing shipped between the level-height migration and this
|
|
898
|
-
// one — and makes the step idempotent. Known accepted edge: a
|
|
899
|
-
// post-migration user typing a custom height exactly equal to the bound
|
|
900
|
-
// keeps it (the gate prevents re-classification on later loads).
|
|
901
|
-
if (isLegacyScene) {
|
|
902
|
-
for (const [id, node] of Object.entries(patchedNodes)) {
|
|
903
|
-
if (node?.type !== 'ceiling' || !('height' in node))
|
|
904
|
-
continue;
|
|
905
|
-
const dropHeight = () => {
|
|
906
|
-
const { height: _height, ...follows } = node;
|
|
907
|
-
patchedNodes[id] = follows;
|
|
908
|
-
};
|
|
909
|
-
if (node.autoFromWalls === true) {
|
|
910
|
-
dropHeight();
|
|
911
|
-
continue;
|
|
912
|
-
}
|
|
913
|
-
if (typeof node.parentId !== 'string')
|
|
914
|
-
continue;
|
|
915
|
-
const bound = getCeilingClampBound(node.parentId, patchedNodes, Array.isArray(node.polygon) ? node.polygon : []);
|
|
916
|
-
const stored = getFiniteNumber(node.height, Number.NaN);
|
|
917
|
-
if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) {
|
|
918
|
-
dropHeight();
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
return { nodes: patchedNodes, mintedMaterials };
|
|
895
|
+
const vertical = migrateVerticalSceneNodes(patchedNodes);
|
|
896
|
+
return { nodes: vertical.nodes, mintedMaterials };
|
|
923
897
|
}
|
|
924
898
|
function getNodeChildIds(node) {
|
|
925
899
|
if (!('children' in node && Array.isArray(node.children))) {
|
|
@@ -971,15 +945,170 @@ function collectReachableNodeIds(nodes, rootNodeIds) {
|
|
|
971
945
|
}
|
|
972
946
|
function sceneHistorySnapshotFromState(state) {
|
|
973
947
|
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state;
|
|
974
|
-
|
|
948
|
+
// Fresh placement nodes are renderable drafts, not document history. Excluding their
|
|
949
|
+
// entire subtree here protects both local undo and external commit subscribers.
|
|
950
|
+
const transientNodeIds = new Set();
|
|
951
|
+
for (const node of Object.values(nodes)) {
|
|
952
|
+
const metadata = node.metadata;
|
|
953
|
+
if (metadata &&
|
|
954
|
+
typeof metadata === 'object' &&
|
|
955
|
+
!Array.isArray(metadata) &&
|
|
956
|
+
metadata.isNew === true) {
|
|
957
|
+
transientNodeIds.add(node.id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (transientNodeIds.size === 0) {
|
|
961
|
+
return { nodes, rootNodeIds, collections, materials, installedPlugins };
|
|
962
|
+
}
|
|
963
|
+
const childIdsByParentId = new Map();
|
|
964
|
+
const addChild = (parentId, childId) => {
|
|
965
|
+
const childIds = childIdsByParentId.get(parentId) ?? new Set();
|
|
966
|
+
childIds.add(childId);
|
|
967
|
+
childIdsByParentId.set(parentId, childIds);
|
|
968
|
+
};
|
|
969
|
+
for (const node of Object.values(nodes)) {
|
|
970
|
+
if (node.parentId)
|
|
971
|
+
addChild(node.parentId, node.id);
|
|
972
|
+
for (const childId of getNodeChildIds(node))
|
|
973
|
+
addChild(node.id, childId);
|
|
974
|
+
}
|
|
975
|
+
const pendingIds = [...transientNodeIds];
|
|
976
|
+
while (pendingIds.length > 0) {
|
|
977
|
+
const parentId = pendingIds.pop();
|
|
978
|
+
if (!parentId)
|
|
979
|
+
continue;
|
|
980
|
+
for (const childId of childIdsByParentId.get(parentId) ?? []) {
|
|
981
|
+
if (transientNodeIds.has(childId))
|
|
982
|
+
continue;
|
|
983
|
+
transientNodeIds.add(childId);
|
|
984
|
+
pendingIds.push(childId);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const historyNodes = {};
|
|
988
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
989
|
+
if (transientNodeIds.has(id))
|
|
990
|
+
continue;
|
|
991
|
+
if (!('children' in node && Array.isArray(node.children))) {
|
|
992
|
+
historyNodes[id] = node;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const children = node.children.filter((childId) => !transientNodeIds.has(childId));
|
|
996
|
+
historyNodes[id] =
|
|
997
|
+
children.length === node.children.length ? node : { ...node, children };
|
|
998
|
+
}
|
|
999
|
+
const historyCollections = {};
|
|
1000
|
+
for (const [id, collection] of Object.entries(collections)) {
|
|
1001
|
+
const nodeIds = collection.nodeIds.filter((nodeId) => !transientNodeIds.has(nodeId));
|
|
1002
|
+
if (collection.controlNodeId && transientNodeIds.has(collection.controlNodeId)) {
|
|
1003
|
+
const { controlNodeId: _controlNodeId, ...rest } = collection;
|
|
1004
|
+
historyCollections[id] = { ...rest, nodeIds };
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
historyCollections[id] =
|
|
1008
|
+
nodeIds.length === collection.nodeIds.length ? collection : { ...collection, nodeIds };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
nodes: historyNodes,
|
|
1013
|
+
rootNodeIds: rootNodeIds.filter((id) => !transientNodeIds.has(id)),
|
|
1014
|
+
collections: historyCollections,
|
|
1015
|
+
materials,
|
|
1016
|
+
installedPlugins,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* A dirty mark is a promise that some system will rebuild the node and clear
|
|
1021
|
+
* the mark, so marks are only accepted for kinds with a dirty consumer: kinds
|
|
1022
|
+
* with `dirtyTracking: false` (and kinds of disabled plugins) have none, and
|
|
1023
|
+
* a mark for them would sit in the set for the whole session and defeat every
|
|
1024
|
+
* consumer's empty-set early exit. Ids without a node pass: tools mark nodes
|
|
1025
|
+
* they are about to create.
|
|
1026
|
+
*/
|
|
1027
|
+
function isDirtyTrackable(id, scene) {
|
|
1028
|
+
const node = scene.nodes[id];
|
|
1029
|
+
if (!node)
|
|
1030
|
+
return true;
|
|
1031
|
+
if (!isNodeKindEnabled(node.type, scene.installedPlugins))
|
|
1032
|
+
return false;
|
|
1033
|
+
return nodeRegistry.get(node.type)?.dirtyTracking !== false;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* `markDirty` always applied the consumer-kind guard, but many call sites add
|
|
1037
|
+
* to the raw set directly (that is how stuck `level` marks got in) — enforcing
|
|
1038
|
+
* it in `add` itself keeps them all honest.
|
|
1039
|
+
*/
|
|
1040
|
+
class GuardedDirtySet extends Set {
|
|
1041
|
+
getScene;
|
|
1042
|
+
constructor(getScene, from) {
|
|
1043
|
+
super();
|
|
1044
|
+
this.getScene = getScene;
|
|
1045
|
+
if (from)
|
|
1046
|
+
for (const id of from)
|
|
1047
|
+
this.add(id);
|
|
1048
|
+
}
|
|
1049
|
+
add(id) {
|
|
1050
|
+
if (!isDirtyTrackable(id, this.getScene()))
|
|
1051
|
+
return this;
|
|
1052
|
+
return super.add(id);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function createSceneStore(config) {
|
|
1056
|
+
const hydratedConfig = (set, get, store) => {
|
|
1057
|
+
const setWithHydration = (partial, replace) => {
|
|
1058
|
+
const state = get();
|
|
1059
|
+
let next = typeof partial === 'function' ? partial(state) : partial;
|
|
1060
|
+
const documentChanged = ['nodes', 'rootNodeIds', 'materials', 'collections', 'installedPlugins'].some((key) => (replace || key in next) && next[key] !== state[key]);
|
|
1061
|
+
if (documentChanged && !isHydrationNormalization()) {
|
|
1062
|
+
invalidatePendingHydration();
|
|
1063
|
+
if (state.hydrationToken)
|
|
1064
|
+
next = { ...next, hydrationToken: null };
|
|
1065
|
+
}
|
|
1066
|
+
if (replace)
|
|
1067
|
+
set(next, true);
|
|
1068
|
+
else
|
|
1069
|
+
set(next);
|
|
1070
|
+
};
|
|
1071
|
+
store.setState = setWithHydration;
|
|
1072
|
+
return config(setWithHydration, get, store);
|
|
1073
|
+
};
|
|
1074
|
+
return create()(hydratedConfig);
|
|
1075
|
+
}
|
|
1076
|
+
function runTemporalJump(target, jump) {
|
|
1077
|
+
if (!target?.nodes) {
|
|
1078
|
+
jump();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const before = useScene.getState().nodes;
|
|
1082
|
+
const changed = new Set();
|
|
1083
|
+
for (const id of new Set([...Object.keys(before), ...Object.keys(target.nodes)])) {
|
|
1084
|
+
const nodeId = id;
|
|
1085
|
+
const previous = before[nodeId];
|
|
1086
|
+
const next = target.nodes[nodeId];
|
|
1087
|
+
if (previous === next)
|
|
1088
|
+
continue;
|
|
1089
|
+
// Structural hierarchy changes keep the full-level reconciliation fallback.
|
|
1090
|
+
if ([previous, next].some((node) => node && ['site', 'building', 'level'].includes(node.type))) {
|
|
1091
|
+
jump();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
changed.add(nodeId);
|
|
1095
|
+
}
|
|
1096
|
+
runWithSceneCommitNodeIds(changed, jump);
|
|
975
1097
|
}
|
|
976
|
-
const useScene =
|
|
1098
|
+
const useScene = createSceneStore(temporal((set, get) => ({
|
|
977
1099
|
// 1. Flat dictionary of all nodes
|
|
978
1100
|
nodes: {},
|
|
979
1101
|
// 2. Root node IDs
|
|
980
1102
|
rootNodeIds: [],
|
|
981
1103
|
// 3. Dirty set
|
|
982
|
-
dirtyNodes: new
|
|
1104
|
+
dirtyNodes: new GuardedDirtySet(get),
|
|
1105
|
+
hydrationToken: null,
|
|
1106
|
+
hydrationId: null,
|
|
1107
|
+
invalidateHydration: () => {
|
|
1108
|
+
invalidatePendingHydration();
|
|
1109
|
+
if (get().hydrationToken)
|
|
1110
|
+
set({ hydrationToken: null });
|
|
1111
|
+
},
|
|
983
1112
|
// 4. Collections
|
|
984
1113
|
collections: {},
|
|
985
1114
|
materials: {},
|
|
@@ -989,10 +1118,13 @@ const useScene = create()(temporal((set, get) => ({
|
|
|
989
1118
|
readOnly: false,
|
|
990
1119
|
setReadOnly: (readOnly) => set({ readOnly }),
|
|
991
1120
|
unloadScene: () => {
|
|
1121
|
+
invalidatePendingHydration();
|
|
992
1122
|
set({
|
|
1123
|
+
hydrationToken: null,
|
|
1124
|
+
hydrationId: null,
|
|
993
1125
|
nodes: {},
|
|
994
1126
|
rootNodeIds: [],
|
|
995
|
-
dirtyNodes: new
|
|
1127
|
+
dirtyNodes: new GuardedDirtySet(get),
|
|
996
1128
|
collections: {},
|
|
997
1129
|
materials: {},
|
|
998
1130
|
installedPlugins: [],
|
|
@@ -1035,26 +1167,65 @@ const useScene = create()(temporal((set, get) => ({
|
|
|
1035
1167
|
// pre-write state onto `pastStates`. Writing the scene in two steps
|
|
1036
1168
|
// (as this used to) exposed a half-normalized intermediate state —
|
|
1037
1169
|
// and the pre-load (possibly empty) state — as undo targets.
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1170
|
+
const hydrationId = {};
|
|
1171
|
+
runSceneHydration(() => {
|
|
1172
|
+
set({
|
|
1173
|
+
hydrationToken: null,
|
|
1174
|
+
hydrationId,
|
|
1175
|
+
nodes: cleanedNodes,
|
|
1176
|
+
rootNodeIds: normalizedRootNodeIds,
|
|
1177
|
+
dirtyNodes: new GuardedDirtySet(get),
|
|
1178
|
+
collections: extra?.collections ?? {},
|
|
1179
|
+
materials,
|
|
1180
|
+
installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
|
|
1181
|
+
hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
|
|
1182
|
+
});
|
|
1183
|
+
const applyNormalization = (updates) => {
|
|
1184
|
+
if (updates.length > 0)
|
|
1185
|
+
get().updateNodes(updates);
|
|
1186
|
+
};
|
|
1187
|
+
const hydratedNodes = Object.values(get().nodes);
|
|
1188
|
+
if (!get().readOnly) {
|
|
1189
|
+
pauseSceneHistory(useScene);
|
|
1190
|
+
try {
|
|
1191
|
+
if (hydratedNodes.some((node) => node.type === 'elevator')) {
|
|
1192
|
+
applyNormalization(syncAutoElevatorOpenings(get().nodes));
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
finally {
|
|
1196
|
+
resumeSceneHistory(useScene);
|
|
1197
|
+
}
|
|
1198
|
+
if (hydratedNodes.some((node) => node.type === 'stair')) {
|
|
1199
|
+
// Spatial-grid subscribers must settle first. Owning this pass
|
|
1200
|
+
// here also covers opening systems that mount after the load.
|
|
1201
|
+
queueSceneNormalization(() => {
|
|
1202
|
+
if (get().hydrationId !== hydrationId)
|
|
1203
|
+
return;
|
|
1204
|
+
pauseSceneHistory(useScene);
|
|
1205
|
+
try {
|
|
1206
|
+
applyNormalization(syncStairRises(get().nodes));
|
|
1207
|
+
applyNormalization(syncAutoStairOpenings(get().nodes));
|
|
1208
|
+
}
|
|
1209
|
+
finally {
|
|
1210
|
+
resumeSceneHistory(useScene);
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
// Mark all nodes as dirty to trigger re-validation
|
|
1216
|
+
Object.values(get().nodes).forEach((node) => {
|
|
1217
|
+
get().markDirty(node.id);
|
|
1218
|
+
});
|
|
1219
|
+
}, () => set({ hydrationToken: hydrationId }));
|
|
1051
1220
|
},
|
|
1052
1221
|
setInstalledPlugins: (pluginIds, options) => {
|
|
1053
1222
|
if (get().readOnly)
|
|
1054
1223
|
return;
|
|
1055
1224
|
const nextInstalledPlugins = Array.from(new Set(pluginIds));
|
|
1056
1225
|
const previousInstalledPlugins = get().installedPlugins;
|
|
1057
|
-
|
|
1226
|
+
// Guard against the *next* plugin list: the store still holds the old
|
|
1227
|
+
// one, and re-marks for newly enabled kinds must pass the guard.
|
|
1228
|
+
const dirtyNodes = new GuardedDirtySet(() => ({ nodes: get().nodes, installedPlugins: nextInstalledPlugins }), get().dirtyNodes);
|
|
1058
1229
|
for (const node of Object.values(get().nodes)) {
|
|
1059
1230
|
if (!getNodePluginId(node.type))
|
|
1060
1231
|
continue;
|
|
@@ -1105,10 +1276,9 @@ const useScene = create()(temporal((set, get) => ({
|
|
|
1105
1276
|
set({ nodes, rootNodeIds });
|
|
1106
1277
|
},
|
|
1107
1278
|
markDirty: (id) => {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false)
|
|
1279
|
+
// Guarded here too, not just in GuardedDirtySet.add — tests (and any
|
|
1280
|
+
// setState caller) can swap in a plain Set.
|
|
1281
|
+
if (!isDirtyTrackable(id, get()))
|
|
1112
1282
|
return;
|
|
1113
1283
|
get().dirtyNodes.add(id);
|
|
1114
1284
|
},
|
|
@@ -1258,8 +1428,27 @@ const useScene = create()(temporal((set, get) => ({
|
|
|
1258
1428
|
current: sceneHistorySnapshotFromState(currentState),
|
|
1259
1429
|
});
|
|
1260
1430
|
},
|
|
1431
|
+
wrapTemporal: (config) => (set, get, store) => {
|
|
1432
|
+
const state = config(set, get, store);
|
|
1433
|
+
return {
|
|
1434
|
+
...state,
|
|
1435
|
+
undo: (steps = 1) => runTemporalJump(get().pastStates.slice().splice(-steps, steps)[0], () => state.undo(steps)),
|
|
1436
|
+
redo: (steps = 1) => runTemporalJump(get().futureStates.slice().splice(-steps, steps)[0], () => state.redo(steps)),
|
|
1437
|
+
};
|
|
1438
|
+
},
|
|
1261
1439
|
limit: 50, // Limit to last 50 actions
|
|
1262
1440
|
}));
|
|
1441
|
+
// Live state belongs to the hydration owner so even a lazy consumer cannot
|
|
1442
|
+
// miss an override that was set and cleared before its first frame.
|
|
1443
|
+
const invalidateForLiveState = () => {
|
|
1444
|
+
if (useLiveNodeOverrides.getState().overrides.size ||
|
|
1445
|
+
useLiveTransforms.getState().transforms.size) {
|
|
1446
|
+
useScene.getState().invalidateHydration();
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
useLiveNodeOverrides.subscribe(invalidateForLiveState);
|
|
1450
|
+
useLiveTransforms.subscribe(invalidateForLiveState);
|
|
1451
|
+
useScene.subscribe(invalidateForLiveState);
|
|
1263
1452
|
export default useScene;
|
|
1264
1453
|
let sceneReadOnlyLeaseCount = 0;
|
|
1265
1454
|
let sceneReadOnlyLeaseBaseline = false;
|
|
@@ -1631,6 +1820,8 @@ export function applySceneSnapshot(snapshot, options) {
|
|
|
1631
1820
|
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) {
|
|
1632
1821
|
throw new Error('Cannot replace the scene snapshot during an active interaction');
|
|
1633
1822
|
}
|
|
1823
|
+
useLiveNodeOverrides.getState().clearAll();
|
|
1824
|
+
useLiveTransforms.getState().clearAll();
|
|
1634
1825
|
pauseSceneHistory(useScene);
|
|
1635
1826
|
try {
|
|
1636
1827
|
useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, {
|
|
@@ -1643,18 +1834,15 @@ export function applySceneSnapshot(snapshot, options) {
|
|
|
1643
1834
|
finally {
|
|
1644
1835
|
resumeSceneHistory(useScene);
|
|
1645
1836
|
}
|
|
1646
|
-
useLiveNodeOverrides.getState().clearAll();
|
|
1647
|
-
useLiveTransforms.getState().clearAll();
|
|
1648
1837
|
const current = sceneHistorySnapshotFromState(useScene.getState());
|
|
1649
1838
|
if (areSceneSnapshotsEqual(before, current))
|
|
1650
1839
|
return false;
|
|
1651
1840
|
notifySceneCommit({ origin: options.origin, before, current });
|
|
1652
1841
|
return true;
|
|
1653
1842
|
}
|
|
1654
|
-
// Track previous temporal state lengths
|
|
1843
|
+
// Track previous temporal state lengths for identifying history jumps
|
|
1655
1844
|
let prevPastLength = 0;
|
|
1656
1845
|
let prevFutureLength = 0;
|
|
1657
|
-
let prevNodesSnapshot = null;
|
|
1658
1846
|
export function clearSceneHistory() {
|
|
1659
1847
|
resetSceneHistoryPauseDepth();
|
|
1660
1848
|
// Resetting the pause-depth counter without resuming would strand the
|
|
@@ -1666,10 +1854,14 @@ export function clearSceneHistory() {
|
|
|
1666
1854
|
useScene.temporal.getState().clear();
|
|
1667
1855
|
prevPastLength = 0;
|
|
1668
1856
|
prevFutureLength = 0;
|
|
1669
|
-
prevNodesSnapshot = null;
|
|
1670
1857
|
}
|
|
1671
1858
|
// Subscribe to the temporal store (Undo/Redo events)
|
|
1672
|
-
useScene.temporal.subscribe((state) => {
|
|
1859
|
+
useScene.temporal.subscribe((state, previousState) => {
|
|
1860
|
+
// Zundo mutates its source stack before writing the scene. Reconciliation's
|
|
1861
|
+
// pause/resume notifications must not advance our pre-jump stack lengths.
|
|
1862
|
+
if (state.pastStates === previousState.pastStates &&
|
|
1863
|
+
state.futureStates === previousState.futureStates)
|
|
1864
|
+
return;
|
|
1673
1865
|
const currentPastLength = state.pastStates.length;
|
|
1674
1866
|
const currentFutureLength = state.futureStates.length;
|
|
1675
1867
|
// Undo: futureStates increases (state moved from past to future)
|
|
@@ -1677,40 +1869,21 @@ useScene.temporal.subscribe((state) => {
|
|
|
1677
1869
|
const didUndo = currentFutureLength > prevFutureLength;
|
|
1678
1870
|
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength;
|
|
1679
1871
|
if (didUndo || didRedo) {
|
|
1680
|
-
// Capture
|
|
1681
|
-
|
|
1872
|
+
// Capture both layouts before another synchronous jump can replace them.
|
|
1873
|
+
// The state pushed onto the opposite stack includes history-paused derived
|
|
1874
|
+
// writes (such as stair rise), unlike a snapshot saved at the last edit.
|
|
1875
|
+
const snapshotBefore = didUndo
|
|
1876
|
+
? state.futureStates[prevFutureLength]?.nodes
|
|
1877
|
+
: state.pastStates[prevPastLength]?.nodes;
|
|
1878
|
+
const snapshotAfter = useScene.getState().nodes;
|
|
1682
1879
|
// Defer to a microtask so the scene store has settled before we diff,
|
|
1683
1880
|
// but still mark walls/items dirty before the next paint.
|
|
1684
1881
|
queueMicrotask(() => {
|
|
1685
1882
|
const currentNodes = useScene.getState().nodes;
|
|
1686
1883
|
const { markDirty } = useScene.getState();
|
|
1687
1884
|
if (snapshotBefore) {
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
if (snapshotBefore[id] !== node) {
|
|
1691
|
-
markDirty(id);
|
|
1692
|
-
// Also mark parent so merged geometries update
|
|
1693
|
-
if (node.parentId)
|
|
1694
|
-
markDirty(node.parentId);
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
// Nodes that were deleted (exist in prev but not current)
|
|
1698
|
-
for (const [id, node] of Object.entries(snapshotBefore)) {
|
|
1699
|
-
if (!currentNodes[id]) {
|
|
1700
|
-
const parentId = node.parentId;
|
|
1701
|
-
if (parentId) {
|
|
1702
|
-
markDirty(parentId);
|
|
1703
|
-
// Mark sibling nodes dirty so they can update their geometry
|
|
1704
|
-
// (e.g. adjacent walls need to recalculate miter/junction geometry)
|
|
1705
|
-
const parent = currentNodes[parentId];
|
|
1706
|
-
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
|
1707
|
-
for (const childId of parent.children) {
|
|
1708
|
-
markDirty(childId);
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1885
|
+
for (const id of getHistoryDirtyNodeIds(snapshotBefore, snapshotAfter))
|
|
1886
|
+
markDirty(id);
|
|
1714
1887
|
}
|
|
1715
1888
|
else {
|
|
1716
1889
|
// No snapshot to diff against — fall back to marking all
|
|
@@ -1718,10 +1891,17 @@ useScene.temporal.subscribe((state) => {
|
|
|
1718
1891
|
markDirty(node.id);
|
|
1719
1892
|
}
|
|
1720
1893
|
}
|
|
1894
|
+
// Undo/redo rewrites `nodes` without going through the delete actions,
|
|
1895
|
+
// so marks for nodes that no longer exist would sit in the set for the
|
|
1896
|
+
// rest of the session — no system clears a mark whose node is gone.
|
|
1897
|
+
const { dirtyNodes, clearDirty } = useScene.getState();
|
|
1898
|
+
for (const id of [...dirtyNodes]) {
|
|
1899
|
+
if (!currentNodes[id])
|
|
1900
|
+
clearDirty(id);
|
|
1901
|
+
}
|
|
1721
1902
|
});
|
|
1722
1903
|
}
|
|
1723
|
-
// Update tracked lengths
|
|
1904
|
+
// Update tracked lengths
|
|
1724
1905
|
prevPastLength = currentPastLength;
|
|
1725
1906
|
prevFutureLength = currentFutureLength;
|
|
1726
|
-
prevNodesSnapshot = useScene.getState().nodes;
|
|
1727
1907
|
});
|