@pascal-app/editor 1.0.3 → 1.0.4

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 (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, emitter, type NodeEvent, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useThree } from '@react-three/fiber'
6
+ import { useEffect } from 'react'
7
+ import { selectionModifiersFromEvent } from '../../lib/selection-routing'
8
+ import {
9
+ applyViewerClick,
10
+ applyViewerHover,
11
+ viewerPickFromNodeEvent,
12
+ } from '../../lib/viewer-selection'
13
+
14
+ /**
15
+ * Selection for a parametric viewer surface (the published viewer's fallback,
16
+ * its embed, the editor's Preview): free-form and room-first, through the same
17
+ * rules as the baked viewer. Mount it with `<Viewer selectionManager="custom">`.
18
+ */
19
+ export function ViewerSelectionManager() {
20
+ const gl = useThree((state) => state.gl)
21
+
22
+ useEffect(() => {
23
+ let clickHandled = false
24
+ let hoveredFrom: string | null = null
25
+ const pendingClicks = new Set<number>()
26
+
27
+ const onMove = (event: NodeEvent) => {
28
+ if (useViewer.getState().walkthroughMode) return
29
+ const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
30
+ if (!pick) return
31
+ event.stopPropagation()
32
+ hoveredFrom = event.node.id
33
+ applyViewerHover(pick, useScene.getState().nodes)
34
+ }
35
+ const onLeave = (event: NodeEvent) => {
36
+ if (event.node.id !== hoveredFrom) return
37
+ hoveredFrom = null
38
+ useViewer.getState().setHoveredId(null)
39
+ }
40
+ const onClick = (event: NodeEvent) => {
41
+ if (useViewer.getState().walkthroughMode) return
42
+ const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
43
+ if (!pick) return
44
+ event.stopPropagation()
45
+ clickHandled = true
46
+ applyViewerClick(
47
+ pick,
48
+ useScene.getState().nodes,
49
+ selectionModifiersFromEvent(event.nativeEvent),
50
+ )
51
+ }
52
+ // A click on nothing selectable clears the room and the element.
53
+ const onCanvasClick = (event: MouseEvent) => {
54
+ const viewer = useViewer.getState()
55
+ if (event.button !== 0 || viewer.walkthroughMode) return
56
+ if (viewer.cameraDragging || viewer.inputDragging) return
57
+ const frame = requestAnimationFrame(() => {
58
+ pendingClicks.delete(frame)
59
+ if (useViewer.getState().walkthroughMode) return
60
+ if (clickHandled) {
61
+ clickHandled = false
62
+ return
63
+ }
64
+ applyViewerClick(null, useScene.getState().nodes)
65
+ })
66
+ pendingClicks.add(frame)
67
+ }
68
+
69
+ emitter.on('node:move', onMove)
70
+ emitter.on('node:enter', onMove)
71
+ emitter.on('node:leave', onLeave)
72
+ emitter.on('node:click', onClick)
73
+ const canvas = gl.domElement
74
+ canvas.addEventListener('click', onCanvasClick)
75
+ return () => {
76
+ emitter.off('node:move', onMove)
77
+ emitter.off('node:enter', onMove)
78
+ emitter.off('node:leave', onLeave)
79
+ emitter.off('node:click', onClick)
80
+ canvas.removeEventListener('click', onCanvasClick)
81
+ for (const frame of pendingClicks) cancelAnimationFrame(frame)
82
+ useViewer.getState().setHoveredId(null)
83
+ }
84
+ }, [gl])
85
+
86
+ return <ViewerOutlineSync />
87
+ }
88
+
89
+ /** The selected elements and the hovered one, outlined (rooms highlight instead). */
90
+ function ViewerOutlineSync() {
91
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
92
+ const externalSelectedIds = useViewer((s) => s.externalSelectedIds)
93
+ const hoveredId = useViewer((s) => s.hoveredId)
94
+ const outliner = useViewer((s) => s.outliner)
95
+ const geometryRevision = useViewer((s) => s.geometryRevision)
96
+ const walkthrough = useViewer((s) => s.walkthroughMode)
97
+ const nodes = useScene((s) => s.nodes)
98
+
99
+ useEffect(() => {
100
+ void geometryRevision
101
+ outliner.selectedObjects.length = 0
102
+ outliner.hoveredObjects.length = 0
103
+ if (walkthrough) return
104
+ for (const id of new Set([...selectedIds, ...externalSelectedIds])) {
105
+ const object = sceneRegistry.nodes.get(id)
106
+ if (object) outliner.selectedObjects.push(object)
107
+ }
108
+ const hovered = hoveredId ? nodes[hoveredId as AnyNodeId] : undefined
109
+ if (hovered && hovered.type !== 'zone') {
110
+ const object = sceneRegistry.nodes.get(hovered.id)
111
+ if (object) outliner.hoveredObjects.push(object)
112
+ }
113
+ }, [selectedIds, externalSelectedIds, hoveredId, outliner, nodes, geometryRevision, walkthrough])
114
+
115
+ useEffect(
116
+ () => () => {
117
+ outliner.selectedObjects.length = 0
118
+ outliner.hoveredObjects.length = 0
119
+ },
120
+ [outliner],
121
+ )
122
+
123
+ return null
124
+ }
@@ -60,7 +60,6 @@ function selectViewerLevel(nodes: Record<string, AnyNode>, levelId: string) {
60
60
  selectedIds: [],
61
61
  zoneId: null,
62
62
  })
63
- viewer.setLevelMode('solo')
64
63
  }
65
64
 
66
65
  export function ViewerStage({
@@ -137,7 +136,8 @@ export function ViewerStage({
137
136
  if (notify && nextLevelId !== useViewer.getState().selection.levelId) {
138
137
  markPerfAction('level-switch', nextLevelId)
139
138
  }
140
- selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
139
+ if (nextLevelId !== useViewer.getState().selection.levelId)
140
+ selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
141
141
  if (notify) onLevelChange?.(nextLevelId)
142
142
  },
143
143
  [onLevelChange, scene],
@@ -11,7 +11,6 @@ import useInteractionScope from '../store/use-interaction-scope'
11
11
  export const ViewerZoneSystem = () => {
12
12
  useFrame(() => {
13
13
  const { levelId, zoneId } = useViewer.getState().selection
14
- const structureLayer = useEditor.getState().structureLayer
15
14
  const nodes = useScene.getState().nodes
16
15
  // Snapshot capture is a clean, camera-only surface — zone geometry and
17
16
  // tags stay out of the framed shot (mirrors the editor ZoneSystem's gate).
@@ -29,17 +28,13 @@ export const ViewerZoneSystem = () => {
29
28
 
30
29
  const isOnSelectedLevel = zone.parentId === levelId
31
30
 
32
- // Keep group visible (so <Html> labels stay active), hide/show meshes only.
33
- // Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
34
- // The editor ZoneSystem handles the selected zone's opacity animation.
31
+ // Keep group visible (so <Html> labels stay active), hide/show meshes only:
32
+ // only the selected zone shows its volume.
35
33
  const isSelected = id === zoneId
36
34
  // A zone the author hid (sidebar eye) takes the group with it — this
37
35
  // per-frame write would otherwise undo the renderer's `visible` prop.
38
36
  const nodeVisible = zone.visible !== false
39
- const shouldShowGeometry =
40
- nodeVisible &&
41
- !isCaptureMode &&
42
- ((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
37
+ const shouldShowGeometry = nodeVisible && !isCaptureMode && isSelected
43
38
  if (obj.visible !== nodeVisible) obj.visible = nodeVisible
44
39
  obj.traverse((child) => {
45
40
  if ((child as Mesh).isMesh) {
@@ -11,6 +11,7 @@ import { create } from '@react-three/test-renderer'
11
11
  import { type SaveStatus, useAutoSave } from './use-auto-save'
12
12
 
13
13
  const originalWindow = globalThis.window
14
+ const originalRequestAnimationFrame = globalThis.requestAnimationFrame
14
15
  const previousScene = useScene.getState()
15
16
  let renderer: Awaited<ReturnType<typeof create>> | undefined
16
17
 
@@ -18,6 +19,7 @@ afterEach(async () => {
18
19
  await renderer?.unmount()
19
20
  renderer = undefined
20
21
  globalThis.window = originalWindow
22
+ globalThis.requestAnimationFrame = originalRequestAnimationFrame
21
23
  useScene.setState(previousScene, true)
22
24
  clearSceneHistory()
23
25
  })
@@ -42,11 +44,8 @@ function graph(count: number) {
42
44
  return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [site.id] }
43
45
  }
44
46
 
45
- async function mount(
46
- loadedCount: number,
47
- guardAgainstSceneWipe?: boolean,
48
- save?: () => Promise<void>,
49
- ) {
47
+ /** Mounts the hook in its initial state: a scene load not yet completed. */
48
+ async function mountBeforeLoad(guardAgainstSceneWipe?: boolean, save?: () => Promise<void>) {
50
49
  globalThis.window = new EventTarget() as unknown as Window & typeof globalThis
51
50
  useScene.setState({ ...graph(0), readOnly: false })
52
51
  clearSceneHistory()
@@ -65,10 +64,6 @@ async function mount(
65
64
  return null
66
65
  }
67
66
  renderer = await create(<Host guardAgainstSceneWipe={guardAgainstSceneWipe} />)
68
- useScene.setState(graph(loadedCount))
69
- clearSceneHistory()
70
- controls!.isLoadingSceneRef.current = false
71
- statuses.length = 0
72
67
  return {
73
68
  writes,
74
69
  statuses,
@@ -78,6 +73,125 @@ async function mount(
78
73
  }
79
74
  }
80
75
 
76
+ async function mount(
77
+ loadedCount: number,
78
+ guardAgainstSceneWipe?: boolean,
79
+ save?: () => Promise<void>,
80
+ ) {
81
+ const mounted = await mountBeforeLoad(guardAgainstSceneWipe, save)
82
+ useScene.setState(graph(loadedCount))
83
+ clearSceneHistory()
84
+ mounted.controls.completeSceneLoad()
85
+ mounted.statuses.length = 0
86
+ return mounted
87
+ }
88
+
89
+ /** The Editor's load sequence: begin, unload, hydrate the store, complete. */
90
+ function loadScene(controls: ReturnType<typeof useAutoSave>, count: number) {
91
+ controls.beginSceneLoad()
92
+ useScene.getState().unloadScene()
93
+ hydrate(count)
94
+ controls.completeSceneLoad()
95
+ }
96
+
97
+ function hydrate(count: number) {
98
+ const { nodes, rootNodeIds } = graph(count)
99
+ useScene.getState().setScene(nodes as never, rootNodeIds as never)
100
+ clearSceneHistory()
101
+ }
102
+
103
+ function agentAddsColumn() {
104
+ useScene
105
+ .getState()
106
+ .createNode(
107
+ ColumnNode.parse({ id: 'column_agent', parentId: 'level_autosave' }),
108
+ 'level_autosave' as never,
109
+ )
110
+ }
111
+
112
+ /** A hidden tab: requestAnimationFrame callbacks never run. */
113
+ function hideDocument() {
114
+ const frames: FrameRequestCallback[] = []
115
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
116
+ frames.push(callback)
117
+ return frames.length
118
+ }) as typeof requestAnimationFrame
119
+ return frames
120
+ }
121
+
122
+ test('a scene loaded in a hidden tab autosaves agent edits without waiting for a frame', async () => {
123
+ hideDocument()
124
+ const { writes, statuses, controls } = await mountBeforeLoad()
125
+ statuses.length = 0
126
+ // Pre-hydration document writes (the host-panel installedPlugins sync on
127
+ // mount) still must not arm a save of the empty store.
128
+ useScene.getState().setInstalledPlugins(['pre-hydration-default'], { explicit: false })
129
+ loadScene(controls, 12)
130
+ expect(statuses).toEqual([])
131
+
132
+ agentAddsColumn()
133
+ await Bun.sleep(1100)
134
+ expect(writes).toEqual([13])
135
+ expect(statuses.at(-1)).toBe('saved')
136
+ })
137
+
138
+ test('opening a scene and leaving it unchanged saves nothing', async () => {
139
+ const { writes, statuses, controls } = await mountBeforeLoad()
140
+ loadScene(controls, 12)
141
+ statuses.length = 0
142
+ // Mount-time writes after the load: systems re-marking nodes, and a plugin
143
+ // that registers late syncing the default plugin set.
144
+ useScene.getState().markDirty('level_autosave' as never)
145
+ useScene.getState().setInstalledPlugins(['late-default'], { explicit: false })
146
+ await Bun.sleep(1100)
147
+ window.dispatchEvent(new Event('pagehide'))
148
+ expect(writes).toEqual([])
149
+ expect(statuses).toEqual([])
150
+ })
151
+
152
+ test('installing a plugin by hand after the load is saved', async () => {
153
+ const { writes, controls } = await mountBeforeLoad()
154
+ loadScene(controls, 12)
155
+ useScene.getState().setInstalledPlugins(['chosen'], { explicit: true })
156
+ await Bun.sleep(1100)
157
+ expect(writes).toEqual([12])
158
+ })
159
+
160
+ test('the exit flush writes an agent edit made right after a hidden load', async () => {
161
+ hideDocument()
162
+ const { writes, statuses, controls } = await mountBeforeLoad()
163
+ loadScene(controls, 12)
164
+ agentAddsColumn()
165
+ window.dispatchEvent(new Event('pagehide'))
166
+ expect(writes).toEqual([13])
167
+ expect(statuses).not.toContain('error')
168
+ })
169
+
170
+ test('the exit flush still skips while a load is in flight', async () => {
171
+ const { writes, controls } = await mount(12)
172
+ agentAddsColumn()
173
+ controls.beginSceneLoad()
174
+ useScene.getState().unloadScene()
175
+ window.dispatchEvent(new Event('pagehide'))
176
+ expect(writes).toEqual([])
177
+ })
178
+
179
+ test('a save that came due mid-load is written once the load completes', async () => {
180
+ const { writes, statuses, controls } = await mount(12)
181
+ agentAddsColumn()
182
+ controls.beginSceneLoad()
183
+ useScene.getState().unloadScene()
184
+ await Bun.sleep(1100)
185
+ expect(writes).toEqual([])
186
+ expect(statuses.at(-1)).toBe('paused')
187
+
188
+ hydrate(12)
189
+ controls.completeSceneLoad()
190
+ await Bun.sleep(0)
191
+ expect(writes).toEqual([12])
192
+ expect(statuses.at(-1)).toBe('saved')
193
+ })
194
+
81
195
  test.each([
82
196
  'undo',
83
197
  'redo',
@@ -1,8 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import { useScene } from '@pascal-app/core'
4
- import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
5
- import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
4
+ import { useCallback, useEffect, useRef } from 'react'
5
+ import { getSavedSceneDocument, type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
6
6
 
7
7
  const AUTOSAVE_DEBOUNCE_MS = 1000
8
8
  const STRUCTURAL_NODE_COUNT = 4
@@ -105,7 +105,8 @@ export function useAutoSave({
105
105
  onSaveStatusChange,
106
106
  isVersionPreviewMode = false,
107
107
  }: UseAutoSaveOptions): {
108
- isLoadingSceneRef: MutableRefObject<boolean>
108
+ beginSceneLoad: () => void
109
+ completeSceneLoad: () => void
109
110
  saveNow: () => void
110
111
  } {
111
112
  const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
@@ -169,7 +170,8 @@ export function useAutoSave({
169
170
  return
170
171
  }
171
172
 
172
- const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
173
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } =
174
+ getSavedSceneDocument()
173
175
  const sceneGraph = {
174
176
  nodes,
175
177
  rootNodeIds,
@@ -237,18 +239,23 @@ export function useAutoSave({
237
239
  return
238
240
  }
239
241
 
242
+ // A plugin set the user never chose is the host's defaults, re-derived on
243
+ // every load: a plugin registering after the load (hosted plugins arrive
244
+ // late) syncs it without anyone editing the scene.
245
+ const pluginsEdited =
246
+ state.installedPlugins !== lastInstalledPluginsRef && state.hasExplicitPluginInstallState
247
+ lastInstalledPluginsRef = state.installedPlugins
240
248
  const currentNodesSnapshot = JSON.stringify(state.nodes)
241
249
  const changed =
242
250
  currentNodesSnapshot !== lastNodesSnapshot ||
243
251
  state.collections !== lastCollectionsRef ||
244
252
  state.materials !== lastMaterialsRef ||
245
- state.installedPlugins !== lastInstalledPluginsRef
253
+ pluginsEdited
246
254
  if (!changed) return
247
255
 
248
256
  lastNodesSnapshot = currentNodesSnapshot
249
257
  lastCollectionsRef = state.collections
250
258
  lastMaterialsRef = state.materials
251
- lastInstalledPluginsRef = state.installedPlugins
252
259
  hasDirtyChangesRef.current = true
253
260
  onDirtyRef.current?.()
254
261
  setSaveStatus('pending')
@@ -272,7 +279,8 @@ export function useAutoSave({
272
279
  // would otherwise drop the change entirely. `pagehide` fires in cases
273
280
  // (mobile Safari, bfcache) where `beforeunload` does not.
274
281
  function flushOnExit() {
275
- const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
282
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } =
283
+ getSavedSceneDocument()
276
284
  const currentNodeCount = Object.keys(nodes).length
277
285
  const previousNodeCount = storedNodeCount.count
278
286
  const decision = decideExitFlush({
@@ -376,5 +384,23 @@ export function useAutoSave({
376
384
  executeSaveRef.current?.()
377
385
  }, [])
378
386
 
379
- return { isLoadingSceneRef, saveNow }
387
+ const beginSceneLoad = useCallback(() => {
388
+ isLoadingSceneRef.current = true
389
+ }, [])
390
+
391
+ // Call as soon as the store holds the loaded graph — hydration is data, not
392
+ // a frame. Gating this on requestAnimationFrame kept a tab loaded while
393
+ // hidden (no frames ever run) "loading" indefinitely: every later edit was
394
+ // adopted as the loaded baseline, so nothing autosaved and the exit flush
395
+ // had nothing dirty to write.
396
+ const completeSceneLoad = useCallback(() => {
397
+ isLoadingSceneRef.current = false
398
+ // A save that came due mid-load was deferred; the store now holds real
399
+ // data, so write it rather than leave the status parked on 'paused'.
400
+ if (pendingSaveRef.current && !isSavingRef.current && !saveTimeoutRef.current) {
401
+ executeSaveRef.current?.()
402
+ }
403
+ }, [])
404
+
405
+ return { beginSceneLoad, completeSceneLoad, saveNow }
380
406
  }
@@ -10,20 +10,29 @@ import {
10
10
  nodeRegistry,
11
11
  PipeFittingNode,
12
12
  PipeSegmentNode,
13
+ registerNode,
14
+ useInteractive,
13
15
  useScene,
14
16
  } from '@pascal-app/core'
17
+ import { useSidebarStore } from '../components/ui/primitives/sidebar'
15
18
  import { runRedo, runUndo } from '../lib/history'
16
19
  import { meshEditScope } from '../lib/interaction/scope'
20
+ import { setSidebarTabIds } from '../lib/sidebar-panel'
17
21
  import useEditor from '../store/use-editor'
18
22
  import useInteractionScope from '../store/use-interaction-scope'
19
23
  import {
24
+ armPaintFromShortcut,
25
+ armWallToolFromShortcut,
20
26
  blocksSnappingShortcut,
21
27
  canCycleSnappingModeShortcut,
22
28
  canRunGlobalRotationShortcut,
23
29
  isToolOwnedCanopyForm,
24
30
  isToolOwnedRotation,
25
31
  markToolCancelConsumed,
32
+ openItemCatalogFromShortcut,
26
33
  runHistoryShortcut,
34
+ runNodeInteraction,
35
+ switchPhaseFromShortcut,
27
36
  } from './use-keyboard'
28
37
 
29
38
  type RafFn = (callback: (time: number) => void) => number
@@ -106,11 +115,10 @@ describe('rotation shortcut ownership', () => {
106
115
  expect(isToolOwnedRotation()).toBe(true)
107
116
  })
108
117
 
109
- test('leaves R to the wall tool for its line / rectangle toggle, but not T', () => {
118
+ test('does not reserve R for the wall tool: its shape is picked in the Build panel', () => {
110
119
  useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
111
120
 
112
- expect(isToolOwnedRotation('r')).toBe(true)
113
- expect(isToolOwnedRotation('t')).toBe(false)
121
+ expect(isToolOwnedRotation()).toBe(false)
114
122
  })
115
123
 
116
124
  test('leaves R and T to a moving lean-to extension', () => {
@@ -249,3 +257,170 @@ describe('history while drawing distribution runs', () => {
249
257
  })
250
258
  }
251
259
  })
260
+
261
+ describe('E on a single selected node', () => {
262
+ function registerKind(overrides: Record<string, unknown>) {
263
+ const kind = `test:e-${crypto.randomUUID()}`
264
+ registerNode({
265
+ kind,
266
+ schemaVersion: 1,
267
+ category: 'furnish',
268
+ defaults: () => ({}),
269
+ capabilities: {},
270
+ ...overrides,
271
+ } as never)
272
+ return { object: 'node', id: `${kind}_1`, type: kind } as unknown as AnyNode
273
+ }
274
+
275
+ const switchMechanism = {
276
+ has: () => true,
277
+ isOn: (node: AnyNode, state: ReturnType<typeof useInteractive.getState>) =>
278
+ Boolean(state.mechanisms[node.id]),
279
+ set: (node: AnyNode, on: boolean) => useInteractive.getState().setMechanism(node.id, on),
280
+ }
281
+
282
+ test('runs the mechanism of a kind that declares only the capability', () => {
283
+ const node = registerKind({ capabilities: { mechanism: switchMechanism } })
284
+ try {
285
+ expect(runNodeInteraction(node)).toBe(true)
286
+ expect(useInteractive.getState().mechanisms[node.id]).toBe(true)
287
+ expect(runNodeInteraction(node)).toBe(true)
288
+ expect(useInteractive.getState().mechanisms[node.id]).toBe(false)
289
+ } finally {
290
+ useInteractive.getState().removeMechanism(node.id)
291
+ }
292
+ })
293
+
294
+ test("prefers the kind's own E action over its mechanism", () => {
295
+ let ran = 0
296
+ const node = registerKind({
297
+ capabilities: { mechanism: switchMechanism },
298
+ keyboardActions: { e: { appliesTo: () => true, run: () => (ran += 1) } },
299
+ })
300
+ expect(runNodeInteraction(node)).toBe(true)
301
+ expect(ran).toBe(1)
302
+ expect(useInteractive.getState().mechanisms[node.id]).toBeUndefined()
303
+ })
304
+
305
+ test('leaves E to the door and window fallbacks when nothing applies', () => {
306
+ const node = registerKind({
307
+ capabilities: { mechanism: { ...switchMechanism, has: () => false } },
308
+ })
309
+ expect(runNodeInteraction(node)).toBe(false)
310
+ expect(runNodeInteraction(registerKind({}))).toBe(false)
311
+ })
312
+ })
313
+
314
+ describe('B and P open their panel', () => {
315
+ afterEach(() => {
316
+ setSidebarTabIds([])
317
+ useSidebarStore.getState().setIsCollapsed(false)
318
+ useEditor.getState().setContinuation('wall', 'room')
319
+ useEditor.getState().armToolMode({ mode: 'select' })
320
+ })
321
+
322
+ test('1 goes to site and 2 into the building; 3 is no longer a shortcut', () => {
323
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
324
+ expect(switchPhaseFromShortcut('1')).toBe(true)
325
+ expect(useEditor.getState().phase).toBe('site')
326
+ expect(useEditor.getState().mode).toBe('select')
327
+ expect(switchPhaseFromShortcut('2')).toBe(true)
328
+ expect(useEditor.getState().phase).toBe('building')
329
+ expect(switchPhaseFromShortcut('3')).toBe(false)
330
+ expect(useEditor.getState().phase).toBe('building')
331
+ })
332
+
333
+ test('F, B and P arm their tool and keep the building', () => {
334
+ setSidebarTabIds(['site', 'build', 'paint', 'items'])
335
+ useEditor.getState().setPhase('building')
336
+ for (const [arm, mode, tool] of [
337
+ [openItemCatalogFromShortcut, 'build', 'item'],
338
+ [armWallToolFromShortcut, 'build', 'wall'],
339
+ [armPaintFromShortcut, 'material-paint', null],
340
+ ] as const) {
341
+ arm()
342
+ expect(useEditor.getState().phase).toBe('building')
343
+ expect(useEditor.getState().mode).toBe(mode)
344
+ expect(useEditor.getState().tool).toBe(tool)
345
+ }
346
+ openItemCatalogFromShortcut()
347
+ expect(useEditor.getState().activeSidebarPanel).toBe('items')
348
+ })
349
+
350
+ test('F, B and P also keep Site when arming their tool', () => {
351
+ setSidebarTabIds(['site', 'build', 'paint', 'items'])
352
+ for (const arm of [
353
+ openItemCatalogFromShortcut,
354
+ armWallToolFromShortcut,
355
+ armPaintFromShortcut,
356
+ ]) {
357
+ useEditor.getState().setPhase('site')
358
+ useEditor.getState().armToolMode({ mode: 'select' })
359
+ arm()
360
+ expect(useEditor.getState().phase).toBe('site')
361
+ }
362
+ })
363
+
364
+ test('F opens a collapsed catalog sidebar', () => {
365
+ setSidebarTabIds(['site', 'build', 'items'])
366
+ useSidebarStore.getState().setIsCollapsed(true)
367
+ openItemCatalogFromShortcut()
368
+ expect(useEditor.getState().activeSidebarPanel).toBe('items')
369
+ expect(useSidebarStore.getState().isCollapsed).toBe(false)
370
+ })
371
+
372
+ test('B arms the wall tool on Rectangle and shows the Build panel', () => {
373
+ setSidebarTabIds(['site', 'build', 'paint', 'items'])
374
+ useEditor.getState().setActiveSidebarPanel('items')
375
+ useSidebarStore.getState().setIsCollapsed(true)
376
+ useEditor.getState().setContinuation('wall', 'single')
377
+
378
+ armWallToolFromShortcut()
379
+
380
+ const editor = useEditor.getState()
381
+ expect(editor.mode).toBe('build')
382
+ expect(editor.tool).toBe('wall')
383
+ expect(editor.getContinuation('wall')).toBe('rectangle')
384
+ expect(editor.activeSidebarPanel).toBe('build')
385
+ expect(useSidebarStore.getState().isCollapsed).toBe(false)
386
+ })
387
+
388
+ // A host may drop the armed tool when the panel changes (the community does,
389
+ // with any placement in flight), so B and F open their panel first.
390
+ test('B and F keep their tool from another panel where the host disarms on a panel change', () => {
391
+ setSidebarTabIds(['build', 'paint', 'items', 'ai'])
392
+ const unsubscribe = useEditor.subscribe((state, prev) => {
393
+ if (state.activeSidebarPanel !== prev.activeSidebarPanel) {
394
+ useEditor.getState().armToolMode({ mode: 'select' })
395
+ }
396
+ })
397
+ try {
398
+ for (const [arm, from, panel, tool] of [
399
+ [armWallToolFromShortcut, 'ai', 'build', 'wall'],
400
+ [openItemCatalogFromShortcut, 'paint', 'items', 'item'],
401
+ ] as const) {
402
+ useEditor.getState().setActiveSidebarPanel(from)
403
+ arm()
404
+ const editor = useEditor.getState()
405
+ expect(editor.activeSidebarPanel).toBe(panel)
406
+ expect(editor.mode).toBe('build')
407
+ expect(editor.tool).toBe(tool)
408
+ }
409
+ } finally {
410
+ unsubscribe()
411
+ }
412
+ })
413
+
414
+ test('P arms paint and shows the Paint panel, or Build where the host has none', () => {
415
+ setSidebarTabIds(['build', 'paint', 'items'])
416
+ useEditor.getState().setActiveSidebarPanel('items')
417
+ armPaintFromShortcut()
418
+ expect(useEditor.getState().mode).toBe('material-paint')
419
+ expect(useEditor.getState().activeSidebarPanel).toBe('paint')
420
+
421
+ setSidebarTabIds(['site', 'build', 'items'])
422
+ useEditor.getState().setActiveSidebarPanel('items')
423
+ armPaintFromShortcut()
424
+ expect(useEditor.getState().activeSidebarPanel).toBe('build')
425
+ })
426
+ })