@pascal-app/core 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (378) hide show
  1. package/README.md +17 -10
  2. package/dist/events/bus.d.ts +49 -2
  3. package/dist/events/bus.d.ts.map +1 -1
  4. package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
  5. package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
  6. package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
  7. package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
  8. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  9. package/dist/hooks/scene-registry/scene-registry.js +27 -1
  10. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +17 -1
  11. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -1
  12. package/dist/hooks/spatial-grid/floor-placed-elevation.js +61 -8
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +135 -28
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  15. package/dist/hooks/spatial-grid/spatial-grid-manager.js +427 -271
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +46 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  18. package/dist/hooks/spatial-grid/spatial-grid-sync.js +208 -9
  19. package/dist/hooks/spatial-grid/support-host-id.d.ts +3 -0
  20. package/dist/hooks/spatial-grid/support-host-id.d.ts.map +1 -0
  21. package/dist/hooks/spatial-grid/support-host-id.js +2 -0
  22. package/dist/hooks/spatial-grid/support-host-patch.d.ts +51 -0
  23. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -0
  24. package/dist/hooks/spatial-grid/support-host-patch.js +164 -0
  25. package/dist/index.d.ts +33 -11
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +32 -10
  28. package/dist/lib/level-name.d.ts +4 -0
  29. package/dist/lib/level-name.d.ts.map +1 -0
  30. package/dist/lib/level-name.js +10 -0
  31. package/dist/lib/measurement-geometry.d.ts +23 -0
  32. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  33. package/dist/lib/measurement-geometry.js +249 -0
  34. package/dist/lib/polygon-geometry.d.ts +0 -1
  35. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  36. package/dist/lib/polygon-geometry.js +0 -18
  37. package/dist/lib/selection-proxy.d.ts +4 -0
  38. package/dist/lib/selection-proxy.d.ts.map +1 -0
  39. package/dist/lib/selection-proxy.js +12 -0
  40. package/dist/lib/slab-polygon.d.ts +31 -2
  41. package/dist/lib/slab-polygon.d.ts.map +1 -1
  42. package/dist/lib/slab-polygon.js +557 -31
  43. package/dist/lib/slots.d.ts +14 -0
  44. package/dist/lib/slots.d.ts.map +1 -0
  45. package/dist/lib/slots.js +26 -0
  46. package/dist/lib/space-detection.d.ts +40 -6
  47. package/dist/lib/space-detection.d.ts.map +1 -1
  48. package/dist/lib/space-detection.js +574 -192
  49. package/dist/lib/terrain-brush.d.ts +163 -0
  50. package/dist/lib/terrain-brush.d.ts.map +1 -0
  51. package/dist/lib/terrain-brush.js +359 -0
  52. package/dist/lib/terrain-codec.d.ts +51 -0
  53. package/dist/lib/terrain-codec.d.ts.map +1 -0
  54. package/dist/lib/terrain-codec.js +167 -0
  55. package/dist/lib/terrain-field.d.ts +150 -0
  56. package/dist/lib/terrain-field.d.ts.map +1 -0
  57. package/dist/lib/terrain-field.js +248 -0
  58. package/dist/lib/terrain-raycast.d.ts +44 -0
  59. package/dist/lib/terrain-raycast.d.ts.map +1 -0
  60. package/dist/lib/terrain-raycast.js +188 -0
  61. package/dist/lib/terrain-source.d.ts +60 -0
  62. package/dist/lib/terrain-source.d.ts.map +1 -0
  63. package/dist/lib/terrain-source.js +90 -0
  64. package/dist/lib/terrain-support.d.ts +60 -0
  65. package/dist/lib/terrain-support.d.ts.map +1 -0
  66. package/dist/lib/terrain-support.js +150 -0
  67. package/dist/lib/wall-distance.d.ts +60 -0
  68. package/dist/lib/wall-distance.d.ts.map +1 -0
  69. package/dist/lib/wall-distance.js +85 -0
  70. package/dist/lib/zone-quantities.d.ts +21 -0
  71. package/dist/lib/zone-quantities.d.ts.map +1 -0
  72. package/dist/lib/zone-quantities.js +428 -0
  73. package/dist/material-library.d.ts +28 -1
  74. package/dist/material-library.d.ts.map +1 -1
  75. package/dist/material-library.js +2233 -441
  76. package/dist/registry/handles.d.ts +91 -3
  77. package/dist/registry/handles.d.ts.map +1 -1
  78. package/dist/registry/index.d.ts +3 -3
  79. package/dist/registry/index.d.ts.map +1 -1
  80. package/dist/registry/index.js +1 -1
  81. package/dist/registry/registry.d.ts +46 -3
  82. package/dist/registry/registry.d.ts.map +1 -1
  83. package/dist/registry/registry.js +82 -1
  84. package/dist/registry/subtree.d.ts.map +1 -1
  85. package/dist/registry/subtree.js +8 -1
  86. package/dist/registry/types.d.ts +781 -39
  87. package/dist/registry/types.d.ts.map +1 -1
  88. package/dist/schema/index.d.ts +27 -8
  89. package/dist/schema/index.d.ts.map +1 -1
  90. package/dist/schema/index.js +23 -6
  91. package/dist/schema/material.d.ts +3 -2
  92. package/dist/schema/material.d.ts.map +1 -1
  93. package/dist/schema/material.js +7 -5
  94. package/dist/schema/nodes/box-vent.d.ts +2 -2
  95. package/dist/schema/nodes/cabinet.d.ts +536 -0
  96. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  97. package/dist/schema/nodes/cabinet.js +139 -0
  98. package/dist/schema/nodes/ceiling.d.ts +4 -3
  99. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  100. package/dist/schema/nodes/ceiling.js +11 -1
  101. package/dist/schema/nodes/chimney.d.ts +4 -4
  102. package/dist/schema/nodes/column.d.ts +4 -2
  103. package/dist/schema/nodes/column.d.ts.map +1 -1
  104. package/dist/schema/nodes/column.js +5 -0
  105. package/dist/schema/nodes/construction-dimension.d.ts +195 -0
  106. package/dist/schema/nodes/construction-dimension.d.ts.map +1 -0
  107. package/dist/schema/nodes/construction-dimension.js +151 -0
  108. package/dist/schema/nodes/cupola.d.ts +2 -2
  109. package/dist/schema/nodes/door.d.ts +42 -5
  110. package/dist/schema/nodes/door.d.ts.map +1 -1
  111. package/dist/schema/nodes/door.js +32 -0
  112. package/dist/schema/nodes/dormer.d.ts +8 -8
  113. package/dist/schema/nodes/dormer.js +1 -1
  114. package/dist/schema/nodes/downspout.d.ts +2 -2
  115. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  116. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  117. package/dist/schema/nodes/duct-fitting.js +92 -0
  118. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  119. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  120. package/dist/schema/nodes/duct-segment.js +73 -0
  121. package/dist/schema/nodes/duct-terminal.d.ts +58 -0
  122. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  123. package/dist/schema/nodes/duct-terminal.js +53 -0
  124. package/dist/schema/nodes/elevator.d.ts +3 -2
  125. package/dist/schema/nodes/elevator.d.ts.map +1 -1
  126. package/dist/schema/nodes/elevator.js +3 -0
  127. package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
  128. package/dist/schema/nodes/fence.d.ts +20 -2
  129. package/dist/schema/nodes/fence.d.ts.map +1 -1
  130. package/dist/schema/nodes/fence.js +33 -2
  131. package/dist/schema/nodes/gutter.d.ts +2 -2
  132. package/dist/schema/nodes/hvac-equipment.d.ts +64 -0
  133. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  134. package/dist/schema/nodes/hvac-equipment.js +57 -0
  135. package/dist/schema/nodes/item.d.ts +9 -0
  136. package/dist/schema/nodes/item.d.ts.map +1 -1
  137. package/dist/schema/nodes/item.js +24 -0
  138. package/dist/schema/nodes/level.d.ts +2 -1
  139. package/dist/schema/nodes/level.d.ts.map +1 -1
  140. package/dist/schema/nodes/level.js +12 -31
  141. package/dist/schema/nodes/lineset.d.ts +42 -0
  142. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  143. package/dist/schema/nodes/lineset.js +38 -0
  144. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  145. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  146. package/dist/schema/nodes/liquid-line.js +24 -0
  147. package/dist/schema/nodes/measurement.d.ts +306 -0
  148. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  149. package/dist/schema/nodes/measurement.js +87 -0
  150. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  151. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  152. package/dist/schema/nodes/pipe-fitting.js +45 -0
  153. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  154. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  155. package/dist/schema/nodes/pipe-segment.js +36 -0
  156. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  157. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  158. package/dist/schema/nodes/pipe-trap.js +36 -0
  159. package/dist/schema/nodes/ridge-vent.d.ts +18 -3
  160. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  161. package/dist/schema/nodes/ridge-vent.js +221 -3
  162. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  163. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  164. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  165. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  166. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  167. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  168. package/dist/schema/nodes/roof-segment.d.ts +84 -8
  169. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  170. package/dist/schema/nodes/roof-segment.js +290 -5
  171. package/dist/schema/nodes/roof.d.ts +8 -8
  172. package/dist/schema/nodes/shelf.d.ts +4 -2
  173. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  174. package/dist/schema/nodes/shelf.js +8 -0
  175. package/dist/schema/nodes/site.d.ts +9 -0
  176. package/dist/schema/nodes/site.d.ts.map +1 -1
  177. package/dist/schema/nodes/site.js +8 -6
  178. package/dist/schema/nodes/skylight.d.ts +10 -10
  179. package/dist/schema/nodes/slab.d.ts +8 -2
  180. package/dist/schema/nodes/slab.d.ts.map +1 -1
  181. package/dist/schema/nodes/slab.js +18 -2
  182. package/dist/schema/nodes/solar-panel.d.ts +4 -4
  183. package/dist/schema/nodes/spawn.d.ts +1 -0
  184. package/dist/schema/nodes/spawn.d.ts.map +1 -1
  185. package/dist/schema/nodes/spawn.js +2 -0
  186. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  187. package/dist/schema/nodes/stair.d.ts +16 -13
  188. package/dist/schema/nodes/stair.d.ts.map +1 -1
  189. package/dist/schema/nodes/stair.js +11 -1
  190. package/dist/schema/nodes/structural-grid.d.ts +27 -0
  191. package/dist/schema/nodes/structural-grid.d.ts.map +1 -0
  192. package/dist/schema/nodes/structural-grid.js +17 -0
  193. package/dist/schema/nodes/turbine-vent.d.ts +2 -2
  194. package/dist/schema/nodes/wall.d.ts +248 -6
  195. package/dist/schema/nodes/wall.d.ts.map +1 -1
  196. package/dist/schema/nodes/wall.js +266 -0
  197. package/dist/schema/nodes/window.d.ts +40 -3
  198. package/dist/schema/nodes/window.d.ts.map +1 -1
  199. package/dist/schema/nodes/window.js +30 -0
  200. package/dist/schema/nodes/zone.d.ts +22 -0
  201. package/dist/schema/nodes/zone.d.ts.map +1 -1
  202. package/dist/schema/nodes/zone.js +21 -0
  203. package/dist/schema/scene-material.d.ts +41 -0
  204. package/dist/schema/scene-material.d.ts.map +1 -0
  205. package/dist/schema/scene-material.js +9 -0
  206. package/dist/schema/terrain.d.ts +28 -0
  207. package/dist/schema/terrain.d.ts.map +1 -0
  208. package/dist/schema/terrain.js +30 -0
  209. package/dist/schema/types.d.ts +1300 -97
  210. package/dist/schema/types.d.ts.map +1 -1
  211. package/dist/schema/types.js +27 -0
  212. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts +80 -0
  213. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts.map +1 -0
  214. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.js +89 -0
  215. package/dist/services/alignment-anchors.d.ts.map +1 -1
  216. package/dist/services/alignment-anchors.js +30 -1
  217. package/dist/services/alignment.d.ts +5 -0
  218. package/dist/services/alignment.d.ts.map +1 -1
  219. package/dist/services/alignment.js +2 -0
  220. package/dist/services/hosting.d.ts +8 -0
  221. package/dist/services/hosting.d.ts.map +1 -1
  222. package/dist/services/hosting.js +13 -4
  223. package/dist/services/index.d.ts +9 -2
  224. package/dist/services/index.d.ts.map +1 -1
  225. package/dist/services/index.js +9 -2
  226. package/dist/services/level-height.d.ts +27 -0
  227. package/dist/services/level-height.d.ts.map +1 -0
  228. package/dist/services/level-height.js +94 -0
  229. package/dist/services/opening-guides.d.ts +141 -0
  230. package/dist/services/opening-guides.d.ts.map +1 -0
  231. package/dist/services/opening-guides.js +267 -0
  232. package/dist/services/port-connectivity.d.ts +115 -0
  233. package/dist/services/port-connectivity.d.ts.map +1 -0
  234. package/dist/services/port-connectivity.js +300 -0
  235. package/dist/services/riser-diagram.d.ts +40 -0
  236. package/dist/services/riser-diagram.d.ts.map +1 -0
  237. package/dist/services/riser-diagram.js +110 -0
  238. package/dist/services/snap.d.ts +9 -1
  239. package/dist/services/snap.d.ts.map +1 -1
  240. package/dist/services/snap.js +21 -1
  241. package/dist/services/storey.d.ts +103 -0
  242. package/dist/services/storey.d.ts.map +1 -0
  243. package/dist/services/storey.js +296 -0
  244. package/dist/services/system-graph.d.ts +30 -0
  245. package/dist/services/system-graph.d.ts.map +1 -0
  246. package/dist/services/system-graph.js +164 -0
  247. package/dist/services/validate-dwv.d.ts +32 -0
  248. package/dist/services/validate-dwv.d.ts.map +1 -0
  249. package/dist/services/validate-dwv.js +118 -0
  250. package/dist/store/actions/node-actions.d.ts.map +1 -1
  251. package/dist/store/actions/node-actions.js +141 -4
  252. package/dist/store/history-control.d.ts +31 -0
  253. package/dist/store/history-control.d.ts.map +1 -1
  254. package/dist/store/history-control.js +130 -0
  255. package/dist/store/use-live-terrain.d.ts +65 -0
  256. package/dist/store/use-live-terrain.d.ts.map +1 -0
  257. package/dist/store/use-live-terrain.js +31 -0
  258. package/dist/store/use-live-transforms.d.ts +8 -0
  259. package/dist/store/use-live-transforms.d.ts.map +1 -1
  260. package/dist/store/use-scene.d.ts +47 -3
  261. package/dist/store/use-scene.d.ts.map +1 -1
  262. package/dist/store/use-scene.js +992 -55
  263. package/dist/systems/elevator/elevator-opening-system.d.ts +1 -0
  264. package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -1
  265. package/dist/systems/elevator/elevator-opening-system.js +27 -21
  266. package/dist/systems/elevator/elevator-service.d.ts +0 -2
  267. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  268. package/dist/systems/elevator/elevator-service.js +2 -24
  269. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  270. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  271. package/dist/systems/fence/fence-centerline.js +37 -0
  272. package/dist/systems/fence/fence-spline.d.ts +39 -0
  273. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  274. package/dist/systems/fence/fence-spline.js +196 -0
  275. package/dist/systems/slab/slab-placement.d.ts +11 -0
  276. package/dist/systems/slab/slab-placement.d.ts.map +1 -0
  277. package/dist/systems/slab/slab-placement.js +14 -0
  278. package/dist/systems/slab/slab-support.d.ts +137 -0
  279. package/dist/systems/slab/slab-support.d.ts.map +1 -0
  280. package/dist/systems/slab/slab-support.js +557 -0
  281. package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
  282. package/dist/systems/stair/stair-footprint.js +21 -11
  283. package/dist/systems/stair/stair-opening-preview.d.ts +1300 -505
  284. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  285. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  286. package/dist/systems/stair/stair-opening-sync.js +19 -33
  287. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  288. package/dist/systems/stair/stair-opening-system.js +32 -4
  289. package/dist/systems/stair/stair-rise.d.ts +22 -0
  290. package/dist/systems/stair/stair-rise.d.ts.map +1 -0
  291. package/dist/systems/stair/stair-rise.js +79 -0
  292. package/dist/systems/wall/wall-curve.d.ts +10 -0
  293. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  294. package/dist/systems/wall/wall-curve.js +1 -1
  295. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  296. package/dist/systems/wall/wall-mitering.js +44 -8
  297. package/dist/systems/wall/wall-top.d.ts +40 -0
  298. package/dist/systems/wall/wall-top.d.ts.map +1 -0
  299. package/dist/systems/wall/wall-top.js +46 -0
  300. package/dist/utils/clone-scene-graph.d.ts +1 -0
  301. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  302. package/dist/utils/clone-scene-graph.js +56 -5
  303. package/dist/utils/heal-scene-graph.d.ts +18 -0
  304. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  305. package/dist/utils/heal-scene-graph.js +106 -0
  306. package/dist/validation/validate-build-json.d.ts +3 -0
  307. package/dist/validation/validate-build-json.d.ts.map +1 -1
  308. package/dist/validation/validate-build-json.js +90 -5
  309. package/package.json +4 -4
  310. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +0 -2
  311. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +0 -1
  312. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +0 -273
  313. package/dist/lib/space-detection-pause.test.d.ts +0 -2
  314. package/dist/lib/space-detection-pause.test.d.ts.map +0 -1
  315. package/dist/lib/space-detection-pause.test.js +0 -42
  316. package/dist/lib/space-detection.test.d.ts +0 -2
  317. package/dist/lib/space-detection.test.d.ts.map +0 -1
  318. package/dist/lib/space-detection.test.js +0 -77
  319. package/dist/registry/registry.test.d.ts +0 -2
  320. package/dist/registry/registry.test.d.ts.map +0 -1
  321. package/dist/registry/registry.test.js +0 -183
  322. package/dist/registry/relations-resolver.test.d.ts +0 -2
  323. package/dist/registry/relations-resolver.test.d.ts.map +0 -1
  324. package/dist/registry/relations-resolver.test.js +0 -183
  325. package/dist/registry/scene-api.test.d.ts +0 -2
  326. package/dist/registry/scene-api.test.d.ts.map +0 -1
  327. package/dist/registry/scene-api.test.js +0 -183
  328. package/dist/registry/subtree.test.d.ts +0 -2
  329. package/dist/registry/subtree.test.d.ts.map +0 -1
  330. package/dist/registry/subtree.test.js +0 -133
  331. package/dist/schema/asset-url.test.d.ts +0 -2
  332. package/dist/schema/asset-url.test.d.ts.map +0 -1
  333. package/dist/schema/asset-url.test.js +0 -134
  334. package/dist/services/alignment-anchors.test.d.ts +0 -2
  335. package/dist/services/alignment-anchors.test.d.ts.map +0 -1
  336. package/dist/services/alignment-anchors.test.js +0 -383
  337. package/dist/services/alignment.test.d.ts +0 -2
  338. package/dist/services/alignment.test.d.ts.map +0 -1
  339. package/dist/services/alignment.test.js +0 -92
  340. package/dist/services/drag-session.test.d.ts +0 -2
  341. package/dist/services/drag-session.test.d.ts.map +0 -1
  342. package/dist/services/drag-session.test.js +0 -219
  343. package/dist/services/hosting.test.d.ts +0 -2
  344. package/dist/services/hosting.test.d.ts.map +0 -1
  345. package/dist/services/hosting.test.js +0 -199
  346. package/dist/services/movement.test.d.ts +0 -2
  347. package/dist/services/movement.test.d.ts.map +0 -1
  348. package/dist/services/movement.test.js +0 -133
  349. package/dist/services/single-undo-dance.test.d.ts +0 -2
  350. package/dist/services/single-undo-dance.test.d.ts.map +0 -1
  351. package/dist/services/single-undo-dance.test.js +0 -210
  352. package/dist/services/snap.test.d.ts +0 -2
  353. package/dist/services/snap.test.d.ts.map +0 -1
  354. package/dist/services/snap.test.js +0 -95
  355. package/dist/store/actions/node-mutation-sanitize.test.d.ts +0 -2
  356. package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +0 -1
  357. package/dist/store/actions/node-mutation-sanitize.test.js +0 -147
  358. package/dist/store/actions/reparent.test.d.ts +0 -2
  359. package/dist/store/actions/reparent.test.d.ts.map +0 -1
  360. package/dist/store/actions/reparent.test.js +0 -212
  361. package/dist/store/use-scene-elevator-migration.test.d.ts +0 -2
  362. package/dist/store/use-scene-elevator-migration.test.d.ts.map +0 -1
  363. package/dist/store/use-scene-elevator-migration.test.js +0 -109
  364. package/dist/systems/elevator/elevator-opening-sync.test.d.ts +0 -2
  365. package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +0 -1
  366. package/dist/systems/elevator/elevator-opening-sync.test.js +0 -146
  367. package/dist/systems/elevator/elevator-runtime.test.d.ts +0 -2
  368. package/dist/systems/elevator/elevator-runtime.test.d.ts.map +0 -1
  369. package/dist/systems/elevator/elevator-runtime.test.js +0 -64
  370. package/dist/systems/stair/stair-opening-preview.test.d.ts +0 -2
  371. package/dist/systems/stair/stair-opening-preview.test.d.ts.map +0 -1
  372. package/dist/systems/stair/stair-opening-preview.test.js +0 -80
  373. package/dist/systems/stair/stair-opening-sync.test.d.ts +0 -2
  374. package/dist/systems/stair/stair-opening-sync.test.d.ts.map +0 -1
  375. package/dist/systems/stair/stair-opening-sync.test.js +0 -446
  376. package/dist/utils/clone-scene-graph.test.d.ts +0 -2
  377. package/dist/utils/clone-scene-graph.test.d.ts.map +0 -1
  378. package/dist/utils/clone-scene-graph.test.js +0 -59
@@ -1,18 +1,32 @@
1
1
  'use client';
2
2
  import { temporal } from 'zundo';
3
3
  import { create } from 'zustand';
4
+ import { parseMaterialRef, toSceneMaterialRef } from '../material-library';
5
+ import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry';
4
6
  import { BuildingNode } from '../schema';
5
7
  import { generateCollectionId } from '../schema/collections';
6
8
  import { DoorNode as DoorNodeSchema } from '../schema/nodes/door';
7
9
  import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator';
8
10
  import { LevelNode } from '../schema/nodes/level';
9
11
  import { getPitchFromActiveRoofHeight, } from '../schema/nodes/roof-segment';
12
+ import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls';
10
13
  import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf';
11
14
  import { SiteNode } from '../schema/nodes/site';
12
- import { StairNode as StairNodeSchema } from '../schema/nodes/stair';
15
+ import { getEffectiveStairSurfaceMaterial, StairNode as StairNodeSchema, } from '../schema/nodes/stair';
13
16
  import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment';
17
+ import { getEffectiveWallSurfaceMaterial } from '../schema/nodes/wall';
18
+ import { WindowNode as WindowNodeSchema } from '../schema/nodes/window';
19
+ import { generateSceneMaterialId, SceneMaterial, } from '../schema/scene-material';
20
+ 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';
25
+ import { healSceneNodes } from '../utils/heal-scene-graph';
14
26
  import * as nodeActions from './actions/node-actions';
15
- import { resetSceneHistoryPauseDepth } from './history-control';
27
+ import { areSceneSnapshotsEqual, getSceneHistoryPauseDepth, notifySceneCommit, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, } from './history-control';
28
+ import useLiveNodeOverrides from './use-live-node-overrides';
29
+ import useLiveTransforms from './use-live-transforms';
16
30
  function getFiniteNumber(value, fallback) {
17
31
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
18
32
  }
@@ -45,6 +59,7 @@ function getVector3(value, fallback) {
45
59
  ];
46
60
  }
47
61
  function normalizeStairNode(node) {
62
+ const hasTotalRise = 'totalRise' in node;
48
63
  const sanitized = {
49
64
  ...node,
50
65
  position: getVector3(node.position, [0, 0, 0]),
@@ -55,7 +70,7 @@ function normalizeStairNode(node) {
55
70
  slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'], 'none'),
56
71
  openingOffset: getFiniteNumber(node.openingOffset, 0),
57
72
  width: getFiniteNumber(node.width, 1),
58
- totalRise: getFiniteNumber(node.totalRise, 2.5),
73
+ totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined,
59
74
  stepCount: getFiniteNumber(node.stepCount, 10),
60
75
  thickness: getFiniteNumber(node.thickness, 0.25),
61
76
  fillToFloor: getBoolean(node.fillToFloor, true),
@@ -70,7 +85,15 @@ function normalizeStairNode(node) {
70
85
  children: getStringArray(node.children),
71
86
  };
72
87
  const parsed = StairNodeSchema.safeParse(sanitized);
73
- return parsed.success ? parsed.data : null;
88
+ if (!parsed.success)
89
+ return null;
90
+ if (hasTotalRise)
91
+ return parsed.data;
92
+ // Absent `totalRise` means "rise derives from the storey height" and must
93
+ // survive the load: safeParse echoes the sanitized explicit-undefined key,
94
+ // which would flip `'totalRise' in node` checks — strip it back off.
95
+ const { totalRise: _totalRise, ...rest } = parsed.data;
96
+ return rest;
74
97
  }
75
98
  function normalizeStairSegmentNode(node) {
76
99
  const sanitized = {
@@ -93,6 +116,13 @@ function normalizeDoorNode(node) {
93
116
  const parsed = DoorNodeSchema.safeParse(node);
94
117
  return parsed.success ? { ...node, ...parsed.data } : null;
95
118
  }
119
+ // Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
120
+ // `frameThickness`) load without it; the mesh builder then reads undefined and
121
+ // throws every frame. Zod-parse on load so schema defaults land, like doors.
122
+ function normalizeWindowNode(node) {
123
+ const parsed = WindowNodeSchema.safeParse(node);
124
+ return parsed.success ? { ...node, ...parsed.data } : null;
125
+ }
96
126
  function normalizeShelfNode(node) {
97
127
  const sanitized = {
98
128
  ...node,
@@ -182,40 +212,127 @@ function migrateElevatorParent(id, node, nodes) {
182
212
  parentId: buildingId,
183
213
  };
184
214
  }
185
- function migrateWallSurfaceMaterials(node) {
186
- const hasInterior = node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string';
187
- const hasExterior = node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string';
188
- const legacyFinish = {
189
- material: node.material,
190
- materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
191
- };
192
- if (!(hasInterior || hasExterior)) {
193
- if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
194
- return node;
215
+ // Reuse an already-minted scene material for an identical inline legacy
216
+ // material so a whole building painted one custom colour collapses to one
217
+ // shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match).
218
+ function findMintedSceneMaterialRef(material, mintedMaterials) {
219
+ const target = JSON.stringify(material);
220
+ for (const sceneMaterial of Object.values(mintedMaterials)) {
221
+ if (JSON.stringify(sceneMaterial.material) === target) {
222
+ return toSceneMaterialRef(sceneMaterial.id);
195
223
  }
196
- return {
197
- ...node,
198
- interiorMaterial: legacyFinish.material,
199
- interiorMaterialPreset: legacyFinish.materialPreset,
200
- exteriorMaterial: legacyFinish.material,
201
- exteriorMaterialPreset: legacyFinish.materialPreset,
202
- };
203
224
  }
204
- if (!hasInterior) {
205
- return {
206
- ...node,
207
- interiorMaterial: node.exteriorMaterial,
208
- interiorMaterialPreset: node.exteriorMaterialPreset,
209
- };
225
+ return undefined;
226
+ }
227
+ // Turn a legacy surface spec (`{ material, materialPreset }`) into a
228
+ // `MaterialRef`: a preset that's already a `library:`/`scene:` ref is used
229
+ // as-is; an inline material mints (or reuses) a scene material. Returns
230
+ // undefined when the spec carries no material. Shared by every legacy→slots
231
+ // migration below.
232
+ function legacySpecToMaterialRef(spec, mintedMaterials) {
233
+ if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) {
234
+ return spec.materialPreset;
210
235
  }
211
- if (!hasExterior) {
212
- return {
213
- ...node,
214
- exteriorMaterial: node.interiorMaterial,
215
- exteriorMaterialPreset: node.interiorMaterialPreset,
236
+ if (spec.material !== undefined) {
237
+ const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials);
238
+ if (existing)
239
+ return existing;
240
+ const id = generateSceneMaterialId();
241
+ mintedMaterials[id] = {
242
+ id,
243
+ name: `Material ${Object.keys(mintedMaterials).length + 1}`,
244
+ material: spec.material,
216
245
  };
246
+ return toSceneMaterialRef(id);
247
+ }
248
+ return undefined;
249
+ }
250
+ // Move the retired inline `material*` / `interiorMaterial*` / `exteriorMaterial*`
251
+ // fields onto the unified `node.slots` model (interior / exterior → a
252
+ // `library:`/`scene:` ref), minting scene materials for inline customs into
253
+ // `mintedMaterials` (merged into the scene material map by the caller). Already
254
+ // slot-modelled walls and walls with no legacy material are left untouched.
255
+ function migrateWallSurfaceMaterials(node, mintedMaterials) {
256
+ if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) {
257
+ return node;
258
+ }
259
+ const slots = { ...(node.slots ?? {}) };
260
+ for (const side of ['interior', 'exterior']) {
261
+ const spec = getEffectiveWallSurfaceMaterial(node, side);
262
+ const ref = legacySpecToMaterialRef(spec, mintedMaterials);
263
+ if (ref)
264
+ slots[side] = ref;
265
+ }
266
+ if (Object.keys(slots).length === 0) {
267
+ return node;
268
+ }
269
+ return {
270
+ ...node,
271
+ slots,
272
+ material: undefined,
273
+ materialPreset: undefined,
274
+ interiorMaterial: undefined,
275
+ interiorMaterialPreset: undefined,
276
+ exteriorMaterial: undefined,
277
+ exteriorMaterialPreset: undefined,
278
+ };
279
+ }
280
+ // Move a kind's single legacy `material` / `materialPreset` onto its declared
281
+ // slots. A pre-slot-model node painted one material rendered that material on
282
+ // every part (each slot resolves `node.slots[slot]` → legacy → default), so the
283
+ // migration writes the same ref to every slot id the kind can expose — unused
284
+ // conditional slots are harmless. Already slot-modelled or unpainted nodes are
285
+ // left untouched. Mirrors `migrateWallSurfaceMaterials` for single-surface and
286
+ // whole-object kinds (slab, ceiling, fence, column, shelf).
287
+ function migrateSingleMaterialSlots(node, slotIds, mintedMaterials) {
288
+ if (node.slots && Object.keys(node.slots).length > 0) {
289
+ return node;
290
+ }
291
+ const ref = legacySpecToMaterialRef({ material: node.material, materialPreset: node.materialPreset }, mintedMaterials);
292
+ if (!ref) {
293
+ return node;
294
+ }
295
+ const slots = {};
296
+ for (const slotId of slotIds)
297
+ slots[slotId] = ref;
298
+ return { ...node, slots, material: undefined, materialPreset: undefined };
299
+ }
300
+ // Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` /
301
+ // `railingMaterial*`) plus a catch-all. Map each to its slot via the same
302
+ // fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`):
303
+ // tread→treads, side→body, railing→railing. Runs after
304
+ // `migrateStairSurfaceMaterials` has normalised the legacy fields.
305
+ function migrateStairSurfaceSlots(node, mintedMaterials) {
306
+ if (node.slots && Object.keys(node.slots).length > 0) {
307
+ return node;
308
+ }
309
+ const roleToSlot = [
310
+ ['tread', 'treads'],
311
+ ['side', 'body'],
312
+ ['railing', 'railing'],
313
+ ];
314
+ const slots = {};
315
+ for (const [role, slotId] of roleToSlot) {
316
+ const spec = getEffectiveStairSurfaceMaterial(node, role);
317
+ const ref = legacySpecToMaterialRef(spec, mintedMaterials);
318
+ if (ref)
319
+ slots[slotId] = ref;
320
+ }
321
+ if (Object.keys(slots).length === 0) {
322
+ return node;
217
323
  }
218
- return node;
324
+ return {
325
+ ...node,
326
+ slots,
327
+ material: undefined,
328
+ materialPreset: undefined,
329
+ treadMaterial: undefined,
330
+ treadMaterialPreset: undefined,
331
+ sideMaterial: undefined,
332
+ sideMaterialPreset: undefined,
333
+ railingMaterial: undefined,
334
+ railingMaterialPreset: undefined,
335
+ };
219
336
  }
220
337
  function migrateStairSurfaceMaterials(node) {
221
338
  const hasRailing = node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string';
@@ -333,8 +450,84 @@ function migrateRoofSurfaceMaterials(node) {
333
450
  }
334
451
  return next;
335
452
  }
453
+ function migrateConstructionDimension(node) {
454
+ const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : [];
455
+ const hasLegacyDrawingOverride = drawingOverrides.some((entry) => entry &&
456
+ typeof entry === 'object' &&
457
+ !Array.isArray(entry) &&
458
+ entry.presentation === 'reference');
459
+ if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride))
460
+ return node;
461
+ const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node;
462
+ return {
463
+ ...dimension,
464
+ drawingOverrides: drawingOverrides.map((entry) => {
465
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
466
+ return entry;
467
+ return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry;
468
+ }),
469
+ };
470
+ }
471
+ function removeRetiredDrawingSheets(nodes) {
472
+ const retiredIds = new Set(Object.entries(nodes)
473
+ .filter(([, node]) => node?.type === 'drawing-sheet')
474
+ .map(([id]) => id));
475
+ if (retiredIds.size === 0)
476
+ return;
477
+ for (const id of retiredIds)
478
+ delete nodes[id];
479
+ for (const [id, node] of Object.entries(nodes)) {
480
+ if (!Array.isArray(node?.children))
481
+ continue;
482
+ const children = getStringArray(node.children);
483
+ if (!children.some((childId) => retiredIds.has(childId)))
484
+ continue;
485
+ nodes[id] = {
486
+ ...node,
487
+ children: children.filter((childId) => !retiredIds.has(childId)),
488
+ };
489
+ }
490
+ }
491
+ function migrateWallAssembly(node) {
492
+ if (!Object.hasOwn(node, 'assemblyLayers'))
493
+ return node;
494
+ const assemblyThickness = Array.isArray(node.assemblyLayers)
495
+ ? node.assemblyLayers.reduce((total, layer) => {
496
+ if (!(layer && typeof layer === 'object'))
497
+ return total;
498
+ const thickness = layer.thickness;
499
+ return typeof thickness === 'number' && Number.isFinite(thickness) && thickness > 0
500
+ ? total + thickness
501
+ : total;
502
+ }, 0)
503
+ : 0;
504
+ const { assemblyLayers: _assemblyLayers, ...wall } = node;
505
+ return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall;
506
+ }
507
+ // Walls whose top lands within this of the storey plane become plane-bound;
508
+ // ceilings whose stored height lands within this of their clamp bound become
509
+ // follows-mode (step 3f) — same census-backed threshold for both.
510
+ // From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to
511
+ // a taller wall) must snap to the plane, while intentional 0.20-short walls
512
+ // (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit
513
+ // height — hence 0.20 with a strictly-less-than comparison.
514
+ const PLANE_BOUND_EPSILON = 0.2;
336
515
  function migrateNodes(nodes) {
337
- const patchedNodes = { ...nodes };
516
+ // Repair pre-existing corruption (null children, zero-length walls) before
517
+ // any per-type migration runs, so already-saved scenes load cleanly.
518
+ const { nodes: healed } = healSceneNodes(nodes);
519
+ const patchedNodes = { ...healed };
520
+ removeRetiredDrawingSheets(patchedNodes);
521
+ // Scene materials minted while moving legacy wall fields onto `node.slots`;
522
+ // merged into the scene material map by the caller (`setScene`).
523
+ const mintedMaterials = {};
524
+ // Pass 1: all node types except elevator.
525
+ // Elevator migration (migrateElevatorParent) mutates level.children to remove
526
+ // the elevator ID. If the elevator is processed before its parent level in
527
+ // Object.entries order, the level migration in this same pass would then see
528
+ // a children array that still contains the elevator ID and filter it out as
529
+ // "missing" — corrupting the level. Running elevators in a second pass after
530
+ // all levels are stable avoids the race entirely.
338
531
  for (const [id, node] of Object.entries(patchedNodes)) {
339
532
  // 1. Item scale migration
340
533
  if (node.type === 'item' && !('scale' in node)) {
@@ -410,11 +603,21 @@ function migrateNodes(nodes) {
410
603
  patchedNodes[id] = normalized;
411
604
  }
412
605
  }
606
+ if (node.type === 'window') {
607
+ const normalized = normalizeWindowNode(node);
608
+ if (normalized) {
609
+ patchedNodes[id] = normalized;
610
+ }
611
+ }
612
+ if (node.type === 'construction-dimension') {
613
+ patchedNodes[id] = migrateConstructionDimension(node);
614
+ }
413
615
  if (node.type === 'stair') {
414
616
  const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node));
415
617
  if (normalized) {
416
618
  patchedNodes[id] = normalized;
417
619
  }
620
+ patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials);
418
621
  }
419
622
  if (node.type === 'stair-segment') {
420
623
  const normalized = normalizeStairSegmentNode(node);
@@ -423,20 +626,52 @@ function migrateNodes(nodes) {
423
626
  }
424
627
  }
425
628
  if (node.type === 'wall') {
426
- patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]);
629
+ patchedNodes[id] = migrateWallSurfaceMaterials(migrateWallAssembly(patchedNodes[id]), mintedMaterials);
427
630
  }
428
- if (node.type === 'shelf') {
429
- const normalized = normalizeShelfNode(node);
430
- if (normalized) {
431
- patchedNodes[id] = normalized;
631
+ // Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the
632
+ // per-compartment `doorType`) and was removed from both cabinet schemas;
633
+ // `handlePosition: 'edge'` behaved identically to 'auto' and was dropped
634
+ // from the enum; the compartment stack became a discriminated union that
635
+ // rejects a `cooktopLayout` mismatched to its gas/induction type (the old
636
+ // loose schema ignored it).
637
+ if (node.type === 'cabinet' || node.type === 'cabinet-module') {
638
+ const { doorStyle: _doorStyle, ...rest } = node;
639
+ const next = rest;
640
+ if (next.handlePosition === 'edge')
641
+ next.handlePosition = 'auto';
642
+ if (Array.isArray(next.stack)) {
643
+ next.stack = next.stack.map((compartment) => {
644
+ if (!compartment || typeof compartment !== 'object')
645
+ return compartment;
646
+ const layout = compartment.cooktopLayout;
647
+ if (typeof layout !== 'string')
648
+ return compartment;
649
+ if (compartment.type === 'cooktop-gas' && !layout.startsWith('gas-')) {
650
+ return { ...compartment, cooktopLayout: 'gas-4burner' };
651
+ }
652
+ if (compartment.type === 'cooktop-induction' && !layout.startsWith('induction-')) {
653
+ return { ...compartment, cooktopLayout: 'induction-4zone' };
654
+ }
655
+ return compartment;
656
+ });
432
657
  }
658
+ patchedNodes[id] = next;
659
+ }
660
+ if (node.type === 'slab' || node.type === 'ceiling') {
661
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials);
662
+ }
663
+ if (node.type === 'fence') {
664
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['posts', 'infill', 'base', 'rail'], mintedMaterials);
665
+ }
666
+ if (node.type === 'column') {
667
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['shaft', 'base', 'capital', 'frame'], mintedMaterials);
433
668
  }
434
- if (node.type === 'elevator') {
435
- const parentMigrated = migrateElevatorParent(id, node, patchedNodes);
436
- const normalized = normalizeElevatorNode(parentMigrated);
669
+ if (node.type === 'shelf') {
670
+ const normalized = normalizeShelfNode(node);
437
671
  if (normalized) {
438
672
  patchedNodes[id] = normalized;
439
673
  }
674
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['shelves', 'frame', 'back'], mintedMaterials);
440
675
  }
441
676
  // Roof-segment hosting was added in this migration cycle (the same
442
677
  // pattern as shelf above). Older segments saved before the schema
@@ -449,6 +684,41 @@ function migrateNodes(nodes) {
449
684
  if (node.type === 'roof-segment' && !Array.isArray(node.children)) {
450
685
  patchedNodes[id] = { ...node, children: [] };
451
686
  }
687
+ // Roof-hosted wall children (door / window / item) originally stored
688
+ // SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
689
+ // format moved to explicit `roofFace` + FACE-LOCAL coords so the
690
+ // renderer's face frame can track segment edits live. Convert in
691
+ // place: face from the old cardinal yaw, u/v from the outer-plane
692
+ // projection, z re-based from the outer plane to the wall mid-plane.
693
+ if ((node.type === 'door' || node.type === 'window' || node.type === 'item') &&
694
+ typeof node.roofSegmentId === 'string' &&
695
+ node.roofFace === undefined) {
696
+ const current = patchedNodes[id];
697
+ const segment = patchedNodes[current.roofSegmentId];
698
+ if (segment?.type === 'roof-segment') {
699
+ const tau = Math.PI * 2;
700
+ const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau;
701
+ const eps = 1e-3;
702
+ const face = yaw < eps || tau - yaw < eps
703
+ ? 'front'
704
+ : Math.abs(yaw - Math.PI) < eps
705
+ ? 'back'
706
+ : Math.abs(yaw - Math.PI / 2) < eps
707
+ ? 'right'
708
+ : Math.abs(yaw - (3 * Math.PI) / 2) < eps
709
+ ? 'left'
710
+ : null;
711
+ if (face) {
712
+ const { u, v, dist } = segmentPointToRoofWallFace(segment, face, current.position);
713
+ patchedNodes[id] = {
714
+ ...current,
715
+ roofFace: face,
716
+ position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
717
+ rotation: [0, 0, 0],
718
+ };
719
+ }
720
+ }
721
+ }
452
722
  if (node.type === 'roof') {
453
723
  patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]);
454
724
  }
@@ -476,8 +746,200 @@ function migrateNodes(nodes) {
476
746
  patchedNodes[id] = { ...node, children: flattened };
477
747
  }
478
748
  }
749
+ // Level children normalization.
750
+ // Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node
751
+ // map (e.g. elevator IDs that lived under a level before the elevator
752
+ // parent migration moved them up to building). If those dangling IDs are
753
+ // left in place, collectReachableNodeIds marks the level as having
754
+ // reachable children that don't exist, which corrupts the scene graph
755
+ // traversal and leaves the LevelNode in a broken state — making floors
756
+ // impossible to drag or delete after import.
757
+ // We intentionally do NOT filter by type prefix here; being permissive
758
+ // about which types are allowed as children prevents data loss when new
759
+ // child types are added to the schema in the future.
760
+ if (node.type === 'level') {
761
+ const rawChildren = getStringArray(node.children);
762
+ const validChildren = rawChildren.filter((childId) => {
763
+ const exists = Boolean(patchedNodes[childId]);
764
+ if (!exists) {
765
+ console.warn('[migrateNodes] level', id, 'references missing child', childId, '— dropping');
766
+ }
767
+ return exists;
768
+ });
769
+ const levelNumber = getFiniteNumber(node.level, 0);
770
+ patchedNodes[id] = {
771
+ ...node,
772
+ level: levelNumber,
773
+ children: validChildren,
774
+ };
775
+ }
776
+ }
777
+ // Pass 2: elevator migration.
778
+ // migrateElevatorParent mutates the parent level's children array (removes
779
+ // the elevator ID from it). Running this after Pass 1 guarantees that the
780
+ // level normalization above has already seen a clean children list — if we
781
+ // ran elevator migration inside Pass 1, the order of Object.entries
782
+ // iteration would be non-deterministic: processing an elevator before its
783
+ // parent level would mutate the level's children mid-iteration, potentially
784
+ // causing the level branch above to see a stale node reference.
785
+ for (const [id, node] of Object.entries(patchedNodes)) {
786
+ if (node.type !== 'elevator')
787
+ continue;
788
+ const parentMigrated = migrateElevatorParent(id, node, patchedNodes);
789
+ const normalized = normalizeElevatorNode(parentMigrated);
790
+ if (normalized) {
791
+ patchedNodes[id] = normalized;
792
+ }
793
+ }
794
+ // Pass 3: vertical building model.
795
+ // A level without `height` marks a scene saved before the vertical model
796
+ // landed. Computed before this pass mutates anything: the stair-rise
797
+ // cleanup below must never run on already-migrated scenes.
798
+ const isLegacyScene = Object.values(patchedNodes).some((node) => node?.type === 'level' && !('height' in node));
799
+ // 3a. Ordinal renumber — always runs, per building (idempotent
800
+ // self-healing; MCP's create-level historically wrote its elevation PARAM
801
+ // into the ordinal, so fractional/duplicate ordinals exist in the wild).
802
+ const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building');
803
+ const levelsByBuilding = new Map();
804
+ for (const [id, node] of Object.entries(patchedNodes)) {
805
+ if (node?.type !== 'level')
806
+ continue;
807
+ // Mirrors the building resolution in services/storey.ts: an explicit
808
+ // parentId pointing at a building wins, membership in a building's
809
+ // children array is the legacy fallback, and unresolvable levels share
810
+ // one orphan bucket.
811
+ const buildingId = buildingNodes.find((building) => building.id === node.parentId)?.id ??
812
+ buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ??
813
+ null;
814
+ const bucket = levelsByBuilding.get(buildingId) ?? [];
815
+ bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) });
816
+ levelsByBuilding.set(buildingId, bucket);
817
+ }
818
+ for (const bucket of levelsByBuilding.values()) {
819
+ // Anchored at zero on purpose: ordinals are semantic — `level < 0`
820
+ // renders "Basement N" and `level === 0` is the ground-floor default —
821
+ // so negatives compact upward toward −1 and non-negatives compact down
822
+ // to 0. A blind 0..n renumber would rename basements.
823
+ const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal);
824
+ const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length;
825
+ sorted.forEach((entry, index) => {
826
+ const nextOrdinal = index - negativeCount;
827
+ const current = patchedNodes[entry.id];
828
+ if (current.level !== nextOrdinal) {
829
+ patchedNodes[entry.id] = { ...current, level: nextOrdinal };
830
+ }
831
+ });
832
+ }
833
+ // 3b. Stored storey heights: materialize the legacy stacked height verbatim
834
+ // (never rounded or snapped — snapping would move existing buildings).
835
+ // All planes derive before any wall height below mutates.
836
+ const legacyLevelIds = Object.entries(patchedNodes)
837
+ .filter(([, node]) => node?.type === 'level' && !('height' in node))
838
+ .map(([id]) => id);
839
+ const derivedHeights = new Map();
840
+ for (const levelId of legacyLevelIds) {
841
+ derivedHeights.set(levelId, deriveLegacyLevelHeight(levelId, patchedNodes));
842
+ }
843
+ for (const levelId of legacyLevelIds) {
844
+ const plane = derivedHeights.get(levelId);
845
+ const level = patchedNodes[levelId];
846
+ patchedNodes[levelId] = { ...level, height: plane };
847
+ // 3c. Wall-top classification against the just-written plane, using the
848
+ // same slab-support election as deriveLegacyLevelHeight (call shape
849
+ // mirrored from services/level-height.ts). Walls whose top meets the
850
+ // plane drop their explicit height and follow the level from now on;
851
+ // walls ending short (or tall) keep an explicit height — materializing
852
+ // the 2.5 default onto absent-height walls that end short of the plane.
853
+ const children = getStringArray(level.children)
854
+ .map((childId) => patchedNodes[childId])
855
+ .filter((child) => child !== undefined);
856
+ const slabs = children.filter((child) => child.type === 'slab');
857
+ const walls = children.filter((child) => child.type === 'wall');
858
+ for (const wall of walls) {
859
+ const electedBase = computeWallSlabSupport({
860
+ start: wall.start,
861
+ end: wall.end,
862
+ curveOffset: wall.curveOffset,
863
+ thickness: wall.thickness,
864
+ }, slabs, walls).elevation;
865
+ const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT;
866
+ const top = Math.max(0, electedBase) + effectiveHeight;
867
+ if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) {
868
+ if ('height' in wall) {
869
+ const { height: _height, ...planeBound } = wall;
870
+ patchedNodes[wall.id] = planeBound;
871
+ }
872
+ }
873
+ else {
874
+ patchedNodes[wall.id] = { ...wall, height: effectiveHeight };
875
+ }
876
+ }
877
+ }
878
+ // 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old
879
+ // schema default, not a user choice — drop it so the rise derives from the
880
+ // storey height. Gated on isLegacyScene because on a post-migration scene
881
+ // a stored 2.5 IS a deliberately typed value and must survive reloads.
882
+ if (isLegacyScene) {
883
+ for (const [id, node] of Object.entries(patchedNodes)) {
884
+ if (node?.type !== 'stair')
885
+ continue;
886
+ if (node.totalRise !== 2.5)
887
+ continue;
888
+ const { totalRise: _totalRise, ...derivedRise } = node;
889
+ patchedNodes[id] = derivedRise;
890
+ }
891
+ }
892
+ // 3e. Slab placement/thickness split. `elevation` stays the walking surface;
893
+ // the new `thickness` grows downward so the solid occupies
894
+ // [elevation − thickness, elevation]. Legacy solids extruded [0, elevation],
895
+ // so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS
896
+ // applies to edits only, never here) keeps the occupied interval identical.
897
+ // Legacy pools (elevation < 0) become explicit `recessed` intent with
898
+ // elevation unchanged. Gated per slab on a missing `thickness` — the
899
+ // migration output is cast, so schema defaults never materialize on load.
900
+ for (const [id, node] of Object.entries(patchedNodes)) {
901
+ if (node?.type !== 'slab' || 'thickness' in node)
902
+ continue;
903
+ const elevation = getFiniteNumber(node.elevation, 0.05);
904
+ patchedNodes[id] =
905
+ elevation < 0
906
+ ? { ...node, thickness: 0.05, recessed: true }
907
+ : { ...node, thickness: elevation };
908
+ }
909
+ // 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs
910
+ // after 3b/3e so the clamp bound sees stored level heights and split slab
911
+ // thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its
912
+ // clamp bound (min(storey plane, covering-slab underside) − margin, via
913
+ // getCeilingClampBound) is the legacy default tracking the level top, not
914
+ // a choice — drop it so the ceiling follows the level from now on.
915
+ // autoFromWalls ceilings always convert: their height was derived by the
916
+ // space-detection sync, never user intent. Gated on isLegacyScene, which
917
+ // is exact — nothing shipped between the level-height migration and this
918
+ // one — and makes the step idempotent. Known accepted edge: a
919
+ // post-migration user typing a custom height exactly equal to the bound
920
+ // keeps it (the gate prevents re-classification on later loads).
921
+ if (isLegacyScene) {
922
+ for (const [id, node] of Object.entries(patchedNodes)) {
923
+ if (node?.type !== 'ceiling' || !('height' in node))
924
+ continue;
925
+ const dropHeight = () => {
926
+ const { height: _height, ...follows } = node;
927
+ patchedNodes[id] = follows;
928
+ };
929
+ if (node.autoFromWalls === true) {
930
+ dropHeight();
931
+ continue;
932
+ }
933
+ if (typeof node.parentId !== 'string')
934
+ continue;
935
+ const bound = getCeilingClampBound(node.parentId, patchedNodes, Array.isArray(node.polygon) ? node.polygon : []);
936
+ const stored = getFiniteNumber(node.height, Number.NaN);
937
+ if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) {
938
+ dropHeight();
939
+ }
940
+ }
479
941
  }
480
- return patchedNodes;
942
+ return { nodes: patchedNodes, mintedMaterials };
481
943
  }
482
944
  function getNodeChildIds(node) {
483
945
  if (!('children' in node && Array.isArray(node.children))) {
@@ -527,6 +989,10 @@ function collectReachableNodeIds(nodes, rootNodeIds) {
527
989
  }
528
990
  return reachable;
529
991
  }
992
+ function sceneHistorySnapshotFromState(state) {
993
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } = state;
994
+ return { nodes, rootNodeIds, collections, materials, installedPlugins };
995
+ }
530
996
  const useScene = create()(temporal((set, get) => ({
531
997
  // 1. Flat dictionary of all nodes
532
998
  nodes: {},
@@ -536,6 +1002,9 @@ const useScene = create()(temporal((set, get) => ({
536
1002
  dirtyNodes: new Set(),
537
1003
  // 4. Collections
538
1004
  collections: {},
1005
+ materials: {},
1006
+ installedPlugins: [],
1007
+ hasExplicitPluginInstallState: false,
539
1008
  // 5. Read-only lock
540
1009
  readOnly: false,
541
1010
  setReadOnly: (readOnly) => set({ readOnly }),
@@ -545,15 +1014,25 @@ const useScene = create()(temporal((set, get) => ({
545
1014
  rootNodeIds: [],
546
1015
  dirtyNodes: new Set(),
547
1016
  collections: {},
1017
+ materials: {},
1018
+ installedPlugins: [],
1019
+ hasExplicitPluginInstallState: false,
548
1020
  });
549
1021
  },
550
1022
  clearScene: () => {
1023
+ const installedPlugins = get().installedPlugins;
1024
+ const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState;
551
1025
  get().unloadScene();
552
1026
  get().loadScene(); // Default scene
1027
+ set({ installedPlugins, hasExplicitPluginInstallState });
553
1028
  },
554
- setScene: (nodes, rootNodeIds) => {
1029
+ setScene: (nodes, rootNodeIds, extra) => {
555
1030
  // Apply backward compatibility migrations
556
- const patchedNodes = migrateNodes(nodes);
1031
+ const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes);
1032
+ // Scene materials minted by the wall legacy→slots migration join the
1033
+ // loaded palette (existing refs win on id collision — there are none,
1034
+ // ids are freshly generated).
1035
+ const materials = { ...mintedMaterials, ...(extra?.materials ?? {}) };
557
1036
  // Remove orphans: nodes whose parentId points to a non-existent node
558
1037
  const cleanedNodes = { ...patchedNodes };
559
1038
  for (const node of Object.values(cleanedNodes)) {
@@ -562,12 +1041,6 @@ const useScene = create()(temporal((set, get) => ({
562
1041
  delete cleanedNodes[node.id];
563
1042
  }
564
1043
  }
565
- set({
566
- nodes: cleanedNodes,
567
- rootNodeIds,
568
- dirtyNodes: new Set(),
569
- collections: {},
570
- });
571
1044
  const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds);
572
1045
  const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds);
573
1046
  if (normalizedRootNodeIds.length > 0) {
@@ -578,17 +1051,47 @@ const useScene = create()(temporal((set, get) => ({
578
1051
  delete cleanedNodes[node.id];
579
1052
  }
580
1053
  }
1054
+ // Single tracked `set`: with zundo, every tracked write pushes the
1055
+ // pre-write state onto `pastStates`. Writing the scene in two steps
1056
+ // (as this used to) exposed a half-normalized intermediate state —
1057
+ // and the pre-load (possibly empty) state — as undo targets.
581
1058
  set({
582
1059
  nodes: cleanedNodes,
583
1060
  rootNodeIds: normalizedRootNodeIds,
584
1061
  dirtyNodes: new Set(),
585
- collections: {},
1062
+ collections: extra?.collections ?? {},
1063
+ materials,
1064
+ installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
1065
+ hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
586
1066
  });
587
1067
  // Mark all nodes as dirty to trigger re-validation
588
1068
  Object.values(cleanedNodes).forEach((node) => {
589
1069
  get().markDirty(node.id);
590
1070
  });
591
1071
  },
1072
+ setInstalledPlugins: (pluginIds, options) => {
1073
+ if (get().readOnly)
1074
+ return;
1075
+ const nextInstalledPlugins = Array.from(new Set(pluginIds));
1076
+ const previousInstalledPlugins = get().installedPlugins;
1077
+ const dirtyNodes = new Set(get().dirtyNodes);
1078
+ for (const node of Object.values(get().nodes)) {
1079
+ if (!getNodePluginId(node.type))
1080
+ continue;
1081
+ if (!isNodeKindEnabled(node.type, nextInstalledPlugins)) {
1082
+ dirtyNodes.delete(node.id);
1083
+ }
1084
+ else if (!isNodeKindEnabled(node.type, previousInstalledPlugins)) {
1085
+ if (nodeRegistry.get(node.type)?.dirtyTracking !== false)
1086
+ dirtyNodes.add(node.id);
1087
+ }
1088
+ }
1089
+ set({
1090
+ installedPlugins: nextInstalledPlugins,
1091
+ hasExplicitPluginInstallState: options?.explicit ?? get().hasExplicitPluginInstallState,
1092
+ dirtyNodes,
1093
+ });
1094
+ },
592
1095
  loadScene: () => {
593
1096
  if (get().rootNodeIds.length > 0) {
594
1097
  // Assign all nodes as dirty to force re-validation
@@ -601,6 +1104,7 @@ const useScene = create()(temporal((set, get) => ({
601
1104
  const level0 = LevelNode.parse({
602
1105
  level: 0,
603
1106
  children: [],
1107
+ height: 2.5,
604
1108
  });
605
1109
  const building = BuildingNode.parse({
606
1110
  children: [level0.id],
@@ -619,6 +1123,11 @@ const useScene = create()(temporal((set, get) => ({
619
1123
  set({ nodes, rootNodeIds });
620
1124
  },
621
1125
  markDirty: (id) => {
1126
+ const node = get().nodes[id];
1127
+ if (node && !isNodeKindEnabled(node.type, get().installedPlugins))
1128
+ return;
1129
+ if (node && nodeRegistry.get(node.type)?.dirtyTracking === false)
1130
+ return;
622
1131
  get().dirtyNodes.add(id);
623
1132
  },
624
1133
  clearDirty: (id) => {
@@ -730,20 +1239,448 @@ const useScene = create()(temporal((set, get) => ({
730
1239
  return { collections: nextCollections, nodes: nextNodes };
731
1240
  });
732
1241
  },
1242
+ // --- SCENE MATERIALS ---
1243
+ addSceneMaterial: (material) => {
1244
+ if (get().readOnly)
1245
+ return;
1246
+ set((state) => ({
1247
+ materials: { ...state.materials, [material.id]: material },
1248
+ }));
1249
+ },
1250
+ updateSceneMaterial: (id, data) => {
1251
+ if (get().readOnly)
1252
+ return;
1253
+ set((state) => {
1254
+ const material = state.materials[id];
1255
+ if (!material)
1256
+ return state;
1257
+ return { materials: { ...state.materials, [id]: { ...material, ...data } } };
1258
+ });
1259
+ },
1260
+ removeSceneMaterial: (id) => {
1261
+ if (get().readOnly)
1262
+ return;
1263
+ set((state) => {
1264
+ const materials = { ...state.materials };
1265
+ delete materials[id];
1266
+ return { materials };
1267
+ });
1268
+ },
733
1269
  }), {
734
- partialize: (state) => {
735
- const { nodes, rootNodeIds, collections } = state;
736
- return { nodes, rootNodeIds, collections };
1270
+ partialize: (state) => sceneHistorySnapshotFromState(state),
1271
+ equality: (pastState, currentState) => areSceneSnapshotsEqual(pastState, currentState),
1272
+ onSave: (pastState, currentState) => {
1273
+ notifySceneCommit({
1274
+ origin: 'local',
1275
+ before: sceneHistorySnapshotFromState(pastState),
1276
+ current: sceneHistorySnapshotFromState(currentState),
1277
+ });
737
1278
  },
738
1279
  limit: 50, // Limit to last 50 actions
739
1280
  }));
740
1281
  export default useScene;
1282
+ let sceneReadOnlyLeaseCount = 0;
1283
+ let sceneReadOnlyLeaseBaseline = false;
1284
+ export function acquireSceneReadOnlyLease() {
1285
+ if (sceneReadOnlyLeaseCount === 0) {
1286
+ sceneReadOnlyLeaseBaseline = useScene.getState().readOnly;
1287
+ }
1288
+ sceneReadOnlyLeaseCount += 1;
1289
+ useScene.setState({ readOnly: true });
1290
+ let released = false;
1291
+ return () => {
1292
+ if (released)
1293
+ return;
1294
+ released = true;
1295
+ sceneReadOnlyLeaseCount = Math.max(0, sceneReadOnlyLeaseCount - 1);
1296
+ if (sceneReadOnlyLeaseCount > 0)
1297
+ return;
1298
+ useScene.setState({ readOnly: sceneReadOnlyLeaseBaseline });
1299
+ sceneReadOnlyLeaseBaseline = false;
1300
+ };
1301
+ }
1302
+ function sceneOperationPatchLiveConflictIds(beforeState, changes) {
1303
+ const ids = new Set();
1304
+ const addNodeAndParent = (node) => {
1305
+ if (!node)
1306
+ return;
1307
+ ids.add(node.id);
1308
+ if (node.parentId)
1309
+ ids.add(node.parentId);
1310
+ };
1311
+ for (const { id, data } of changes.nodeUpdates) {
1312
+ ids.add(id);
1313
+ if (Object.hasOwn(data, 'parentId')) {
1314
+ const currentParentId = beforeState.nodes[id]?.parentId;
1315
+ if (currentParentId)
1316
+ ids.add(currentParentId);
1317
+ if (typeof data.parentId === 'string')
1318
+ ids.add(data.parentId);
1319
+ }
1320
+ }
1321
+ for (const { node } of changes.nodeCreates)
1322
+ addNodeAndParent(node);
1323
+ for (const { node } of changes.nodeDeletes)
1324
+ addNodeAndParent(node);
1325
+ return ids;
1326
+ }
1327
+ function sceneOperationPatchHasLiveConflict(beforeState, changes) {
1328
+ const overrides = useLiveNodeOverrides.getState();
1329
+ const transforms = useLiveTransforms.getState();
1330
+ for (const id of sceneOperationPatchLiveConflictIds(beforeState, changes)) {
1331
+ if (overrides.get(id) || transforms.get(id))
1332
+ return true;
1333
+ }
1334
+ return false;
1335
+ }
1336
+ function areScenePatchValuesEqual(left, right) {
1337
+ if (Object.is(left, right))
1338
+ return true;
1339
+ if (typeof left !== typeof right || left === null || right === null)
1340
+ return false;
1341
+ if (Array.isArray(left) || Array.isArray(right)) {
1342
+ return (Array.isArray(left) &&
1343
+ Array.isArray(right) &&
1344
+ left.length === right.length &&
1345
+ left.every((value, index) => areScenePatchValuesEqual(value, right[index])));
1346
+ }
1347
+ if (typeof left !== 'object' || typeof right !== 'object')
1348
+ return false;
1349
+ const leftRecord = left;
1350
+ const rightRecord = right;
1351
+ const leftKeys = Object.keys(leftRecord);
1352
+ if (leftKeys.length !== Object.keys(rightRecord).length)
1353
+ return false;
1354
+ return leftKeys.every((key) => Object.hasOwn(rightRecord, key) &&
1355
+ areScenePatchValuesEqual(leftRecord[key], rightRecord[key]));
1356
+ }
1357
+ function parseSceneOperationPatchNode(value) {
1358
+ const builtin = AnyNodeSchema.safeParse(value);
1359
+ if (builtin.success)
1360
+ return builtin.data;
1361
+ if (!(value && typeof value === 'object' && !Array.isArray(value)))
1362
+ return null;
1363
+ const type = value.type;
1364
+ if (typeof type !== 'string')
1365
+ return null;
1366
+ const registered = nodeRegistry.get(type)?.schema.safeParse(value);
1367
+ return registered?.success ? registered.data : null;
1368
+ }
1369
+ function structuralSiblingIds(nodes, rootNodeIds, parentId) {
1370
+ if (!parentId)
1371
+ return rootNodeIds;
1372
+ const parent = nodes[parentId];
1373
+ if (!(parent && 'children' in parent && Array.isArray(parent.children)))
1374
+ return null;
1375
+ return parent.children.every((id) => typeof id === 'string')
1376
+ ? parent.children
1377
+ : null;
1378
+ }
1379
+ function insertSceneStructuralPlacements(base, placements) {
1380
+ if (placements.length === 0)
1381
+ return base;
1382
+ const result = new Array(base.length + placements.length);
1383
+ for (const change of placements) {
1384
+ if (!Number.isSafeInteger(change.position) ||
1385
+ change.position < 0 ||
1386
+ change.position >= result.length ||
1387
+ result[change.position] !== undefined) {
1388
+ return null;
1389
+ }
1390
+ result[change.position] = change.node.id;
1391
+ }
1392
+ let baseIndex = 0;
1393
+ for (let index = 0; index < result.length; index += 1) {
1394
+ if (result[index] !== undefined)
1395
+ continue;
1396
+ result[index] = base[baseIndex];
1397
+ baseIndex += 1;
1398
+ }
1399
+ return result;
1400
+ }
1401
+ function sceneOperationPatchNextState(beforeState, changes) {
1402
+ const createIds = new Set();
1403
+ const deleteIds = new Set();
1404
+ const updateIds = new Set();
1405
+ const materialIds = new Set();
1406
+ const parsedCreates = [];
1407
+ for (const change of changes.nodeCreates) {
1408
+ const parsed = parseSceneOperationPatchNode(change.node);
1409
+ if (!parsed ||
1410
+ parsed.id !== change.node.id ||
1411
+ createIds.has(parsed.id) ||
1412
+ Object.hasOwn(beforeState.nodes, parsed.id) ||
1413
+ !Number.isSafeInteger(change.position) ||
1414
+ change.position < 0) {
1415
+ return null;
1416
+ }
1417
+ createIds.add(parsed.id);
1418
+ parsedCreates.push({ node: change.node, position: change.position });
1419
+ }
1420
+ for (const change of changes.nodeDeletes) {
1421
+ const id = change.node.id;
1422
+ const current = beforeState.nodes[id];
1423
+ const parentId = change.node.parentId ?? null;
1424
+ const siblings = structuralSiblingIds(beforeState.nodes, beforeState.rootNodeIds, parentId);
1425
+ if (!current ||
1426
+ createIds.has(id) ||
1427
+ deleteIds.has(id) ||
1428
+ !Number.isSafeInteger(change.position) ||
1429
+ change.position < 0 ||
1430
+ siblings?.[change.position] !== id ||
1431
+ !areScenePatchValuesEqual(current, change.node)) {
1432
+ return null;
1433
+ }
1434
+ deleteIds.add(id);
1435
+ }
1436
+ for (const id of createIds) {
1437
+ if (deleteIds.has(id))
1438
+ return null;
1439
+ }
1440
+ for (const node of Object.values(beforeState.nodes)) {
1441
+ const parentId = node.parentId ?? null;
1442
+ if (parentId && deleteIds.has(parentId) && !deleteIds.has(node.id))
1443
+ return null;
1444
+ }
1445
+ const nextNodes = { ...beforeState.nodes };
1446
+ let nextRootNodeIds = deleteIds.size > 0
1447
+ ? beforeState.rootNodeIds.filter((id) => !deleteIds.has(id))
1448
+ : beforeState.rootNodeIds;
1449
+ const changedParentIds = new Set();
1450
+ for (const change of changes.nodeDeletes) {
1451
+ const parentId = change.node.parentId ?? null;
1452
+ if (parentId && !deleteIds.has(parentId))
1453
+ changedParentIds.add(parentId);
1454
+ delete nextNodes[change.node.id];
1455
+ }
1456
+ for (const parentId of changedParentIds) {
1457
+ const parent = nextNodes[parentId];
1458
+ if (!(parent && 'children' in parent && Array.isArray(parent.children)))
1459
+ return null;
1460
+ nextNodes[parentId] = {
1461
+ ...parent,
1462
+ children: parent.children.filter((id) => !deleteIds.has(id)),
1463
+ };
1464
+ }
1465
+ for (const change of parsedCreates)
1466
+ nextNodes[change.node.id] = change.node;
1467
+ const rootCreates = [];
1468
+ const existingParentCreates = new Map();
1469
+ for (const change of parsedCreates) {
1470
+ const parentId = change.node.parentId ?? null;
1471
+ if (!parentId) {
1472
+ rootCreates.push(change);
1473
+ continue;
1474
+ }
1475
+ const parent = nextNodes[parentId];
1476
+ if (!parent)
1477
+ return null;
1478
+ if (createIds.has(parentId)) {
1479
+ if (!('children' in parent) ||
1480
+ !Array.isArray(parent.children) ||
1481
+ parent.children[change.position] !== change.node.id) {
1482
+ return null;
1483
+ }
1484
+ continue;
1485
+ }
1486
+ const placements = existingParentCreates.get(parentId) ?? [];
1487
+ placements.push(change);
1488
+ existingParentCreates.set(parentId, placements);
1489
+ }
1490
+ const insertedRoots = insertSceneStructuralPlacements(nextRootNodeIds, rootCreates);
1491
+ if (!insertedRoots)
1492
+ return null;
1493
+ nextRootNodeIds = insertedRoots;
1494
+ for (const [parentId, placements] of existingParentCreates) {
1495
+ const parent = nextNodes[parentId];
1496
+ if (!(parent && 'children' in parent && Array.isArray(parent.children)))
1497
+ return null;
1498
+ const children = insertSceneStructuralPlacements(parent.children, placements);
1499
+ if (!children)
1500
+ return null;
1501
+ nextNodes[parentId] = { ...parent, children };
1502
+ }
1503
+ for (const change of parsedCreates) {
1504
+ const parentId = change.node.parentId ?? null;
1505
+ const siblings = structuralSiblingIds(nextNodes, nextRootNodeIds, parentId);
1506
+ if (siblings?.[change.position] !== change.node.id)
1507
+ return null;
1508
+ if (!('children' in change.node && Array.isArray(change.node.children)))
1509
+ continue;
1510
+ for (const childId of change.node.children) {
1511
+ if (nextNodes[childId]?.parentId !== change.node.id)
1512
+ return null;
1513
+ }
1514
+ }
1515
+ for (const { id, data, removeFields } of changes.nodeUpdates) {
1516
+ const node = nextNodes[id];
1517
+ if (!node ||
1518
+ createIds.has(id) ||
1519
+ deleteIds.has(id) ||
1520
+ updateIds.has(id) ||
1521
+ ('id' in data && data.id !== node.id) ||
1522
+ ('type' in data && data.type !== node.type) ||
1523
+ ('object' in data && data.object !== node.object) ||
1524
+ removeFields.some((field) => field === 'id' || field === 'object' || field === 'type' || Object.hasOwn(data, field))) {
1525
+ return null;
1526
+ }
1527
+ updateIds.add(id);
1528
+ const candidate = { ...node, ...data };
1529
+ for (const field of removeFields)
1530
+ delete candidate[field];
1531
+ const validated = parseSceneOperationPatchNode(candidate);
1532
+ if (!validated ||
1533
+ validated.id !== id ||
1534
+ validated.type !== node.type ||
1535
+ validated.object !== node.object) {
1536
+ return null;
1537
+ }
1538
+ nextNodes[id] = candidate;
1539
+ }
1540
+ const materials = changes.materialChanges.length > 0 ? { ...beforeState.materials } : beforeState.materials;
1541
+ for (const { id, material } of changes.materialChanges) {
1542
+ if (materialIds.has(id) ||
1543
+ (material !== null && (material.id !== id || !SceneMaterial.safeParse(material).success))) {
1544
+ return null;
1545
+ }
1546
+ materialIds.add(id);
1547
+ if (material === null)
1548
+ delete materials[id];
1549
+ else
1550
+ materials[id] = material;
1551
+ }
1552
+ return { materials, nodes: nextNodes, rootNodeIds: nextRootNodeIds };
1553
+ }
1554
+ export function applySceneOperationPatch(changes) {
1555
+ const beforeState = useScene.getState();
1556
+ if (changes.nodeUpdates.length === 0 &&
1557
+ changes.materialChanges.length === 0 &&
1558
+ changes.nodeCreates.length === 0 &&
1559
+ changes.nodeDeletes.length === 0) {
1560
+ return false;
1561
+ }
1562
+ if (sceneOperationPatchHasLiveConflict(beforeState, changes))
1563
+ return false;
1564
+ const next = sceneOperationPatchNextState(beforeState, changes);
1565
+ if (!next)
1566
+ return false;
1567
+ const before = sceneHistorySnapshotFromState(beforeState);
1568
+ const shouldScopeHistoryPause = useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0;
1569
+ if (shouldScopeHistoryPause)
1570
+ pauseSceneHistory(useScene);
1571
+ try {
1572
+ useScene.setState(next);
1573
+ }
1574
+ finally {
1575
+ if (shouldScopeHistoryPause)
1576
+ resumeSceneHistory(useScene);
1577
+ }
1578
+ const currentState = useScene.getState();
1579
+ const current = sceneHistorySnapshotFromState(currentState);
1580
+ const touchedNodeIds = new Set([
1581
+ ...changes.nodeUpdates.map(({ id }) => id),
1582
+ ...changes.nodeCreates.map(({ node }) => node.id),
1583
+ ...changes.nodeDeletes.map(({ node }) => node.id),
1584
+ ]);
1585
+ for (const id of touchedNodeIds) {
1586
+ useLiveNodeOverrides.getState().clear(id);
1587
+ useLiveTransforms.getState().clear(id);
1588
+ }
1589
+ if (areSceneSnapshotsEqual(before, current))
1590
+ return false;
1591
+ for (const id of touchedNodeIds) {
1592
+ if (current.nodes[id])
1593
+ currentState.markDirty(id);
1594
+ else
1595
+ currentState.clearDirty(id);
1596
+ const beforeParentId = before.nodes[id]?.parentId;
1597
+ const currentParentId = current.nodes[id]?.parentId;
1598
+ if (beforeParentId)
1599
+ currentState.markDirty(beforeParentId);
1600
+ if (currentParentId)
1601
+ currentState.markDirty(currentParentId);
1602
+ }
1603
+ const structuralParentIds = new Set();
1604
+ for (const { node } of changes.nodeCreates) {
1605
+ if (node.parentId)
1606
+ structuralParentIds.add(node.parentId);
1607
+ }
1608
+ for (const { node } of changes.nodeDeletes) {
1609
+ if (node.parentId)
1610
+ structuralParentIds.add(node.parentId);
1611
+ }
1612
+ for (const parentId of structuralParentIds) {
1613
+ const parent = current.nodes[parentId];
1614
+ if (!(parent && 'children' in parent && Array.isArray(parent.children)))
1615
+ continue;
1616
+ for (const childId of parent.children)
1617
+ currentState.markDirty(childId);
1618
+ }
1619
+ if (changes.materialChanges.length > 0) {
1620
+ const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)));
1621
+ for (const node of Object.values(current.nodes)) {
1622
+ const slots = 'slots' in node ? node.slots : undefined;
1623
+ if (!(slots && Object.values(slots).some((ref) => materialRefs.has(ref))))
1624
+ continue;
1625
+ currentState.markDirty(node.id);
1626
+ if (node.parentId)
1627
+ currentState.markDirty(node.parentId);
1628
+ }
1629
+ }
1630
+ for (const { node } of changes.nodeDeletes)
1631
+ currentState.clearDirty(node.id);
1632
+ notifySceneCommit({
1633
+ origin: 'host',
1634
+ before,
1635
+ current,
1636
+ });
1637
+ return true;
1638
+ }
1639
+ export function applyScenePatch(changes) {
1640
+ return applySceneOperationPatch({
1641
+ ...changes,
1642
+ nodeCreates: [],
1643
+ nodeDeletes: [],
1644
+ });
1645
+ }
1646
+ export function applySceneSnapshot(snapshot, options) {
1647
+ const before = sceneHistorySnapshotFromState(useScene.getState());
1648
+ const temporalState = useScene.temporal.getState();
1649
+ if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) {
1650
+ throw new Error('Cannot replace the scene snapshot during an active interaction');
1651
+ }
1652
+ pauseSceneHistory(useScene);
1653
+ try {
1654
+ useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, {
1655
+ collections: snapshot.collections,
1656
+ installedPlugins: snapshot.installedPlugins,
1657
+ materials: snapshot.materials,
1658
+ });
1659
+ useScene.temporal.getState().clear();
1660
+ }
1661
+ finally {
1662
+ resumeSceneHistory(useScene);
1663
+ }
1664
+ useLiveNodeOverrides.getState().clearAll();
1665
+ useLiveTransforms.getState().clearAll();
1666
+ const current = sceneHistorySnapshotFromState(useScene.getState());
1667
+ if (areSceneSnapshotsEqual(before, current))
1668
+ return false;
1669
+ notifySceneCommit({ origin: options.origin, before, current });
1670
+ return true;
1671
+ }
741
1672
  // Track previous temporal state lengths and node snapshot for diffing
742
1673
  let prevPastLength = 0;
743
1674
  let prevFutureLength = 0;
744
1675
  let prevNodesSnapshot = null;
745
1676
  export function clearSceneHistory() {
746
1677
  resetSceneHistoryPauseDepth();
1678
+ // Resetting the pause-depth counter without resuming would strand the
1679
+ // temporal store in `isTracking: false` if a pause window was active when
1680
+ // the scene was (re)loaded — every edit after the load would then be
1681
+ // invisible to undo. Resume unconditionally so the cleared history starts
1682
+ // tracking from the loaded baseline.
1683
+ useScene.temporal.getState().resume();
747
1684
  useScene.temporal.getState().clear();
748
1685
  prevPastLength = 0;
749
1686
  prevFutureLength = 0;