@pascal-app/core 0.9.1 → 0.9.2

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 (321) hide show
  1. package/dist/events/bus.d.ts +30 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
  4. package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
  5. package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
  6. package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
  7. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  8. package/dist/hooks/scene-registry/scene-registry.js +27 -1
  9. package/dist/hooks/scene-registry/scene-registry.test.d.ts +2 -0
  10. package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +1 -0
  11. package/dist/hooks/scene-registry/scene-registry.test.js +26 -0
  12. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +93 -0
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +69 -12
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  15. package/dist/hooks/spatial-grid/spatial-grid-manager.js +476 -195
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.js +19 -1
  18. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
  19. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
  20. package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
  21. package/dist/index.d.ts +16 -6
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +15 -5
  24. package/dist/lib/level-name.d.ts +4 -0
  25. package/dist/lib/level-name.d.ts.map +1 -0
  26. package/dist/lib/level-name.js +10 -0
  27. package/dist/lib/measurement-geometry.d.ts +19 -0
  28. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  29. package/dist/lib/measurement-geometry.js +232 -0
  30. package/dist/lib/measurement-geometry.test.d.ts +2 -0
  31. package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
  32. package/dist/lib/measurement-geometry.test.js +102 -0
  33. package/dist/lib/polygon-geometry.d.ts +0 -1
  34. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  35. package/dist/lib/polygon-geometry.js +0 -18
  36. package/dist/lib/selection-proxy.d.ts +4 -0
  37. package/dist/lib/selection-proxy.d.ts.map +1 -0
  38. package/dist/lib/selection-proxy.js +12 -0
  39. package/dist/lib/slab-polygon.d.ts +31 -2
  40. package/dist/lib/slab-polygon.d.ts.map +1 -1
  41. package/dist/lib/slab-polygon.js +525 -31
  42. package/dist/lib/slab-polygon.test.d.ts +2 -0
  43. package/dist/lib/slab-polygon.test.d.ts.map +1 -0
  44. package/dist/lib/slab-polygon.test.js +635 -0
  45. package/dist/lib/slots.d.ts +14 -0
  46. package/dist/lib/slots.d.ts.map +1 -0
  47. package/dist/lib/slots.js +26 -0
  48. package/dist/lib/space-detection.d.ts +25 -2
  49. package/dist/lib/space-detection.d.ts.map +1 -1
  50. package/dist/lib/space-detection.js +414 -118
  51. package/dist/lib/space-detection.test.js +301 -2
  52. package/dist/lib/wall-distance.d.ts +60 -0
  53. package/dist/lib/wall-distance.d.ts.map +1 -0
  54. package/dist/lib/wall-distance.js +85 -0
  55. package/dist/lib/zone-quantities.d.ts +21 -0
  56. package/dist/lib/zone-quantities.d.ts.map +1 -0
  57. package/dist/lib/zone-quantities.js +183 -0
  58. package/dist/lib/zone-quantities.test.d.ts +2 -0
  59. package/dist/lib/zone-quantities.test.d.ts.map +1 -0
  60. package/dist/lib/zone-quantities.test.js +82 -0
  61. package/dist/material-library.d.ts +20 -1
  62. package/dist/material-library.d.ts.map +1 -1
  63. package/dist/material-library.js +2181 -441
  64. package/dist/registry/handles.d.ts +75 -2
  65. package/dist/registry/handles.d.ts.map +1 -1
  66. package/dist/registry/index.d.ts +3 -3
  67. package/dist/registry/index.d.ts.map +1 -1
  68. package/dist/registry/index.js +1 -1
  69. package/dist/registry/registry.d.ts +46 -3
  70. package/dist/registry/registry.d.ts.map +1 -1
  71. package/dist/registry/registry.js +82 -1
  72. package/dist/registry/registry.test.js +14 -1
  73. package/dist/registry/subtree.d.ts.map +1 -1
  74. package/dist/registry/subtree.js +4 -0
  75. package/dist/registry/subtree.test.js +30 -0
  76. package/dist/registry/types.d.ts +717 -38
  77. package/dist/registry/types.d.ts.map +1 -1
  78. package/dist/schema/index.d.ts +21 -5
  79. package/dist/schema/index.d.ts.map +1 -1
  80. package/dist/schema/index.js +17 -3
  81. package/dist/schema/material.d.ts +3 -2
  82. package/dist/schema/material.d.ts.map +1 -1
  83. package/dist/schema/material.js +7 -5
  84. package/dist/schema/material.test.d.ts +2 -0
  85. package/dist/schema/material.test.d.ts.map +1 -0
  86. package/dist/schema/material.test.js +43 -0
  87. package/dist/schema/nodes/box-vent.d.ts +2 -2
  88. package/dist/schema/nodes/cabinet.d.ts +534 -0
  89. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  90. package/dist/schema/nodes/cabinet.js +137 -0
  91. package/dist/schema/nodes/ceiling.d.ts +3 -2
  92. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  93. package/dist/schema/nodes/ceiling.js +4 -0
  94. package/dist/schema/nodes/chimney.d.ts +4 -4
  95. package/dist/schema/nodes/column.d.ts +3 -2
  96. package/dist/schema/nodes/column.d.ts.map +1 -1
  97. package/dist/schema/nodes/column.js +3 -0
  98. package/dist/schema/nodes/cupola.d.ts +2 -2
  99. package/dist/schema/nodes/door.d.ts +13 -5
  100. package/dist/schema/nodes/door.d.ts.map +1 -1
  101. package/dist/schema/nodes/door.js +12 -0
  102. package/dist/schema/nodes/dormer.d.ts +8 -8
  103. package/dist/schema/nodes/dormer.js +1 -1
  104. package/dist/schema/nodes/downspout.d.ts +2 -2
  105. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  106. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  107. package/dist/schema/nodes/duct-fitting.js +92 -0
  108. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  109. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  110. package/dist/schema/nodes/duct-segment.js +73 -0
  111. package/dist/schema/nodes/duct-terminal.d.ts +57 -0
  112. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  113. package/dist/schema/nodes/duct-terminal.js +51 -0
  114. package/dist/schema/nodes/elevator.d.ts +3 -2
  115. package/dist/schema/nodes/elevator.d.ts.map +1 -1
  116. package/dist/schema/nodes/elevator.js +3 -0
  117. package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
  118. package/dist/schema/nodes/fence.d.ts +18 -2
  119. package/dist/schema/nodes/fence.d.ts.map +1 -1
  120. package/dist/schema/nodes/fence.js +25 -2
  121. package/dist/schema/nodes/gutter.d.ts +2 -2
  122. package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
  123. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  124. package/dist/schema/nodes/hvac-equipment.js +55 -0
  125. package/dist/schema/nodes/item.d.ts +8 -0
  126. package/dist/schema/nodes/item.d.ts.map +1 -1
  127. package/dist/schema/nodes/item.js +11 -0
  128. package/dist/schema/nodes/level.d.ts +1 -1
  129. package/dist/schema/nodes/level.d.ts.map +1 -1
  130. package/dist/schema/nodes/level.js +2 -0
  131. package/dist/schema/nodes/lineset.d.ts +42 -0
  132. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  133. package/dist/schema/nodes/lineset.js +38 -0
  134. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  135. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  136. package/dist/schema/nodes/liquid-line.js +24 -0
  137. package/dist/schema/nodes/measurement.d.ts +306 -0
  138. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  139. package/dist/schema/nodes/measurement.js +87 -0
  140. package/dist/schema/nodes/measurement.test.d.ts +2 -0
  141. package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
  142. package/dist/schema/nodes/measurement.test.js +139 -0
  143. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  144. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  145. package/dist/schema/nodes/pipe-fitting.js +45 -0
  146. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  147. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  148. package/dist/schema/nodes/pipe-segment.js +36 -0
  149. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  150. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  151. package/dist/schema/nodes/pipe-trap.js +36 -0
  152. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
  153. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
  154. package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
  155. package/dist/schema/nodes/ridge-vent.d.ts +18 -3
  156. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  157. package/dist/schema/nodes/ridge-vent.js +221 -3
  158. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  159. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  160. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  161. package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
  162. package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
  163. package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
  164. package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
  165. package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
  166. package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
  167. package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
  168. package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
  169. package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
  170. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  171. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  172. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  173. package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
  174. package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
  175. package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
  176. package/dist/schema/nodes/roof-segment.d.ts +84 -8
  177. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  178. package/dist/schema/nodes/roof-segment.js +290 -5
  179. package/dist/schema/nodes/roof.d.ts +8 -8
  180. package/dist/schema/nodes/shelf.d.ts +3 -2
  181. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  182. package/dist/schema/nodes/shelf.js +6 -0
  183. package/dist/schema/nodes/skylight.d.ts +10 -10
  184. package/dist/schema/nodes/slab.d.ts +3 -2
  185. package/dist/schema/nodes/slab.d.ts.map +1 -1
  186. package/dist/schema/nodes/slab.js +4 -0
  187. package/dist/schema/nodes/solar-panel.d.ts +4 -4
  188. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  189. package/dist/schema/nodes/stair.d.ts +13 -12
  190. package/dist/schema/nodes/stair.d.ts.map +1 -1
  191. package/dist/schema/nodes/stair.js +3 -0
  192. package/dist/schema/nodes/turbine-vent.d.ts +2 -2
  193. package/dist/schema/nodes/wall.d.ts +245 -6
  194. package/dist/schema/nodes/wall.d.ts.map +1 -1
  195. package/dist/schema/nodes/wall.js +257 -0
  196. package/dist/schema/nodes/wall.test.d.ts +2 -0
  197. package/dist/schema/nodes/wall.test.d.ts.map +1 -0
  198. package/dist/schema/nodes/wall.test.js +208 -0
  199. package/dist/schema/nodes/window.d.ts +11 -3
  200. package/dist/schema/nodes/window.d.ts.map +1 -1
  201. package/dist/schema/nodes/window.js +12 -0
  202. package/dist/schema/nodes/zone.d.ts +2 -0
  203. package/dist/schema/nodes/zone.d.ts.map +1 -1
  204. package/dist/schema/nodes/zone.js +6 -0
  205. package/dist/schema/scene-material.d.ts +41 -0
  206. package/dist/schema/scene-material.d.ts.map +1 -0
  207. package/dist/schema/scene-material.js +9 -0
  208. package/dist/schema/types.d.ts +1089 -94
  209. package/dist/schema/types.d.ts.map +1 -1
  210. package/dist/schema/types.js +23 -0
  211. package/dist/services/alignment-anchors.d.ts.map +1 -1
  212. package/dist/services/alignment-anchors.js +30 -1
  213. package/dist/services/alignment.d.ts +5 -0
  214. package/dist/services/alignment.d.ts.map +1 -1
  215. package/dist/services/alignment.js +2 -0
  216. package/dist/services/hosting.d.ts +8 -0
  217. package/dist/services/hosting.d.ts.map +1 -1
  218. package/dist/services/hosting.js +13 -4
  219. package/dist/services/hosting.test.js +32 -1
  220. package/dist/services/index.d.ts +8 -2
  221. package/dist/services/index.d.ts.map +1 -1
  222. package/dist/services/index.js +8 -2
  223. package/dist/services/level-height.d.ts +28 -0
  224. package/dist/services/level-height.d.ts.map +1 -0
  225. package/dist/services/level-height.js +62 -0
  226. package/dist/services/opening-guides.d.ts +141 -0
  227. package/dist/services/opening-guides.d.ts.map +1 -0
  228. package/dist/services/opening-guides.js +267 -0
  229. package/dist/services/opening-guides.test.d.ts +2 -0
  230. package/dist/services/opening-guides.test.d.ts.map +1 -0
  231. package/dist/services/opening-guides.test.js +208 -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/port-connectivity.test.d.ts +2 -0
  236. package/dist/services/port-connectivity.test.d.ts.map +1 -0
  237. package/dist/services/port-connectivity.test.js +297 -0
  238. package/dist/services/riser-diagram.d.ts +40 -0
  239. package/dist/services/riser-diagram.d.ts.map +1 -0
  240. package/dist/services/riser-diagram.js +110 -0
  241. package/dist/services/riser-diagram.test.d.ts +2 -0
  242. package/dist/services/riser-diagram.test.d.ts.map +1 -0
  243. package/dist/services/riser-diagram.test.js +66 -0
  244. package/dist/services/snap.d.ts +9 -1
  245. package/dist/services/snap.d.ts.map +1 -1
  246. package/dist/services/snap.js +21 -1
  247. package/dist/services/snap.test.js +43 -1
  248. package/dist/services/system-graph.d.ts +30 -0
  249. package/dist/services/system-graph.d.ts.map +1 -0
  250. package/dist/services/system-graph.js +164 -0
  251. package/dist/services/system-graph.test.d.ts +2 -0
  252. package/dist/services/system-graph.test.d.ts.map +1 -0
  253. package/dist/services/system-graph.test.js +138 -0
  254. package/dist/services/validate-dwv.d.ts +32 -0
  255. package/dist/services/validate-dwv.d.ts.map +1 -0
  256. package/dist/services/validate-dwv.js +118 -0
  257. package/dist/services/validate-dwv.test.d.ts +2 -0
  258. package/dist/services/validate-dwv.test.d.ts.map +1 -0
  259. package/dist/services/validate-dwv.test.js +109 -0
  260. package/dist/store/actions/node-actions.d.ts.map +1 -1
  261. package/dist/store/actions/node-actions.js +94 -1
  262. package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
  263. package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
  264. package/dist/store/actions/ridge-vent-update.test.js +264 -0
  265. package/dist/store/history-control.d.ts +11 -0
  266. package/dist/store/history-control.d.ts.map +1 -1
  267. package/dist/store/history-control.js +33 -0
  268. package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
  269. package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
  270. package/dist/store/use-scene-dirty-tracking.test.js +68 -0
  271. package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
  272. package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
  273. package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
  274. package/dist/store/use-scene-plugins.test.d.ts +2 -0
  275. package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
  276. package/dist/store/use-scene-plugins.test.js +51 -0
  277. package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
  278. package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
  279. package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
  280. package/dist/store/use-scene-window-migration.test.d.ts +2 -0
  281. package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
  282. package/dist/store/use-scene-window-migration.test.js +85 -0
  283. package/dist/store/use-scene.d.ts +17 -2
  284. package/dist/store/use-scene.d.ts.map +1 -1
  285. package/dist/store/use-scene.js +358 -51
  286. package/dist/systems/elevator/elevator-service.js +1 -1
  287. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  288. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  289. package/dist/systems/fence/fence-centerline.js +37 -0
  290. package/dist/systems/fence/fence-spline.d.ts +39 -0
  291. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  292. package/dist/systems/fence/fence-spline.js +196 -0
  293. package/dist/systems/fence/fence-spline.test.d.ts +2 -0
  294. package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
  295. package/dist/systems/fence/fence-spline.test.js +136 -0
  296. package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
  297. package/dist/systems/stair/stair-footprint.js +21 -11
  298. package/dist/systems/stair/stair-opening-preview.d.ts +1139 -469
  299. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  300. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  301. package/dist/systems/stair/stair-opening-sync.js +1 -18
  302. package/dist/systems/stair/stair-opening-sync.test.js +39 -0
  303. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  304. package/dist/systems/wall/wall-mitering.js +38 -2
  305. package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
  306. package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
  307. package/dist/systems/wall/wall-mitering.test.js +63 -0
  308. package/dist/utils/clone-scene-graph.d.ts +1 -0
  309. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  310. package/dist/utils/clone-scene-graph.js +23 -3
  311. package/dist/utils/clone-scene-graph.test.js +3 -0
  312. package/dist/utils/heal-scene-graph.d.ts +18 -0
  313. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  314. package/dist/utils/heal-scene-graph.js +90 -0
  315. package/dist/utils/heal-scene-graph.test.d.ts +2 -0
  316. package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
  317. package/dist/utils/heal-scene-graph.test.js +88 -0
  318. package/dist/validation/validate-build-json.d.ts +1 -0
  319. package/dist/validation/validate-build-json.d.ts.map +1 -1
  320. package/dist/validation/validate-build-json.js +31 -2
  321. package/package.json +3 -3
@@ -1,16 +1,23 @@
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, } from '../schema/scene-material';
20
+ import { healSceneNodes } from '../utils/heal-scene-graph';
14
21
  import * as nodeActions from './actions/node-actions';
15
22
  import { resetSceneHistoryPauseDepth } from './history-control';
16
23
  function getFiniteNumber(value, fallback) {
@@ -93,6 +100,13 @@ function normalizeDoorNode(node) {
93
100
  const parsed = DoorNodeSchema.safeParse(node);
94
101
  return parsed.success ? { ...node, ...parsed.data } : null;
95
102
  }
103
+ // Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
104
+ // `frameThickness`) load without it; the mesh builder then reads undefined and
105
+ // throws every frame. Zod-parse on load so schema defaults land, like doors.
106
+ function normalizeWindowNode(node) {
107
+ const parsed = WindowNodeSchema.safeParse(node);
108
+ return parsed.success ? { ...node, ...parsed.data } : null;
109
+ }
96
110
  function normalizeShelfNode(node) {
97
111
  const sanitized = {
98
112
  ...node,
@@ -182,40 +196,127 @@ function migrateElevatorParent(id, node, nodes) {
182
196
  parentId: buildingId,
183
197
  };
184
198
  }
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;
199
+ // Reuse an already-minted scene material for an identical inline legacy
200
+ // material so a whole building painted one custom colour collapses to one
201
+ // shared datablock (mirrors `commitSlotPaint`'s dedupe-on-match).
202
+ function findMintedSceneMaterialRef(material, mintedMaterials) {
203
+ const target = JSON.stringify(material);
204
+ for (const sceneMaterial of Object.values(mintedMaterials)) {
205
+ if (JSON.stringify(sceneMaterial.material) === target) {
206
+ return toSceneMaterialRef(sceneMaterial.id);
195
207
  }
196
- return {
197
- ...node,
198
- interiorMaterial: legacyFinish.material,
199
- interiorMaterialPreset: legacyFinish.materialPreset,
200
- exteriorMaterial: legacyFinish.material,
201
- exteriorMaterialPreset: legacyFinish.materialPreset,
202
- };
203
208
  }
204
- if (!hasInterior) {
205
- return {
206
- ...node,
207
- interiorMaterial: node.exteriorMaterial,
208
- interiorMaterialPreset: node.exteriorMaterialPreset,
209
- };
209
+ return undefined;
210
+ }
211
+ // Turn a legacy surface spec (`{ material, materialPreset }`) into a
212
+ // `MaterialRef`: a preset that's already a `library:`/`scene:` ref is used
213
+ // as-is; an inline material mints (or reuses) a scene material. Returns
214
+ // undefined when the spec carries no material. Shared by every legacy→slots
215
+ // migration below.
216
+ function legacySpecToMaterialRef(spec, mintedMaterials) {
217
+ if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) {
218
+ return spec.materialPreset;
210
219
  }
211
- if (!hasExterior) {
212
- return {
213
- ...node,
214
- exteriorMaterial: node.interiorMaterial,
215
- exteriorMaterialPreset: node.interiorMaterialPreset,
220
+ if (spec.material !== undefined) {
221
+ const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials);
222
+ if (existing)
223
+ return existing;
224
+ const id = generateSceneMaterialId();
225
+ mintedMaterials[id] = {
226
+ id,
227
+ name: `Material ${Object.keys(mintedMaterials).length + 1}`,
228
+ material: spec.material,
216
229
  };
230
+ return toSceneMaterialRef(id);
217
231
  }
218
- return node;
232
+ return undefined;
233
+ }
234
+ // Move the retired inline `material*` / `interiorMaterial*` / `exteriorMaterial*`
235
+ // fields onto the unified `node.slots` model (interior / exterior → a
236
+ // `library:`/`scene:` ref), minting scene materials for inline customs into
237
+ // `mintedMaterials` (merged into the scene material map by the caller). Already
238
+ // slot-modelled walls and walls with no legacy material are left untouched.
239
+ function migrateWallSurfaceMaterials(node, mintedMaterials) {
240
+ if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) {
241
+ return node;
242
+ }
243
+ const slots = { ...(node.slots ?? {}) };
244
+ for (const side of ['interior', 'exterior']) {
245
+ const spec = getEffectiveWallSurfaceMaterial(node, side);
246
+ const ref = legacySpecToMaterialRef(spec, mintedMaterials);
247
+ if (ref)
248
+ slots[side] = ref;
249
+ }
250
+ if (Object.keys(slots).length === 0) {
251
+ return node;
252
+ }
253
+ return {
254
+ ...node,
255
+ slots,
256
+ material: undefined,
257
+ materialPreset: undefined,
258
+ interiorMaterial: undefined,
259
+ interiorMaterialPreset: undefined,
260
+ exteriorMaterial: undefined,
261
+ exteriorMaterialPreset: undefined,
262
+ };
263
+ }
264
+ // Move a kind's single legacy `material` / `materialPreset` onto its declared
265
+ // slots. A pre-slot-model node painted one material rendered that material on
266
+ // every part (each slot resolves `node.slots[slot]` → legacy → default), so the
267
+ // migration writes the same ref to every slot id the kind can expose — unused
268
+ // conditional slots are harmless. Already slot-modelled or unpainted nodes are
269
+ // left untouched. Mirrors `migrateWallSurfaceMaterials` for single-surface and
270
+ // whole-object kinds (slab, ceiling, fence, column, shelf).
271
+ function migrateSingleMaterialSlots(node, slotIds, mintedMaterials) {
272
+ if (node.slots && Object.keys(node.slots).length > 0) {
273
+ return node;
274
+ }
275
+ const ref = legacySpecToMaterialRef({ material: node.material, materialPreset: node.materialPreset }, mintedMaterials);
276
+ if (!ref) {
277
+ return node;
278
+ }
279
+ const slots = {};
280
+ for (const slotId of slotIds)
281
+ slots[slotId] = ref;
282
+ return { ...node, slots, material: undefined, materialPreset: undefined };
283
+ }
284
+ // Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` /
285
+ // `railingMaterial*`) plus a catch-all. Map each to its slot via the same
286
+ // fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`):
287
+ // tread→treads, side→body, railing→railing. Runs after
288
+ // `migrateStairSurfaceMaterials` has normalised the legacy fields.
289
+ function migrateStairSurfaceSlots(node, mintedMaterials) {
290
+ if (node.slots && Object.keys(node.slots).length > 0) {
291
+ return node;
292
+ }
293
+ const roleToSlot = [
294
+ ['tread', 'treads'],
295
+ ['side', 'body'],
296
+ ['railing', 'railing'],
297
+ ];
298
+ const slots = {};
299
+ for (const [role, slotId] of roleToSlot) {
300
+ const spec = getEffectiveStairSurfaceMaterial(node, role);
301
+ const ref = legacySpecToMaterialRef(spec, mintedMaterials);
302
+ if (ref)
303
+ slots[slotId] = ref;
304
+ }
305
+ if (Object.keys(slots).length === 0) {
306
+ return node;
307
+ }
308
+ return {
309
+ ...node,
310
+ slots,
311
+ material: undefined,
312
+ materialPreset: undefined,
313
+ treadMaterial: undefined,
314
+ treadMaterialPreset: undefined,
315
+ sideMaterial: undefined,
316
+ sideMaterialPreset: undefined,
317
+ railingMaterial: undefined,
318
+ railingMaterialPreset: undefined,
319
+ };
219
320
  }
220
321
  function migrateStairSurfaceMaterials(node) {
221
322
  const hasRailing = node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string';
@@ -334,7 +435,20 @@ function migrateRoofSurfaceMaterials(node) {
334
435
  return next;
335
436
  }
336
437
  function migrateNodes(nodes) {
337
- const patchedNodes = { ...nodes };
438
+ // Repair pre-existing corruption (null children, zero-length walls) before
439
+ // any per-type migration runs, so already-saved scenes load cleanly.
440
+ const { nodes: healed } = healSceneNodes(nodes);
441
+ const patchedNodes = { ...healed };
442
+ // Scene materials minted while moving legacy wall fields onto `node.slots`;
443
+ // merged into the scene material map by the caller (`setScene`).
444
+ const mintedMaterials = {};
445
+ // Pass 1: all node types except elevator.
446
+ // Elevator migration (migrateElevatorParent) mutates level.children to remove
447
+ // the elevator ID. If the elevator is processed before its parent level in
448
+ // Object.entries order, the level migration in this same pass would then see
449
+ // a children array that still contains the elevator ID and filter it out as
450
+ // "missing" — corrupting the level. Running elevators in a second pass after
451
+ // all levels are stable avoids the race entirely.
338
452
  for (const [id, node] of Object.entries(patchedNodes)) {
339
453
  // 1. Item scale migration
340
454
  if (node.type === 'item' && !('scale' in node)) {
@@ -410,11 +524,18 @@ function migrateNodes(nodes) {
410
524
  patchedNodes[id] = normalized;
411
525
  }
412
526
  }
527
+ if (node.type === 'window') {
528
+ const normalized = normalizeWindowNode(node);
529
+ if (normalized) {
530
+ patchedNodes[id] = normalized;
531
+ }
532
+ }
413
533
  if (node.type === 'stair') {
414
534
  const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node));
415
535
  if (normalized) {
416
536
  patchedNodes[id] = normalized;
417
537
  }
538
+ patchedNodes[id] = migrateStairSurfaceSlots(patchedNodes[id], mintedMaterials);
418
539
  }
419
540
  if (node.type === 'stair-segment') {
420
541
  const normalized = normalizeStairSegmentNode(node);
@@ -423,20 +544,52 @@ function migrateNodes(nodes) {
423
544
  }
424
545
  }
425
546
  if (node.type === 'wall') {
426
- patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]);
547
+ patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials);
427
548
  }
428
- if (node.type === 'shelf') {
429
- const normalized = normalizeShelfNode(node);
430
- if (normalized) {
431
- patchedNodes[id] = normalized;
549
+ // Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the
550
+ // per-compartment `doorType`) and was removed from both cabinet schemas;
551
+ // `handlePosition: 'edge'` behaved identically to 'auto' and was dropped
552
+ // from the enum; the compartment stack became a discriminated union that
553
+ // rejects a `cooktopLayout` mismatched to its gas/induction type (the old
554
+ // loose schema ignored it).
555
+ if (node.type === 'cabinet' || node.type === 'cabinet-module') {
556
+ const { doorStyle: _doorStyle, ...rest } = node;
557
+ const next = rest;
558
+ if (next.handlePosition === 'edge')
559
+ next.handlePosition = 'auto';
560
+ if (Array.isArray(next.stack)) {
561
+ next.stack = next.stack.map((compartment) => {
562
+ if (!compartment || typeof compartment !== 'object')
563
+ return compartment;
564
+ const layout = compartment.cooktopLayout;
565
+ if (typeof layout !== 'string')
566
+ return compartment;
567
+ if (compartment.type === 'cooktop-gas' && !layout.startsWith('gas-')) {
568
+ return { ...compartment, cooktopLayout: 'gas-4burner' };
569
+ }
570
+ if (compartment.type === 'cooktop-induction' && !layout.startsWith('induction-')) {
571
+ return { ...compartment, cooktopLayout: 'induction-4zone' };
572
+ }
573
+ return compartment;
574
+ });
432
575
  }
576
+ patchedNodes[id] = next;
577
+ }
578
+ if (node.type === 'slab' || node.type === 'ceiling') {
579
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials);
580
+ }
581
+ if (node.type === 'fence') {
582
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['posts', 'infill', 'base', 'rail'], mintedMaterials);
433
583
  }
434
- if (node.type === 'elevator') {
435
- const parentMigrated = migrateElevatorParent(id, node, patchedNodes);
436
- const normalized = normalizeElevatorNode(parentMigrated);
584
+ if (node.type === 'column') {
585
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['shaft', 'base', 'capital', 'frame'], mintedMaterials);
586
+ }
587
+ if (node.type === 'shelf') {
588
+ const normalized = normalizeShelfNode(node);
437
589
  if (normalized) {
438
590
  patchedNodes[id] = normalized;
439
591
  }
592
+ patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['shelves', 'frame', 'back'], mintedMaterials);
440
593
  }
441
594
  // Roof-segment hosting was added in this migration cycle (the same
442
595
  // pattern as shelf above). Older segments saved before the schema
@@ -449,6 +602,41 @@ function migrateNodes(nodes) {
449
602
  if (node.type === 'roof-segment' && !Array.isArray(node.children)) {
450
603
  patchedNodes[id] = { ...node, children: [] };
451
604
  }
605
+ // Roof-hosted wall children (door / window / item) originally stored
606
+ // SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
607
+ // format moved to explicit `roofFace` + FACE-LOCAL coords so the
608
+ // renderer's face frame can track segment edits live. Convert in
609
+ // place: face from the old cardinal yaw, u/v from the outer-plane
610
+ // projection, z re-based from the outer plane to the wall mid-plane.
611
+ if ((node.type === 'door' || node.type === 'window' || node.type === 'item') &&
612
+ typeof node.roofSegmentId === 'string' &&
613
+ node.roofFace === undefined) {
614
+ const current = patchedNodes[id];
615
+ const segment = patchedNodes[current.roofSegmentId];
616
+ if (segment?.type === 'roof-segment') {
617
+ const tau = Math.PI * 2;
618
+ const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau;
619
+ const eps = 1e-3;
620
+ const face = yaw < eps || tau - yaw < eps
621
+ ? 'front'
622
+ : Math.abs(yaw - Math.PI) < eps
623
+ ? 'back'
624
+ : Math.abs(yaw - Math.PI / 2) < eps
625
+ ? 'right'
626
+ : Math.abs(yaw - (3 * Math.PI) / 2) < eps
627
+ ? 'left'
628
+ : null;
629
+ if (face) {
630
+ const { u, v, dist } = segmentPointToRoofWallFace(segment, face, current.position);
631
+ patchedNodes[id] = {
632
+ ...current,
633
+ roofFace: face,
634
+ position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
635
+ rotation: [0, 0, 0],
636
+ };
637
+ }
638
+ }
639
+ }
452
640
  if (node.type === 'roof') {
453
641
  patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]);
454
642
  }
@@ -476,8 +664,52 @@ function migrateNodes(nodes) {
476
664
  patchedNodes[id] = { ...node, children: flattened };
477
665
  }
478
666
  }
667
+ // Level children normalization.
668
+ // Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node
669
+ // map (e.g. elevator IDs that lived under a level before the elevator
670
+ // parent migration moved them up to building). If those dangling IDs are
671
+ // left in place, collectReachableNodeIds marks the level as having
672
+ // reachable children that don't exist, which corrupts the scene graph
673
+ // traversal and leaves the LevelNode in a broken state — making floors
674
+ // impossible to drag or delete after import.
675
+ // We intentionally do NOT filter by type prefix here; being permissive
676
+ // about which types are allowed as children prevents data loss when new
677
+ // child types are added to the schema in the future.
678
+ if (node.type === 'level') {
679
+ const rawChildren = getStringArray(node.children);
680
+ const validChildren = rawChildren.filter((childId) => {
681
+ const exists = Boolean(patchedNodes[childId]);
682
+ if (!exists) {
683
+ console.warn('[migrateNodes] level', id, 'references missing child', childId, '— dropping');
684
+ }
685
+ return exists;
686
+ });
687
+ const levelNumber = getFiniteNumber(node.level, 0);
688
+ patchedNodes[id] = {
689
+ ...node,
690
+ level: levelNumber,
691
+ children: validChildren,
692
+ };
693
+ }
694
+ }
695
+ // Pass 2: elevator migration.
696
+ // migrateElevatorParent mutates the parent level's children array (removes
697
+ // the elevator ID from it). Running this after Pass 1 guarantees that the
698
+ // level normalization above has already seen a clean children list — if we
699
+ // ran elevator migration inside Pass 1, the order of Object.entries
700
+ // iteration would be non-deterministic: processing an elevator before its
701
+ // parent level would mutate the level's children mid-iteration, potentially
702
+ // causing the level branch above to see a stale node reference.
703
+ for (const [id, node] of Object.entries(patchedNodes)) {
704
+ if (node.type !== 'elevator')
705
+ continue;
706
+ const parentMigrated = migrateElevatorParent(id, node, patchedNodes);
707
+ const normalized = normalizeElevatorNode(parentMigrated);
708
+ if (normalized) {
709
+ patchedNodes[id] = normalized;
710
+ }
479
711
  }
480
- return patchedNodes;
712
+ return { nodes: patchedNodes, mintedMaterials };
481
713
  }
482
714
  function getNodeChildIds(node) {
483
715
  if (!('children' in node && Array.isArray(node.children))) {
@@ -536,6 +768,9 @@ const useScene = create()(temporal((set, get) => ({
536
768
  dirtyNodes: new Set(),
537
769
  // 4. Collections
538
770
  collections: {},
771
+ materials: {},
772
+ installedPlugins: [],
773
+ hasExplicitPluginInstallState: false,
539
774
  // 5. Read-only lock
540
775
  readOnly: false,
541
776
  setReadOnly: (readOnly) => set({ readOnly }),
@@ -545,15 +780,25 @@ const useScene = create()(temporal((set, get) => ({
545
780
  rootNodeIds: [],
546
781
  dirtyNodes: new Set(),
547
782
  collections: {},
783
+ materials: {},
784
+ installedPlugins: [],
785
+ hasExplicitPluginInstallState: false,
548
786
  });
549
787
  },
550
788
  clearScene: () => {
789
+ const installedPlugins = get().installedPlugins;
790
+ const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState;
551
791
  get().unloadScene();
552
792
  get().loadScene(); // Default scene
793
+ set({ installedPlugins, hasExplicitPluginInstallState });
553
794
  },
554
- setScene: (nodes, rootNodeIds) => {
795
+ setScene: (nodes, rootNodeIds, extra) => {
555
796
  // Apply backward compatibility migrations
556
- const patchedNodes = migrateNodes(nodes);
797
+ const { nodes: patchedNodes, mintedMaterials } = migrateNodes(nodes);
798
+ // Scene materials minted by the wall legacy→slots migration join the
799
+ // loaded palette (existing refs win on id collision — there are none,
800
+ // ids are freshly generated).
801
+ const materials = { ...mintedMaterials, ...(extra?.materials ?? {}) };
557
802
  // Remove orphans: nodes whose parentId points to a non-existent node
558
803
  const cleanedNodes = { ...patchedNodes };
559
804
  for (const node of Object.values(cleanedNodes)) {
@@ -562,12 +807,6 @@ const useScene = create()(temporal((set, get) => ({
562
807
  delete cleanedNodes[node.id];
563
808
  }
564
809
  }
565
- set({
566
- nodes: cleanedNodes,
567
- rootNodeIds,
568
- dirtyNodes: new Set(),
569
- collections: {},
570
- });
571
810
  const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds);
572
811
  const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds);
573
812
  if (normalizedRootNodeIds.length > 0) {
@@ -578,17 +817,47 @@ const useScene = create()(temporal((set, get) => ({
578
817
  delete cleanedNodes[node.id];
579
818
  }
580
819
  }
820
+ // Single tracked `set`: with zundo, every tracked write pushes the
821
+ // pre-write state onto `pastStates`. Writing the scene in two steps
822
+ // (as this used to) exposed a half-normalized intermediate state —
823
+ // and the pre-load (possibly empty) state — as undo targets.
581
824
  set({
582
825
  nodes: cleanedNodes,
583
826
  rootNodeIds: normalizedRootNodeIds,
584
827
  dirtyNodes: new Set(),
585
- collections: {},
828
+ collections: extra?.collections ?? {},
829
+ materials,
830
+ installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
831
+ hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
586
832
  });
587
833
  // Mark all nodes as dirty to trigger re-validation
588
834
  Object.values(cleanedNodes).forEach((node) => {
589
835
  get().markDirty(node.id);
590
836
  });
591
837
  },
838
+ setInstalledPlugins: (pluginIds, options) => {
839
+ if (get().readOnly)
840
+ return;
841
+ const nextInstalledPlugins = Array.from(new Set(pluginIds));
842
+ const previousInstalledPlugins = get().installedPlugins;
843
+ const dirtyNodes = new Set(get().dirtyNodes);
844
+ for (const node of Object.values(get().nodes)) {
845
+ if (!getNodePluginId(node.type))
846
+ continue;
847
+ if (!isNodeKindEnabled(node.type, nextInstalledPlugins)) {
848
+ dirtyNodes.delete(node.id);
849
+ }
850
+ else if (!isNodeKindEnabled(node.type, previousInstalledPlugins)) {
851
+ if (nodeRegistry.get(node.type)?.dirtyTracking !== false)
852
+ dirtyNodes.add(node.id);
853
+ }
854
+ }
855
+ set({
856
+ installedPlugins: nextInstalledPlugins,
857
+ hasExplicitPluginInstallState: options?.explicit ?? get().hasExplicitPluginInstallState,
858
+ dirtyNodes,
859
+ });
860
+ },
592
861
  loadScene: () => {
593
862
  if (get().rootNodeIds.length > 0) {
594
863
  // Assign all nodes as dirty to force re-validation
@@ -619,6 +888,11 @@ const useScene = create()(temporal((set, get) => ({
619
888
  set({ nodes, rootNodeIds });
620
889
  },
621
890
  markDirty: (id) => {
891
+ const node = get().nodes[id];
892
+ if (node && !isNodeKindEnabled(node.type, get().installedPlugins))
893
+ return;
894
+ if (node && nodeRegistry.get(node.type)?.dirtyTracking === false)
895
+ return;
622
896
  get().dirtyNodes.add(id);
623
897
  },
624
898
  clearDirty: (id) => {
@@ -730,10 +1004,37 @@ const useScene = create()(temporal((set, get) => ({
730
1004
  return { collections: nextCollections, nodes: nextNodes };
731
1005
  });
732
1006
  },
1007
+ // --- SCENE MATERIALS ---
1008
+ addSceneMaterial: (material) => {
1009
+ if (get().readOnly)
1010
+ return;
1011
+ set((state) => ({
1012
+ materials: { ...state.materials, [material.id]: material },
1013
+ }));
1014
+ },
1015
+ updateSceneMaterial: (id, data) => {
1016
+ if (get().readOnly)
1017
+ return;
1018
+ set((state) => {
1019
+ const material = state.materials[id];
1020
+ if (!material)
1021
+ return state;
1022
+ return { materials: { ...state.materials, [id]: { ...material, ...data } } };
1023
+ });
1024
+ },
1025
+ removeSceneMaterial: (id) => {
1026
+ if (get().readOnly)
1027
+ return;
1028
+ set((state) => {
1029
+ const materials = { ...state.materials };
1030
+ delete materials[id];
1031
+ return { materials };
1032
+ });
1033
+ },
733
1034
  }), {
734
1035
  partialize: (state) => {
735
- const { nodes, rootNodeIds, collections } = state;
736
- return { nodes, rootNodeIds, collections };
1036
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } = state;
1037
+ return { nodes, rootNodeIds, collections, materials, installedPlugins };
737
1038
  },
738
1039
  limit: 50, // Limit to last 50 actions
739
1040
  }));
@@ -744,6 +1045,12 @@ let prevFutureLength = 0;
744
1045
  let prevNodesSnapshot = null;
745
1046
  export function clearSceneHistory() {
746
1047
  resetSceneHistoryPauseDepth();
1048
+ // Resetting the pause-depth counter without resuming would strand the
1049
+ // temporal store in `isTracking: false` if a pause window was active when
1050
+ // the scene was (re)loaded — every edit after the load would then be
1051
+ // invisible to undo. Resume unconditionally so the cleared history starts
1052
+ // tracking from the loaded baseline.
1053
+ useScene.temporal.getState().resume();
747
1054
  useScene.temporal.getState().clear();
748
1055
  prevPastLength = 0;
749
1056
  prevFutureLength = 0;
@@ -51,7 +51,7 @@ export function resolveElevatorServiceLevels(elevator, nodes) {
51
51
  }
52
52
  export function getElevatorLevelHeight(levelId, nodes) {
53
53
  const level = nodes[levelId];
54
- if (!level || level.type !== 'level')
54
+ if (level?.type !== 'level')
55
55
  return DEFAULT_ELEVATOR_LEVEL_HEIGHT;
56
56
  let maxTop = 0;
57
57
  for (const childId of level.children) {
@@ -0,0 +1,12 @@
1
+ import type { FenceNode } from '../../schema';
2
+ import type { Point2D } from '../wall/wall-mitering';
3
+ type CurveFrame = {
4
+ point: Point2D;
5
+ tangent: Point2D;
6
+ normal: Point2D;
7
+ };
8
+ export declare function getFenceCenterlineFrameAt(fence: FenceNode, t: number): CurveFrame;
9
+ export declare function sampleFenceCenterline(fence: FenceNode, segments?: number): Point2D[];
10
+ export declare function getFenceCenterlineLength(fence: FenceNode, segments?: number): number;
11
+ export {};
12
+ //# sourceMappingURL=fence-centerline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fence-centerline.d.ts","sourceRoot":"","sources":["../../../src/systems/fence/fence-centerline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAE7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAA;AAqBpD,KAAK,UAAU,GAAG;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;CAChB,CAAA;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,UAAU,CAKjF;AAED,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,SAAS,EAChB,QAAQ,SAA0B,GACjC,OAAO,EAAE,CASX;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,SAAS,EAChB,QAAQ,SAA0B,GACjC,MAAM,CAOR"}
@@ -0,0 +1,37 @@
1
+ import { getWallCurveFrameAt, getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve';
2
+ import { getFenceSplineFrameAt, getFenceSplineLength, isSplineFence, sampleFenceSpline, } from './fence-spline';
3
+ /**
4
+ * Unified fence centerline accessors. A fence is either:
5
+ * - a spline fence (`path` of >= 2 control points) → smooth Catmull-Rom, or
6
+ * - a straight / single-arc fence (`start`/`end` + optional `curveOffset`).
7
+ *
8
+ * These wrappers branch on `isSplineFence` and return the SAME shapes the wall
9
+ * arc helpers return, so every consumer (3D geometry, 2D floor-plan, length,
10
+ * handles) can sample the centerline without caring which kind it is. Wall arc
11
+ * math in `wall-curve.ts` is untouched — walls never carry a `path`.
12
+ */
13
+ const DEFAULT_SAMPLE_SEGMENTS = 96;
14
+ export function getFenceCenterlineFrameAt(fence, t) {
15
+ if (isSplineFence(fence) && fence.path) {
16
+ return getFenceSplineFrameAt(fence.path, t, fence.tangents);
17
+ }
18
+ return getWallCurveFrameAt(fence, t);
19
+ }
20
+ export function sampleFenceCenterline(fence, segments = DEFAULT_SAMPLE_SEGMENTS) {
21
+ if (isSplineFence(fence) && fence.path) {
22
+ // Spread the requested sample budget across the spans so a long path still
23
+ // reads smoothly without exploding the point count.
24
+ const spanCount = Math.max(1, fence.path.length - 1);
25
+ const perSpan = Math.max(2, Math.ceil(segments / spanCount));
26
+ return sampleFenceSpline(fence.path, fence.tangents, perSpan);
27
+ }
28
+ return sampleWallCenterline(fence, segments);
29
+ }
30
+ export function getFenceCenterlineLength(fence, segments = DEFAULT_SAMPLE_SEGMENTS) {
31
+ if (isSplineFence(fence) && fence.path) {
32
+ const spanCount = Math.max(1, fence.path.length - 1);
33
+ const perSpan = Math.max(2, Math.ceil(segments / spanCount));
34
+ return getFenceSplineLength(fence.path, fence.tangents, perSpan);
35
+ }
36
+ return getWallCurveLength(fence, segments);
37
+ }
@@ -0,0 +1,39 @@
1
+ import type { FenceNode } from '../../schema';
2
+ import type { Point2D } from '../wall/wall-mitering';
3
+ type FenceSplineLike = Pick<FenceNode, 'path'>;
4
+ type TangentList = ReadonlyArray<readonly [number, number] | null> | undefined;
5
+ export declare function isSplineFence(fence: FenceSplineLike): boolean;
6
+ type CurveFrame = {
7
+ point: Point2D;
8
+ tangent: Point2D;
9
+ normal: Point2D;
10
+ };
11
+ /**
12
+ * OUT-handle offset vector for control point `index` — the stored tangent if
13
+ * the user has adjusted it, otherwise the automatic distance-aware tangent
14
+ * (endpoints duplicate the neighbour so the ends stay tangent to their single
15
+ * span). The IN handle is the negation of this.
16
+ *
17
+ * Exported so the editing UI can draw the tangent line / handle dots at the
18
+ * right place even before the user has dragged them.
19
+ */
20
+ export declare function getFenceControlHandle(path: ReadonlyArray<readonly [number, number]>, tangents: TangentList, index: number): Point2D;
21
+ export declare function getTwoPointFenceCurveTangents(path: ReadonlyArray<readonly [number, number]>): Array<[number, number] | null> | undefined;
22
+ /**
23
+ * Sample the spline centerline into a polyline. Control points are honored as
24
+ * on-curve anchors; `segmentsPerSpan` controls smoothness between them.
25
+ * Returns `(segmentsPerSpan * spanCount) + 1` points, first == path[0],
26
+ * last == path[-1].
27
+ */
28
+ export declare function sampleFenceSpline(path: ReadonlyArray<readonly [number, number]>, tangents?: TangentList, segmentsPerSpan?: number): Point2D[];
29
+ /**
30
+ * Frame (point + tangent + normal) at parameter `t` in [0, 1] along the spline
31
+ * centerline. Same return shape as `getWallCurveFrameAt` so it is a drop-in for
32
+ * the arc branch. `t` is uniform over the sampled polyline (arc length is not
33
+ * reparameterised — adequate for marching posts / rails and far cheaper).
34
+ */
35
+ export declare function getFenceSplineFrameAt(path: ReadonlyArray<readonly [number, number]>, t: number, tangents?: TangentList, segmentsPerSpan?: number): CurveFrame;
36
+ /** Total polyline length of the sampled spline centerline. */
37
+ export declare function getFenceSplineLength(path: ReadonlyArray<readonly [number, number]>, tangents?: TangentList, segmentsPerSpan?: number): number;
38
+ export {};
39
+ //# sourceMappingURL=fence-spline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fence-spline.d.ts","sourceRoot":"","sources":["../../../src/systems/fence/fence-spline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAA;AA0BpD,KAAK,eAAe,GAAG,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAA;AAC9C,KAAK,WAAW,GAAG,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,SAAS,CAAA;AAE9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAE7D;AAED,KAAK,UAAU,GAAG;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;CAChB,CAAA;AAkBD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC9C,QAAQ,EAAE,WAAW,EACrB,KAAK,EAAE,MAAM,GACZ,OAAO,CAiCT;AAED,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAC7C,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,SAAS,CAyB5C;AAkBD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC9C,QAAQ,CAAC,EAAE,WAAW,EACtB,eAAe,SAA4B,GAC1C,OAAO,EAAE,CA0BX;AA4CD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC9C,CAAC,EAAE,MAAM,EACT,QAAQ,CAAC,EAAE,WAAW,EACtB,eAAe,SAA4B,GAC1C,UAAU,CAEZ;AAED,8DAA8D;AAC9D,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC9C,QAAQ,CAAC,EAAE,WAAW,EACtB,eAAe,SAA4B,GAC1C,MAAM,CAOR"}