@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.
Files changed (263) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/dist/capture/index.d.ts +3 -0
  4. package/dist/capture/index.d.ts.map +1 -0
  5. package/dist/capture/index.js +2 -0
  6. package/dist/capture/schema.d.ts +417 -0
  7. package/dist/capture/schema.d.ts.map +1 -0
  8. package/dist/capture/schema.js +348 -0
  9. package/dist/capture/source.d.ts +60 -0
  10. package/dist/capture/source.d.ts.map +1 -0
  11. package/dist/capture/source.js +254 -0
  12. package/dist/events/bus.d.ts +55 -7
  13. package/dist/events/bus.d.ts.map +1 -1
  14. package/dist/events/hidden-wall-pointer-hold.d.ts +28 -0
  15. package/dist/events/hidden-wall-pointer-hold.d.ts.map +1 -0
  16. package/dist/events/hidden-wall-pointer-hold.js +37 -0
  17. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
  18. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  19. package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
  20. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +2785 -1
  21. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  22. package/dist/hooks/spatial-grid/spatial-grid-sync.js +132 -19
  23. package/dist/hooks/spatial-grid/support-host-patch.d.ts +45 -1
  24. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -1
  25. package/dist/hooks/spatial-grid/support-host-patch.js +171 -6
  26. package/dist/index.d.ts +15 -6
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +17 -5
  29. package/dist/lib/asset-storage.d.ts.map +1 -1
  30. package/dist/lib/asset-storage.js +4 -1
  31. package/dist/lib/polygon-union.d.ts +4 -0
  32. package/dist/lib/polygon-union.d.ts.map +1 -0
  33. package/dist/lib/polygon-union.js +306 -0
  34. package/dist/lib/roof-overlap.d.ts +32 -0
  35. package/dist/lib/roof-overlap.d.ts.map +1 -0
  36. package/dist/lib/roof-overlap.js +72 -0
  37. package/dist/lib/room-topology-index.d.ts +50 -0
  38. package/dist/lib/room-topology-index.d.ts.map +1 -0
  39. package/dist/lib/room-topology-index.js +237 -0
  40. package/dist/lib/slab-polygon.d.ts +35 -2
  41. package/dist/lib/slab-polygon.d.ts.map +1 -1
  42. package/dist/lib/slab-polygon.js +133 -27
  43. package/dist/lib/space-detection.d.ts +40 -3
  44. package/dist/lib/space-detection.d.ts.map +1 -1
  45. package/dist/lib/space-detection.js +890 -246
  46. package/dist/registry/handles.d.ts +24 -3
  47. package/dist/registry/handles.d.ts.map +1 -1
  48. package/dist/registry/index.d.ts +3 -3
  49. package/dist/registry/index.d.ts.map +1 -1
  50. package/dist/registry/index.js +1 -1
  51. package/dist/registry/registry.d.ts +29 -6
  52. package/dist/registry/registry.d.ts.map +1 -1
  53. package/dist/registry/registry.js +111 -4
  54. package/dist/registry/scene-api.d.ts +16 -0
  55. package/dist/registry/scene-api.d.ts.map +1 -1
  56. package/dist/registry/scene-api.js +49 -1
  57. package/dist/registry/types.d.ts +261 -13
  58. package/dist/registry/types.d.ts.map +1 -1
  59. package/dist/registry/use-registry-version.d.ts +11 -0
  60. package/dist/registry/use-registry-version.d.ts.map +1 -0
  61. package/dist/registry/use-registry-version.js +15 -0
  62. package/dist/schema/__bench__/node-parsers.bench.d.ts +24 -0
  63. package/dist/schema/__bench__/node-parsers.bench.d.ts.map +1 -0
  64. package/dist/schema/__bench__/node-parsers.bench.js +314 -0
  65. package/dist/schema/__fixtures__/node-fixtures.d.ts +28 -0
  66. package/dist/schema/__fixtures__/node-fixtures.d.ts.map +1 -0
  67. package/dist/schema/__fixtures__/node-fixtures.js +125 -0
  68. package/dist/schema/base.d.ts +1 -1
  69. package/dist/schema/base.d.ts.map +1 -1
  70. package/dist/schema/base.js +8 -1
  71. package/dist/schema/compiled-node-parsers.d.ts +40 -0
  72. package/dist/schema/compiled-node-parsers.d.ts.map +1 -0
  73. package/dist/schema/compiled-node-parsers.js +95 -0
  74. package/dist/schema/index.d.ts +20 -15
  75. package/dist/schema/index.d.ts.map +1 -1
  76. package/dist/schema/index.js +19 -12
  77. package/dist/schema/material.d.ts +2 -1
  78. package/dist/schema/material.d.ts.map +1 -1
  79. package/dist/schema/material.js +1 -0
  80. package/dist/schema/nodes/automatic-downspout.d.ts +17 -0
  81. package/dist/schema/nodes/automatic-downspout.d.ts.map +1 -0
  82. package/dist/schema/nodes/automatic-downspout.js +237 -0
  83. package/dist/schema/nodes/block.d.ts +107 -0
  84. package/dist/schema/nodes/block.d.ts.map +1 -0
  85. package/dist/schema/nodes/block.js +207 -0
  86. package/dist/schema/nodes/box-vent.d.ts +8 -2
  87. package/dist/schema/nodes/box-vent.d.ts.map +1 -1
  88. package/dist/schema/nodes/box-vent.js +2 -0
  89. package/dist/schema/nodes/building.d.ts +1 -1
  90. package/dist/schema/nodes/cabinet.d.ts +25 -3
  91. package/dist/schema/nodes/cabinet.d.ts.map +1 -1
  92. package/dist/schema/nodes/cabinet.js +26 -5
  93. package/dist/schema/nodes/ceiling.d.ts +1 -1
  94. package/dist/schema/nodes/chimney.d.ts +2 -2
  95. package/dist/schema/nodes/column.d.ts +6 -6
  96. package/dist/schema/nodes/construction-dimension.d.ts +4 -4
  97. package/dist/schema/nodes/cupola.d.ts +9 -1
  98. package/dist/schema/nodes/cupola.d.ts.map +1 -1
  99. package/dist/schema/nodes/cupola.js +2 -0
  100. package/dist/schema/nodes/door.d.ts +1 -1
  101. package/dist/schema/nodes/dormer.d.ts +44 -1
  102. package/dist/schema/nodes/dormer.d.ts.map +1 -1
  103. package/dist/schema/nodes/dormer.js +131 -7
  104. package/dist/schema/nodes/downspout.d.ts +11 -1
  105. package/dist/schema/nodes/downspout.d.ts.map +1 -1
  106. package/dist/schema/nodes/downspout.js +24 -0
  107. package/dist/schema/nodes/duct-fitting.d.ts +18 -1
  108. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -1
  109. package/dist/schema/nodes/duct-fitting.js +25 -4
  110. package/dist/schema/nodes/duct-segment.d.ts +23 -1
  111. package/dist/schema/nodes/duct-segment.d.ts.map +1 -1
  112. package/dist/schema/nodes/duct-segment.js +20 -0
  113. package/dist/schema/nodes/duct-terminal.d.ts +1 -1
  114. package/dist/schema/nodes/elevator.d.ts +1 -1
  115. package/dist/schema/nodes/eyebrow-vent.d.ts +8 -2
  116. package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -1
  117. package/dist/schema/nodes/eyebrow-vent.js +2 -0
  118. package/dist/schema/nodes/fence.d.ts +1 -1
  119. package/dist/schema/nodes/guide.d.ts +1 -1
  120. package/dist/schema/nodes/gutter.d.ts +36 -2
  121. package/dist/schema/nodes/gutter.d.ts.map +1 -1
  122. package/dist/schema/nodes/gutter.js +308 -1
  123. package/dist/schema/nodes/hanger-overrides.d.ts +7 -0
  124. package/dist/schema/nodes/hanger-overrides.d.ts.map +1 -0
  125. package/dist/schema/nodes/hanger-overrides.js +6 -0
  126. package/dist/schema/nodes/hvac-equipment.d.ts +1 -1
  127. package/dist/schema/nodes/item.d.ts +4 -3
  128. package/dist/schema/nodes/item.d.ts.map +1 -1
  129. package/dist/schema/nodes/item.js +5 -0
  130. package/dist/schema/nodes/lean-to-extension.d.ts +212 -0
  131. package/dist/schema/nodes/lean-to-extension.d.ts.map +1 -0
  132. package/dist/schema/nodes/lean-to-extension.js +114 -0
  133. package/dist/schema/nodes/level.d.ts +5 -2
  134. package/dist/schema/nodes/level.d.ts.map +1 -1
  135. package/dist/schema/nodes/level.js +9 -0
  136. package/dist/schema/nodes/lineset.d.ts +1 -1
  137. package/dist/schema/nodes/liquid-line.d.ts +1 -1
  138. package/dist/schema/nodes/measurement.d.ts +1 -1
  139. package/dist/schema/nodes/pipe-fitting.d.ts +9 -1
  140. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -1
  141. package/dist/schema/nodes/pipe-fitting.js +6 -2
  142. package/dist/schema/nodes/pipe-segment.d.ts +23 -1
  143. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -1
  144. package/dist/schema/nodes/pipe-segment.js +20 -0
  145. package/dist/schema/nodes/pipe-trap.d.ts +1 -1
  146. package/dist/schema/nodes/ridge-vent.d.ts +1 -1
  147. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  148. package/dist/schema/nodes/ridge-vent.js +4 -1
  149. package/dist/schema/nodes/roof-segment-shape.d.ts +4 -0
  150. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -1
  151. package/dist/schema/nodes/roof-segment-shape.js +64 -1
  152. package/dist/schema/nodes/roof-segment-walls.d.ts +3 -1
  153. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -1
  154. package/dist/schema/nodes/roof-segment-walls.js +1 -1
  155. package/dist/schema/nodes/roof-segment.d.ts +41 -1
  156. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  157. package/dist/schema/nodes/roof-segment.js +79 -2
  158. package/dist/schema/nodes/roof.d.ts +22 -1
  159. package/dist/schema/nodes/roof.d.ts.map +1 -1
  160. package/dist/schema/nodes/roof.js +14 -0
  161. package/dist/schema/nodes/scan.d.ts +19 -2
  162. package/dist/schema/nodes/scan.d.ts.map +1 -1
  163. package/dist/schema/nodes/scan.js +11 -1
  164. package/dist/schema/nodes/shelf.d.ts +2 -2
  165. package/dist/schema/nodes/site.d.ts +1 -1
  166. package/dist/schema/nodes/skylight.d.ts +1 -1
  167. package/dist/schema/nodes/slab.d.ts +1 -1
  168. package/dist/schema/nodes/solar-panel.d.ts +1 -1
  169. package/dist/schema/nodes/spawn.d.ts +1 -1
  170. package/dist/schema/nodes/stair-segment.d.ts +1 -1
  171. package/dist/schema/nodes/stair.d.ts +1 -1
  172. package/dist/schema/nodes/structural-grid.d.ts +1 -1
  173. package/dist/schema/nodes/turbine-vent.d.ts +7 -1
  174. package/dist/schema/nodes/turbine-vent.d.ts.map +1 -1
  175. package/dist/schema/nodes/turbine-vent.js +2 -0
  176. package/dist/schema/nodes/wall.d.ts +4 -4
  177. package/dist/schema/nodes/wall.d.ts.map +1 -1
  178. package/dist/schema/nodes/wall.js +7 -1
  179. package/dist/schema/nodes/window.d.ts +10 -3
  180. package/dist/schema/nodes/window.d.ts.map +1 -1
  181. package/dist/schema/nodes/window.js +4 -0
  182. package/dist/schema/nodes/zone.d.ts +2 -2
  183. package/dist/schema/nodes/zone.js +1 -1
  184. package/dist/schema/types.d.ts +485 -109
  185. package/dist/schema/types.d.ts.map +1 -1
  186. package/dist/schema/types.js +27 -1
  187. package/dist/services/hosting.d.ts +2 -2
  188. package/dist/services/hosting.d.ts.map +1 -1
  189. package/dist/services/hosting.js +4 -4
  190. package/dist/services/index.d.ts +2 -2
  191. package/dist/services/index.d.ts.map +1 -1
  192. package/dist/services/index.js +2 -2
  193. package/dist/services/storey.d.ts +3 -10
  194. package/dist/services/storey.d.ts.map +1 -1
  195. package/dist/services/storey.js +45 -15
  196. package/dist/services/system-graph.d.ts +11 -0
  197. package/dist/services/system-graph.d.ts.map +1 -1
  198. package/dist/services/system-graph.js +35 -5
  199. package/dist/store/actions/node-actions.d.ts +15 -4
  200. package/dist/store/actions/node-actions.d.ts.map +1 -1
  201. package/dist/store/actions/node-actions.js +458 -19
  202. package/dist/store/history-control.d.ts +5 -0
  203. package/dist/store/history-control.d.ts.map +1 -1
  204. package/dist/store/history-control.js +68 -6
  205. package/dist/store/history-invalidation.d.ts +3 -0
  206. package/dist/store/history-invalidation.d.ts.map +1 -0
  207. package/dist/store/history-invalidation.js +72 -0
  208. package/dist/store/scene-hydration.d.ts +5 -0
  209. package/dist/store/scene-hydration.d.ts.map +1 -0
  210. package/dist/store/scene-hydration.js +57 -0
  211. package/dist/store/use-scene.d.ts +3 -0
  212. package/dist/store/use-scene.d.ts.map +1 -1
  213. package/dist/store/use-scene.js +407 -227
  214. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  215. package/dist/systems/elevator/elevator-service.js +16 -11
  216. package/dist/systems/roof/roof-elevation-system.d.ts +3 -0
  217. package/dist/systems/roof/roof-elevation-system.d.ts.map +1 -0
  218. package/dist/systems/roof/roof-elevation-system.js +94 -0
  219. package/dist/systems/roof/roof-elevation.d.ts +4 -0
  220. package/dist/systems/roof/roof-elevation.d.ts.map +1 -0
  221. package/dist/systems/roof/roof-elevation.js +95 -0
  222. package/dist/systems/roof/roof-footprint.d.ts +17 -0
  223. package/dist/systems/roof/roof-footprint.d.ts.map +1 -0
  224. package/dist/systems/roof/roof-footprint.js +83 -0
  225. package/dist/systems/slab/slab-support.d.ts +0 -31
  226. package/dist/systems/slab/slab-support.d.ts.map +1 -1
  227. package/dist/systems/slab/slab-support.js +26 -4
  228. package/dist/systems/stair/stair-flight.d.ts +17 -0
  229. package/dist/systems/stair/stair-flight.d.ts.map +1 -0
  230. package/dist/systems/stair/stair-flight.js +27 -0
  231. package/dist/systems/stair/stair-opening-preview.d.ts +1556 -1328
  232. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  233. package/dist/systems/stair/stair-opening-system.d.ts +1 -0
  234. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  235. package/dist/systems/stair/stair-opening-system.js +93 -96
  236. package/dist/systems/stair/stair-rise.js +21 -20
  237. package/dist/systems/wall/wall-curve.d.ts +1 -0
  238. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  239. package/dist/systems/wall/wall-curve.js +73 -0
  240. package/dist/systems/wall/wall-footprint.d.ts +1 -0
  241. package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
  242. package/dist/systems/wall/wall-footprint.js +1 -0
  243. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  244. package/dist/systems/wall/wall-mitering.js +88 -9
  245. package/dist/systems/wall/wall-topology.d.ts +53 -0
  246. package/dist/systems/wall/wall-topology.d.ts.map +1 -0
  247. package/dist/systems/wall/wall-topology.js +414 -0
  248. package/dist/utils/clone-scene-graph.d.ts +2 -0
  249. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  250. package/dist/utils/clone-scene-graph.js +39 -2
  251. package/dist/utils/scene-migrations.d.ts +1 -0
  252. package/dist/utils/scene-migrations.d.ts.map +1 -1
  253. package/dist/utils/scene-migrations.js +1 -0
  254. package/dist/utils/vertical-scene-migration.d.ts +13 -0
  255. package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
  256. package/dist/utils/vertical-scene-migration.js +179 -0
  257. package/dist/validation/validate-build-json.d.ts +3 -0
  258. package/dist/validation/validate-build-json.d.ts.map +1 -1
  259. package/dist/validation/validate-build-json.js +49 -2
  260. package/package.json +10 -3
  261. package/dist/systems/elevator/elevator-runtime-system.d.ts +0 -2
  262. package/dist/systems/elevator/elevator-runtime-system.d.ts.map +0 -1
  263. package/dist/systems/elevator/elevator-runtime-system.js +0 -8
@@ -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 { deriveLegacyLevelHeight } from '../services/level-height';
22
- import { getCeilingClampBound } from '../services/storey';
23
- import { computeWallSlabSupport } from '../systems/slab/slab-support';
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
- // Walls whose top lands within this of the storey plane become plane-bound;
489
- // ceilings whose stored height lands within this of their clamp bound become
490
- // follows-mode (step 3f) — same census-backed threshold for both.
491
- // From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to
492
- // a taller wall) must snap to the plane, while intentional 0.20-short walls
493
- // (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit
494
- // height hence 0.20 with a strictly-less-than comparison.
495
- const PLANE_BOUND_EPSILON = 0.2;
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
- // Pass 3: vertical building model.
775
- // A level without `height` marks a scene saved before the vertical model
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
- return { nodes, rootNodeIds, collections, materials, installedPlugins };
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 = create()(temporal((set, get) => ({
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 Set(),
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 Set(),
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
- set({
1039
- nodes: cleanedNodes,
1040
- rootNodeIds: normalizedRootNodeIds,
1041
- dirtyNodes: new Set(),
1042
- collections: extra?.collections ?? {},
1043
- materials,
1044
- installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
1045
- hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
1046
- });
1047
- // Mark all nodes as dirty to trigger re-validation
1048
- Object.values(cleanedNodes).forEach((node) => {
1049
- get().markDirty(node.id);
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
- const dirtyNodes = new Set(get().dirtyNodes);
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
- const node = get().nodes[id];
1109
- if (node && !isNodeKindEnabled(node.type, get().installedPlugins))
1110
- return;
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 and node snapshot for diffing
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 the previous snapshot before RAF fires
1681
- const snapshotBefore = prevNodesSnapshot;
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
- // Diff: only mark nodes that actually changed
1689
- for (const [id, node] of Object.entries(currentNodes)) {
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 and snapshot
1904
+ // Update tracked lengths
1724
1905
  prevPastLength = currentPastLength;
1725
1906
  prevFutureLength = currentFutureLength;
1726
- prevNodesSnapshot = useScene.getState().nodes;
1727
1907
  });