@pascal-app/core 1.0.0-beta.5 → 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 (231) 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-sync.d.ts +2785 -1
  18. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  19. package/dist/hooks/spatial-grid/spatial-grid-sync.js +132 -19
  20. package/dist/hooks/spatial-grid/support-host-patch.d.ts +45 -1
  21. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -1
  22. package/dist/hooks/spatial-grid/support-host-patch.js +171 -6
  23. package/dist/index.d.ts +14 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -5
  26. package/dist/lib/asset-storage.d.ts.map +1 -1
  27. package/dist/lib/asset-storage.js +4 -1
  28. package/dist/lib/polygon-union.d.ts +4 -0
  29. package/dist/lib/polygon-union.d.ts.map +1 -0
  30. package/dist/lib/polygon-union.js +306 -0
  31. package/dist/lib/roof-overlap.d.ts +32 -0
  32. package/dist/lib/roof-overlap.d.ts.map +1 -0
  33. package/dist/lib/roof-overlap.js +72 -0
  34. package/dist/lib/slab-polygon.d.ts +35 -2
  35. package/dist/lib/slab-polygon.d.ts.map +1 -1
  36. package/dist/lib/slab-polygon.js +133 -27
  37. package/dist/lib/space-detection.d.ts +11 -0
  38. package/dist/lib/space-detection.d.ts.map +1 -1
  39. package/dist/lib/space-detection.js +42 -14
  40. package/dist/registry/handles.d.ts +24 -3
  41. package/dist/registry/handles.d.ts.map +1 -1
  42. package/dist/registry/index.d.ts +3 -4
  43. package/dist/registry/index.d.ts.map +1 -1
  44. package/dist/registry/index.js +1 -2
  45. package/dist/registry/registry.d.ts +13 -6
  46. package/dist/registry/registry.d.ts.map +1 -1
  47. package/dist/registry/registry.js +37 -4
  48. package/dist/registry/scene-api.d.ts +16 -0
  49. package/dist/registry/scene-api.d.ts.map +1 -1
  50. package/dist/registry/scene-api.js +49 -1
  51. package/dist/registry/types.d.ts +226 -13
  52. package/dist/registry/types.d.ts.map +1 -1
  53. package/dist/schema/__bench__/node-parsers.bench.d.ts +24 -0
  54. package/dist/schema/__bench__/node-parsers.bench.d.ts.map +1 -0
  55. package/dist/schema/__bench__/node-parsers.bench.js +314 -0
  56. package/dist/schema/__fixtures__/node-fixtures.d.ts +28 -0
  57. package/dist/schema/__fixtures__/node-fixtures.d.ts.map +1 -0
  58. package/dist/schema/__fixtures__/node-fixtures.js +125 -0
  59. package/dist/schema/base.d.ts +1 -1
  60. package/dist/schema/base.d.ts.map +1 -1
  61. package/dist/schema/base.js +8 -1
  62. package/dist/schema/compiled-node-parsers.d.ts +40 -0
  63. package/dist/schema/compiled-node-parsers.d.ts.map +1 -0
  64. package/dist/schema/compiled-node-parsers.js +95 -0
  65. package/dist/schema/index.d.ts +19 -15
  66. package/dist/schema/index.d.ts.map +1 -1
  67. package/dist/schema/index.js +17 -12
  68. package/dist/schema/material.d.ts +2 -1
  69. package/dist/schema/material.d.ts.map +1 -1
  70. package/dist/schema/material.js +1 -0
  71. package/dist/schema/nodes/automatic-downspout.d.ts +17 -0
  72. package/dist/schema/nodes/automatic-downspout.d.ts.map +1 -0
  73. package/dist/schema/nodes/automatic-downspout.js +237 -0
  74. package/dist/schema/nodes/block.d.ts +107 -0
  75. package/dist/schema/nodes/block.d.ts.map +1 -0
  76. package/dist/schema/nodes/block.js +207 -0
  77. package/dist/schema/nodes/box-vent.d.ts +8 -2
  78. package/dist/schema/nodes/box-vent.d.ts.map +1 -1
  79. package/dist/schema/nodes/box-vent.js +2 -0
  80. package/dist/schema/nodes/building.d.ts +1 -1
  81. package/dist/schema/nodes/cabinet.d.ts +25 -3
  82. package/dist/schema/nodes/cabinet.d.ts.map +1 -1
  83. package/dist/schema/nodes/cabinet.js +26 -5
  84. package/dist/schema/nodes/ceiling.d.ts +1 -1
  85. package/dist/schema/nodes/chimney.d.ts +2 -2
  86. package/dist/schema/nodes/column.d.ts +6 -6
  87. package/dist/schema/nodes/construction-dimension.d.ts +4 -4
  88. package/dist/schema/nodes/cupola.d.ts +9 -1
  89. package/dist/schema/nodes/cupola.d.ts.map +1 -1
  90. package/dist/schema/nodes/cupola.js +2 -0
  91. package/dist/schema/nodes/door.d.ts +1 -1
  92. package/dist/schema/nodes/dormer.d.ts +44 -1
  93. package/dist/schema/nodes/dormer.d.ts.map +1 -1
  94. package/dist/schema/nodes/dormer.js +131 -7
  95. package/dist/schema/nodes/downspout.d.ts +11 -1
  96. package/dist/schema/nodes/downspout.d.ts.map +1 -1
  97. package/dist/schema/nodes/downspout.js +24 -0
  98. package/dist/schema/nodes/duct-fitting.d.ts +18 -1
  99. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -1
  100. package/dist/schema/nodes/duct-fitting.js +25 -4
  101. package/dist/schema/nodes/duct-segment.d.ts +23 -1
  102. package/dist/schema/nodes/duct-segment.d.ts.map +1 -1
  103. package/dist/schema/nodes/duct-segment.js +20 -0
  104. package/dist/schema/nodes/duct-terminal.d.ts +1 -1
  105. package/dist/schema/nodes/elevator.d.ts +1 -1
  106. package/dist/schema/nodes/eyebrow-vent.d.ts +8 -2
  107. package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -1
  108. package/dist/schema/nodes/eyebrow-vent.js +2 -0
  109. package/dist/schema/nodes/fence.d.ts +1 -1
  110. package/dist/schema/nodes/guide.d.ts +1 -1
  111. package/dist/schema/nodes/gutter.d.ts +36 -2
  112. package/dist/schema/nodes/gutter.d.ts.map +1 -1
  113. package/dist/schema/nodes/gutter.js +308 -1
  114. package/dist/schema/nodes/hanger-overrides.d.ts +7 -0
  115. package/dist/schema/nodes/hanger-overrides.d.ts.map +1 -0
  116. package/dist/schema/nodes/hanger-overrides.js +6 -0
  117. package/dist/schema/nodes/hvac-equipment.d.ts +1 -1
  118. package/dist/schema/nodes/item.d.ts +4 -3
  119. package/dist/schema/nodes/item.d.ts.map +1 -1
  120. package/dist/schema/nodes/item.js +5 -0
  121. package/dist/schema/nodes/lean-to-extension.d.ts +212 -0
  122. package/dist/schema/nodes/lean-to-extension.d.ts.map +1 -0
  123. package/dist/schema/nodes/lean-to-extension.js +114 -0
  124. package/dist/schema/nodes/level.d.ts +2 -2
  125. package/dist/schema/nodes/level.d.ts.map +1 -1
  126. package/dist/schema/nodes/lineset.d.ts +1 -1
  127. package/dist/schema/nodes/liquid-line.d.ts +1 -1
  128. package/dist/schema/nodes/measurement.d.ts +1 -1
  129. package/dist/schema/nodes/pipe-fitting.d.ts +9 -1
  130. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -1
  131. package/dist/schema/nodes/pipe-fitting.js +6 -2
  132. package/dist/schema/nodes/pipe-segment.d.ts +23 -1
  133. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -1
  134. package/dist/schema/nodes/pipe-segment.js +20 -0
  135. package/dist/schema/nodes/pipe-trap.d.ts +1 -1
  136. package/dist/schema/nodes/ridge-vent.d.ts +1 -1
  137. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  138. package/dist/schema/nodes/ridge-vent.js +4 -1
  139. package/dist/schema/nodes/roof-segment-shape.d.ts +4 -0
  140. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -1
  141. package/dist/schema/nodes/roof-segment-shape.js +64 -1
  142. package/dist/schema/nodes/roof-segment-walls.d.ts +3 -1
  143. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -1
  144. package/dist/schema/nodes/roof-segment-walls.js +1 -1
  145. package/dist/schema/nodes/roof-segment.d.ts +41 -1
  146. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  147. package/dist/schema/nodes/roof-segment.js +79 -2
  148. package/dist/schema/nodes/roof.d.ts +22 -1
  149. package/dist/schema/nodes/roof.d.ts.map +1 -1
  150. package/dist/schema/nodes/roof.js +14 -0
  151. package/dist/schema/nodes/scan.d.ts +19 -2
  152. package/dist/schema/nodes/scan.d.ts.map +1 -1
  153. package/dist/schema/nodes/scan.js +11 -1
  154. package/dist/schema/nodes/shelf.d.ts +2 -2
  155. package/dist/schema/nodes/site.d.ts +1 -1
  156. package/dist/schema/nodes/skylight.d.ts +1 -1
  157. package/dist/schema/nodes/slab.d.ts +1 -1
  158. package/dist/schema/nodes/solar-panel.d.ts +1 -1
  159. package/dist/schema/nodes/spawn.d.ts +1 -1
  160. package/dist/schema/nodes/stair-segment.d.ts +1 -1
  161. package/dist/schema/nodes/stair.d.ts +1 -1
  162. package/dist/schema/nodes/structural-grid.d.ts +1 -1
  163. package/dist/schema/nodes/turbine-vent.d.ts +7 -1
  164. package/dist/schema/nodes/turbine-vent.d.ts.map +1 -1
  165. package/dist/schema/nodes/turbine-vent.js +2 -0
  166. package/dist/schema/nodes/wall.d.ts +4 -4
  167. package/dist/schema/nodes/wall.d.ts.map +1 -1
  168. package/dist/schema/nodes/wall.js +7 -1
  169. package/dist/schema/nodes/window.d.ts +10 -3
  170. package/dist/schema/nodes/window.d.ts.map +1 -1
  171. package/dist/schema/nodes/window.js +4 -0
  172. package/dist/schema/nodes/zone.d.ts +2 -2
  173. package/dist/schema/nodes/zone.js +1 -1
  174. package/dist/schema/types.d.ts +484 -109
  175. package/dist/schema/types.d.ts.map +1 -1
  176. package/dist/schema/types.js +27 -1
  177. package/dist/services/hosting.d.ts +2 -2
  178. package/dist/services/hosting.d.ts.map +1 -1
  179. package/dist/services/hosting.js +4 -4
  180. package/dist/services/index.d.ts +1 -1
  181. package/dist/services/index.d.ts.map +1 -1
  182. package/dist/services/index.js +1 -1
  183. package/dist/services/system-graph.d.ts +11 -0
  184. package/dist/services/system-graph.d.ts.map +1 -1
  185. package/dist/services/system-graph.js +35 -5
  186. package/dist/store/actions/node-actions.d.ts +7 -0
  187. package/dist/store/actions/node-actions.d.ts.map +1 -1
  188. package/dist/store/actions/node-actions.js +429 -17
  189. package/dist/store/history-invalidation.d.ts +3 -0
  190. package/dist/store/history-invalidation.d.ts.map +1 -0
  191. package/dist/store/history-invalidation.js +72 -0
  192. package/dist/store/scene-hydration.d.ts +5 -0
  193. package/dist/store/scene-hydration.d.ts.map +1 -0
  194. package/dist/store/scene-hydration.js +57 -0
  195. package/dist/store/use-scene.d.ts +3 -0
  196. package/dist/store/use-scene.d.ts.map +1 -1
  197. package/dist/store/use-scene.js +332 -64
  198. package/dist/systems/roof/roof-elevation-system.d.ts +3 -0
  199. package/dist/systems/roof/roof-elevation-system.d.ts.map +1 -0
  200. package/dist/systems/roof/roof-elevation-system.js +94 -0
  201. package/dist/systems/roof/roof-elevation.d.ts +4 -0
  202. package/dist/systems/roof/roof-elevation.d.ts.map +1 -0
  203. package/dist/systems/roof/roof-elevation.js +95 -0
  204. package/dist/systems/roof/roof-footprint.d.ts +17 -0
  205. package/dist/systems/roof/roof-footprint.d.ts.map +1 -0
  206. package/dist/systems/roof/roof-footprint.js +83 -0
  207. package/dist/systems/stair/stair-flight.d.ts +17 -0
  208. package/dist/systems/stair/stair-flight.d.ts.map +1 -0
  209. package/dist/systems/stair/stair-flight.js +27 -0
  210. package/dist/systems/stair/stair-opening-preview.d.ts +1555 -1328
  211. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  212. package/dist/systems/stair/stair-opening-system.d.ts +1 -0
  213. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  214. package/dist/systems/stair/stair-opening-system.js +93 -96
  215. package/dist/systems/stair/stair-rise.d.ts.map +1 -1
  216. package/dist/systems/stair/stair-rise.js +20 -21
  217. package/dist/systems/wall/wall-curve.d.ts +1 -0
  218. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  219. package/dist/systems/wall/wall-curve.js +73 -0
  220. package/dist/systems/wall/wall-footprint.d.ts +1 -0
  221. package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
  222. package/dist/systems/wall/wall-footprint.js +1 -0
  223. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  224. package/dist/utils/clone-scene-graph.js +26 -0
  225. package/dist/validation/validate-build-json.d.ts +3 -0
  226. package/dist/validation/validate-build-json.d.ts.map +1 -1
  227. package/dist/validation/validate-build-json.js +49 -2
  228. package/package.json +10 -3
  229. package/dist/systems/elevator/elevator-runtime-system.d.ts +0 -2
  230. package/dist/systems/elevator/elevator-runtime-system.d.ts.map +0 -1
  231. package/dist/systems/elevator/elevator-runtime-system.js +0 -8
@@ -5,7 +5,9 @@ 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
12
  import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level';
11
13
  import { getPitchFromActiveRoofHeight, } from '../schema/nodes/roof-segment';
@@ -18,11 +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';
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';
21
26
  import { healSceneNodes } from '../utils/heal-scene-graph';
22
27
  import { removeRetiredDrawingSheetNodes } from '../utils/retired-scene-nodes';
23
28
  import { migrateVerticalSceneNodes } from '../utils/vertical-scene-migration';
24
29
  import * as nodeActions from './actions/node-actions';
25
- 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';
26
33
  import useLiveNodeOverrides from './use-live-node-overrides';
27
34
  import useLiveTransforms from './use-live-transforms';
28
35
  function getFiniteNumber(value, fallback) {
@@ -82,7 +89,7 @@ function normalizeStairNode(node) {
82
89
  railingHeight: getFiniteNumber(node.railingHeight, 0.92),
83
90
  children: getStringArray(node.children),
84
91
  };
85
- const parsed = StairNodeSchema.safeParse(sanitized);
92
+ const parsed = compiledNodeSchema(StairNodeSchema).safeParse(sanitized);
86
93
  if (!parsed.success)
87
94
  return null;
88
95
  if (hasTotalRise)
@@ -107,18 +114,18 @@ function normalizeStairSegmentNode(node) {
107
114
  fillToFloor: getBoolean(node.fillToFloor, true),
108
115
  thickness: getFiniteNumber(node.thickness, 0.25),
109
116
  };
110
- const parsed = StairSegmentNodeSchema.safeParse(sanitized);
117
+ const parsed = compiledNodeSchema(StairSegmentNodeSchema).safeParse(sanitized);
111
118
  return parsed.success ? parsed.data : null;
112
119
  }
113
120
  function normalizeDoorNode(node) {
114
- const parsed = DoorNodeSchema.safeParse(node);
121
+ const parsed = compiledNodeSchema(DoorNodeSchema).safeParse(node);
115
122
  return parsed.success ? { ...node, ...parsed.data } : null;
116
123
  }
117
124
  // Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
118
125
  // `frameThickness`) load without it; the mesh builder then reads undefined and
119
126
  // throws every frame. Zod-parse on load so schema defaults land, like doors.
120
127
  function normalizeWindowNode(node) {
121
- const parsed = WindowNodeSchema.safeParse(node);
128
+ const parsed = compiledNodeSchema(WindowNodeSchema).safeParse(node);
122
129
  return parsed.success ? { ...node, ...parsed.data } : null;
123
130
  }
124
131
  function normalizeShelfNode(node) {
@@ -139,7 +146,7 @@ function normalizeShelfNode(node) {
139
146
  withBottom: getBoolean(node.withBottom, false),
140
147
  bracketStyle: getEnumValue(node.bracketStyle, ['minimal', 'industrial', 'hidden'], 'minimal'),
141
148
  };
142
- const parsed = ShelfNodeSchema.safeParse(sanitized);
149
+ const parsed = compiledNodeSchema(ShelfNodeSchema).safeParse(sanitized);
143
150
  return parsed.success ? parsed.data : null;
144
151
  }
145
152
  function normalizeElevatorNode(node) {
@@ -165,7 +172,7 @@ function normalizeElevatorNode(node) {
165
172
  doorDurationMs: getFiniteNumber(node.doorDurationMs, 900),
166
173
  dwellMs: getFiniteNumber(node.dwellMs, 1400),
167
174
  };
168
- const parsed = ElevatorNodeSchema.safeParse(sanitized);
175
+ const parsed = compiledNodeSchema(ElevatorNodeSchema).safeParse(sanitized);
169
176
  return parsed.success ? parsed.data : null;
170
177
  }
171
178
  function findBuildingIdForLevel(levelId, nodes) {
@@ -295,6 +302,42 @@ function migrateSingleMaterialSlots(node, slotIds, mintedMaterials) {
295
302
  slots[slotId] = ref;
296
303
  return { ...node, slots, material: undefined, materialPreset: undefined };
297
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
+ }
298
341
  // Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` /
299
342
  // `railingMaterial*`) plus a catch-all. Map each to its slot via the same
300
343
  // fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`):
@@ -482,6 +525,39 @@ function migrateWallAssembly(node) {
482
525
  const { assemblyLayers: _assemblyLayers, ...wall } = node;
483
526
  return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall;
484
527
  }
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
+ }
485
561
  function migrateNodes(nodes) {
486
562
  // Repair pre-existing corruption (null children, zero-length walls) before
487
563
  // any per-type migration runs, so already-saved scenes load cleanly.
@@ -490,6 +566,13 @@ function migrateNodes(nodes) {
490
566
  // Scene materials minted while moving legacy wall fields onto `node.slots`;
491
567
  // merged into the scene material map by the caller (`setScene`).
492
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
+ }
493
576
  // Pass 1: all node types except elevator.
494
577
  // Elevator migration (migrateElevatorParent) mutates level.children to remove
495
578
  // the elevator ID. If the elevator is processed before its parent level in
@@ -578,6 +661,34 @@ function migrateNodes(nodes) {
578
661
  patchedNodes[id] = normalized;
579
662
  }
580
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
+ }
581
692
  if (node.type === 'construction-dimension') {
582
693
  patchedNodes[id] = migrateConstructionDimension(node);
583
694
  }
@@ -635,6 +746,26 @@ function migrateNodes(nodes) {
635
746
  if (node.type === 'column') {
636
747
  patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['shaft', 'base', 'capital', 'frame'], mintedMaterials);
637
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
+ }
638
769
  if (node.type === 'shelf') {
639
770
  const normalized = normalizeShelfNode(node);
640
771
  if (normalized) {
@@ -885,13 +1016,99 @@ function sceneHistorySnapshotFromState(state) {
885
1016
  installedPlugins,
886
1017
  };
887
1018
  }
888
- const useScene = create()(temporal((set, get) => ({
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);
1097
+ }
1098
+ const useScene = createSceneStore(temporal((set, get) => ({
889
1099
  // 1. Flat dictionary of all nodes
890
1100
  nodes: {},
891
1101
  // 2. Root node IDs
892
1102
  rootNodeIds: [],
893
1103
  // 3. Dirty set
894
- 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
+ },
895
1112
  // 4. Collections
896
1113
  collections: {},
897
1114
  materials: {},
@@ -901,10 +1118,13 @@ const useScene = create()(temporal((set, get) => ({
901
1118
  readOnly: false,
902
1119
  setReadOnly: (readOnly) => set({ readOnly }),
903
1120
  unloadScene: () => {
1121
+ invalidatePendingHydration();
904
1122
  set({
1123
+ hydrationToken: null,
1124
+ hydrationId: null,
905
1125
  nodes: {},
906
1126
  rootNodeIds: [],
907
- dirtyNodes: new Set(),
1127
+ dirtyNodes: new GuardedDirtySet(get),
908
1128
  collections: {},
909
1129
  materials: {},
910
1130
  installedPlugins: [],
@@ -947,26 +1167,65 @@ const useScene = create()(temporal((set, get) => ({
947
1167
  // pre-write state onto `pastStates`. Writing the scene in two steps
948
1168
  // (as this used to) exposed a half-normalized intermediate state —
949
1169
  // and the pre-load (possibly empty) state — as undo targets.
950
- set({
951
- nodes: cleanedNodes,
952
- rootNodeIds: normalizedRootNodeIds,
953
- dirtyNodes: new Set(),
954
- collections: extra?.collections ?? {},
955
- materials,
956
- installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
957
- hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
958
- });
959
- // Mark all nodes as dirty to trigger re-validation
960
- Object.values(cleanedNodes).forEach((node) => {
961
- get().markDirty(node.id);
962
- });
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 }));
963
1220
  },
964
1221
  setInstalledPlugins: (pluginIds, options) => {
965
1222
  if (get().readOnly)
966
1223
  return;
967
1224
  const nextInstalledPlugins = Array.from(new Set(pluginIds));
968
1225
  const previousInstalledPlugins = get().installedPlugins;
969
- 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);
970
1229
  for (const node of Object.values(get().nodes)) {
971
1230
  if (!getNodePluginId(node.type))
972
1231
  continue;
@@ -1017,10 +1276,9 @@ const useScene = create()(temporal((set, get) => ({
1017
1276
  set({ nodes, rootNodeIds });
1018
1277
  },
1019
1278
  markDirty: (id) => {
1020
- const node = get().nodes[id];
1021
- if (node && !isNodeKindEnabled(node.type, get().installedPlugins))
1022
- return;
1023
- 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()))
1024
1282
  return;
1025
1283
  get().dirtyNodes.add(id);
1026
1284
  },
@@ -1170,8 +1428,27 @@ const useScene = create()(temporal((set, get) => ({
1170
1428
  current: sceneHistorySnapshotFromState(currentState),
1171
1429
  });
1172
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
+ },
1173
1439
  limit: 50, // Limit to last 50 actions
1174
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);
1175
1452
  export default useScene;
1176
1453
  let sceneReadOnlyLeaseCount = 0;
1177
1454
  let sceneReadOnlyLeaseBaseline = false;
@@ -1543,6 +1820,8 @@ export function applySceneSnapshot(snapshot, options) {
1543
1820
  if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) {
1544
1821
  throw new Error('Cannot replace the scene snapshot during an active interaction');
1545
1822
  }
1823
+ useLiveNodeOverrides.getState().clearAll();
1824
+ useLiveTransforms.getState().clearAll();
1546
1825
  pauseSceneHistory(useScene);
1547
1826
  try {
1548
1827
  useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, {
@@ -1555,18 +1834,15 @@ export function applySceneSnapshot(snapshot, options) {
1555
1834
  finally {
1556
1835
  resumeSceneHistory(useScene);
1557
1836
  }
1558
- useLiveNodeOverrides.getState().clearAll();
1559
- useLiveTransforms.getState().clearAll();
1560
1837
  const current = sceneHistorySnapshotFromState(useScene.getState());
1561
1838
  if (areSceneSnapshotsEqual(before, current))
1562
1839
  return false;
1563
1840
  notifySceneCommit({ origin: options.origin, before, current });
1564
1841
  return true;
1565
1842
  }
1566
- // Track previous temporal state lengths and node snapshot for diffing
1843
+ // Track previous temporal state lengths for identifying history jumps
1567
1844
  let prevPastLength = 0;
1568
1845
  let prevFutureLength = 0;
1569
- let prevNodesSnapshot = null;
1570
1846
  export function clearSceneHistory() {
1571
1847
  resetSceneHistoryPauseDepth();
1572
1848
  // Resetting the pause-depth counter without resuming would strand the
@@ -1578,10 +1854,14 @@ export function clearSceneHistory() {
1578
1854
  useScene.temporal.getState().clear();
1579
1855
  prevPastLength = 0;
1580
1856
  prevFutureLength = 0;
1581
- prevNodesSnapshot = null;
1582
1857
  }
1583
1858
  // Subscribe to the temporal store (Undo/Redo events)
1584
- 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;
1585
1865
  const currentPastLength = state.pastStates.length;
1586
1866
  const currentFutureLength = state.futureStates.length;
1587
1867
  // Undo: futureStates increases (state moved from past to future)
@@ -1589,40 +1869,21 @@ useScene.temporal.subscribe((state) => {
1589
1869
  const didUndo = currentFutureLength > prevFutureLength;
1590
1870
  const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength;
1591
1871
  if (didUndo || didRedo) {
1592
- // Capture the previous snapshot before RAF fires
1593
- 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;
1594
1879
  // Defer to a microtask so the scene store has settled before we diff,
1595
1880
  // but still mark walls/items dirty before the next paint.
1596
1881
  queueMicrotask(() => {
1597
1882
  const currentNodes = useScene.getState().nodes;
1598
1883
  const { markDirty } = useScene.getState();
1599
1884
  if (snapshotBefore) {
1600
- // Diff: only mark nodes that actually changed
1601
- for (const [id, node] of Object.entries(currentNodes)) {
1602
- if (snapshotBefore[id] !== node) {
1603
- markDirty(id);
1604
- // Also mark parent so merged geometries update
1605
- if (node.parentId)
1606
- markDirty(node.parentId);
1607
- }
1608
- }
1609
- // Nodes that were deleted (exist in prev but not current)
1610
- for (const [id, node] of Object.entries(snapshotBefore)) {
1611
- if (!currentNodes[id]) {
1612
- const parentId = node.parentId;
1613
- if (parentId) {
1614
- markDirty(parentId);
1615
- // Mark sibling nodes dirty so they can update their geometry
1616
- // (e.g. adjacent walls need to recalculate miter/junction geometry)
1617
- const parent = currentNodes[parentId];
1618
- if (parent && 'children' in parent && Array.isArray(parent.children)) {
1619
- for (const childId of parent.children) {
1620
- markDirty(childId);
1621
- }
1622
- }
1623
- }
1624
- }
1625
- }
1885
+ for (const id of getHistoryDirtyNodeIds(snapshotBefore, snapshotAfter))
1886
+ markDirty(id);
1626
1887
  }
1627
1888
  else {
1628
1889
  // No snapshot to diff against — fall back to marking all
@@ -1630,10 +1891,17 @@ useScene.temporal.subscribe((state) => {
1630
1891
  markDirty(node.id);
1631
1892
  }
1632
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
+ }
1633
1902
  });
1634
1903
  }
1635
- // Update tracked lengths and snapshot
1904
+ // Update tracked lengths
1636
1905
  prevPastLength = currentPastLength;
1637
1906
  prevFutureLength = currentFutureLength;
1638
- prevNodesSnapshot = useScene.getState().nodes;
1639
1907
  });
@@ -0,0 +1,3 @@
1
+ export declare function initializeRoofElevationSync(): () => void;
2
+ export declare function RoofElevationSystem(): null;
3
+ //# sourceMappingURL=roof-elevation-system.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"roof-elevation-system.d.ts","sourceRoot":"","sources":["../../../src/systems/roof/roof-elevation-system.tsx"],"names":[],"mappings":"AAgCA,wBAAgB,2BAA2B,IAAI,MAAM,IAAI,CAmExD;AAED,wBAAgB,mBAAmB,SAGlC"}